Files
kovi/plugins/kovi.koplugin/raw_http.lua
T
2026-09-25 15:29:54 +01:00

177 lines
6.5 KiB
Lua

local socket = require("socket")
local urlmod = require("socket.url")
local M = {}
local function parse_target(target)
local ok, parsed = pcall(urlmod.parse, target)
if not ok or type(parsed) ~= "table" then
return nil, "invalid URL"
end
if parsed.scheme ~= "http" then
return nil, "direct transport only supports http:// URLs"
end
if not parsed.host or parsed.host == "" then
return nil, "URL has no host"
end
local port = tonumber(parsed.port) or 80
if port < 1 or port > 65535 then
return nil, "invalid port"
end
local path = parsed.path
if not path or path == "" then path = "/" end
if parsed.params and parsed.params ~= "" then path = path .. ";" .. parsed.params end
if parsed.query and parsed.query ~= "" then path = path .. "?" .. parsed.query end
local host_header = parsed.host
if parsed.host:find(":", 1, true) and parsed.host:sub(1, 1) ~= "[" then
host_header = "[" .. parsed.host .. "]"
end
if port ~= 80 then host_header = host_header .. ":" .. tostring(port) end
return {
host = parsed.host,
port = port,
path = path,
host_header = host_header,
}
end
local function close_quietly(tcp)
if tcp then pcall(function() tcp:close() end) end
end
function M.probe(target, timeout)
local parsed, parse_err = parse_target(target)
if not parsed then
return false, { stage = "url", detail = parse_err }
end
local tcp, socket_err = socket.tcp()
if not tcp then
return false, { stage = "socket", detail = tostring(socket_err or "cannot create TCP socket") }
end
tcp:settimeout(timeout or 6)
local connected, connect_err = tcp:connect(parsed.host, parsed.port)
close_quietly(tcp)
if not connected then
return false, {
stage = "tcp",
detail = tostring(connect_err or "connection failed"),
host = parsed.host,
port = parsed.port,
}
end
return true, { host = parsed.host, port = parsed.port }
end
local function decode_chunked(body)
local out = {}
local pos = 1
while true do
local line_end = body:find("\r\n", pos, true)
if not line_end then return nil, "invalid chunked response" end
local size_line = body:sub(pos, line_end - 1):match("^%s*([^;]+)")
local size = tonumber(size_line, 16)
if not size then return nil, "invalid chunk size" end
pos = line_end + 2
if size == 0 then return table.concat(out) end
local chunk_end = pos + size - 1
if chunk_end > #body then return nil, "truncated chunked response" end
out[#out + 1] = body:sub(pos, chunk_end)
pos = chunk_end + 1
if body:sub(pos, pos + 1) ~= "\r\n" then return nil, "invalid chunk separator" end
pos = pos + 2
end
end
function M.request(method, target, headers, body, timeout)
local parsed, parse_err = parse_target(target)
if not parsed then
return nil, nil, nil, nil, { stage = "url", detail = parse_err }
end
local tcp, socket_err = socket.tcp()
if not tcp then
return nil, nil, nil, nil, { stage = "socket", detail = tostring(socket_err or "cannot create TCP socket") }
end
tcp:settimeout(timeout or 12)
local connected, connect_err = tcp:connect(parsed.host, parsed.port)
if not connected then
close_quietly(tcp)
return nil, nil, nil, nil, {
stage = "tcp",
detail = tostring(connect_err or "connection failed"),
host = parsed.host,
port = parsed.port,
}
end
headers = headers or {}
local has_host, has_connection, has_length = false, false, false
local lines = { tostring(method or "GET") .. " " .. parsed.path .. " HTTP/1.1" }
for k, v in pairs(headers) do
local lower = tostring(k):lower()
if lower == "host" then has_host = true end
if lower == "connection" then has_connection = true end
if lower == "content-length" then has_length = true end
lines[#lines + 1] = tostring(k) .. ": " .. tostring(v)
end
if not has_host then lines[#lines + 1] = "Host: " .. parsed.host_header end
if not has_connection then lines[#lines + 1] = "Connection: close" end
if body ~= nil and not has_length then lines[#lines + 1] = "Content-Length: " .. tostring(#body) end
lines[#lines + 1] = ""
lines[#lines + 1] = ""
local payload = table.concat(lines, "\r\n") .. (body or "")
local sent, send_err, sent_partial = tcp:send(payload)
if not sent then
close_quietly(tcp)
return nil, nil, nil, nil, {
stage = "send",
detail = tostring(send_err or "send failed") .. (sent_partial and (" after " .. tostring(sent_partial) .. " bytes") or ""),
}
end
local chunks = {}
while true do
local chunk, receive_err, partial = tcp:receive(4096)
if chunk and #chunk > 0 then chunks[#chunks + 1] = chunk end
if partial and #partial > 0 then chunks[#chunks + 1] = partial end
if receive_err then
if receive_err ~= "closed" then
close_quietly(tcp)
return nil, nil, nil, nil, { stage = "receive", detail = tostring(receive_err) }
end
break
end
end
close_quietly(tcp)
local raw = table.concat(chunks)
local header_block, response_body = raw:match("^(.-)\r\n\r\n(.*)$")
if not header_block then
return nil, nil, nil, nil, { stage = "http", detail = "malformed HTTP response" }
end
local status_line, rest = header_block:match("^([^\r\n]+)\r\n(.*)$")
if not status_line then status_line, rest = header_block, "" end
local code = tonumber(status_line:match("^HTTP/%d+%.%d+%s+(%d%d%d)"))
if not code then
return nil, nil, nil, nil, { stage = "http", detail = "invalid status line: " .. tostring(status_line) }
end
local response_headers = {}
for line in (rest .. "\r\n"):gmatch("(.-)\r\n") do
local key, value = line:match("^([^:]+):%s*(.*)$")
if key then response_headers[key:lower()] = value end
end
if response_headers["transfer-encoding"] and response_headers["transfer-encoding"]:lower():find("chunked", 1, true) then
local decoded, chunk_err = decode_chunked(response_body)
if not decoded then
return nil, nil, nil, nil, { stage = "http", detail = chunk_err }
end
response_body = decoded
end
return code, response_headers, status_line, response_body, nil
end
return M