2025-07-24 21:49:44 -05:00

812 lines
18 KiB
Lua

local http = {}
-- Global routing tables
_G._http_routes = _G._http_routes or {}
_G._http_middleware = _G._http_middleware or {}
-- Request/Response classes
local Request = {}
Request.__index = Request
local Response = {}
Response.__index = Response
-- ======================================================================
-- ENHANCED COOKIE PARSING
-- ======================================================================
local function parse_cookies(cookie_header)
local cookies = {}
if string.is_empty(cookie_header) then
return cookies
end
-- Split by semicolon and parse each cookie
local cookie_pairs = string.split(cookie_header, ";")
for _, cookie_pair in ipairs(cookie_pairs) do
local trimmed = string.trim(cookie_pair)
if not string.is_empty(trimmed) then
local parts = string.split(trimmed, "=")
if #parts >= 2 then
local name = string.trim(parts[1])
local value = string.trim(parts[2])
-- URL decode the value
local success, decoded = pcall(function()
return string.url_decode(value)
end)
cookies[name] = success and decoded or value
elseif #parts == 1 then
-- Cookie without value
cookies[string.trim(parts[1])] = ""
end
end
end
return cookies
end
-- ======================================================================
-- ENHANCED ROUTER IMPLEMENTATION
-- ======================================================================
local function split_path(path)
if string.is_empty(path) or path == "/" then
return {}
end
-- Remove leading/trailing slashes and split
local clean_path = string.trim(path, "/")
if string.is_empty(clean_path) then
return {}
end
return string.split(clean_path, "/")
end
local function match_route(method, path)
local path_segments = split_path(path)
for _, route in ipairs(_G._http_routes) do
if route.method == method then
local params = {}
local route_segments = route.segments
local match = true
local i = 1
while i <= #route_segments and match do
local route_seg = route_segments[i]
if route_seg == "*" then
-- Wildcard captures everything remaining
local remaining = {}
for j = i, #path_segments do
table.insert(remaining, path_segments[j])
end
params["*"] = string.join(remaining, "/")
break
elseif string.starts_with(route_seg, ":") then
-- Parameter segment
if i <= #path_segments then
local param_name = string.slice(route_seg, 2, -1)
params[param_name] = path_segments[i]
else
match = false
end
else
-- Static segment
if i > #path_segments or route_seg ~= path_segments[i] then
match = false
end
end
i = i + 1
end
-- Must consume all segments unless wildcard
if match and (i > #path_segments or (route_segments[i-1] and route_segments[i-1] == "*")) then
return route, params
end
end
end
return nil, {}
end
function _http_handle_request(req_table, res_table)
local req = Request.new(req_table)
local res = Response.new(res_table)
-- Execute middleware chain first
local function run_middleware(index)
if index > #_G._http_middleware then
local route, params = match_route(req.method, req.path)
req.params = params
if not route then
res:status(404):send("Not Found")
return
end
-- Run route handler
route.handler(req, res)
return
end
local mw = _G._http_middleware[index]
if mw.path == nil or string.starts_with(req.path, mw.path) then
mw.handler(req, res, function()
run_middleware(index + 1)
end)
else
run_middleware(index + 1)
end
end
run_middleware(1)
end
-- Functions for route synchronization
function _http_get_routes()
return {
routes = _G._http_routes,
middleware = _G._http_middleware
}
end
function _http_sync_worker_routes()
local data = _G._http_routes_data
if data and data.routes and data.middleware then
_G._http_routes = data.routes
_G._http_middleware = data.middleware
end
end
-- ======================================================================
-- SERVER CLASS
-- ======================================================================
local Server = {}
Server.__index = Server
function http.server()
if _G.__IS_WORKER then
return setmetatable({}, Server)
end
local server = setmetatable({
_server_created = false
}, Server)
local success, err = moonshark.http_create_server()
if not success then
error("Failed to create HTTP server: " .. (err or "unknown error"))
end
server._server_created = true
return server
end
function Server:use(...)
local args = {...}
if #args == 1 and type(args[1]) == "function" then
table.insert(_G._http_middleware, {path = nil, handler = args[1]})
elseif #args == 2 and type(args[1]) == "string" and type(args[2]) == "function" then
table.insert(_G._http_middleware, {path = args[1], handler = args[2]})
else
error("Invalid arguments to use()")
end
return self
end
function Server:_add_route(method, path, handler)
-- Ensure path starts with /
if not string.starts_with(path, "/") then
path = "/" .. path
end
local segments = split_path(path)
table.insert(_G._http_routes, {
method = method,
path = path,
segments = segments,
handler = handler
})
return self
end
-- HTTP method helpers
function Server:get(path, handler)
return self:_add_route("GET", path, handler)
end
function Server:post(path, handler)
return self:_add_route("POST", path, handler)
end
function Server:put(path, handler)
return self:_add_route("PUT", path, handler)
end
function Server:delete(path, handler)
return self:_add_route("DELETE", path, handler)
end
function Server:patch(path, handler)
return self:_add_route("PATCH", path, handler)
end
function Server:head(path, handler)
return self:_add_route("HEAD", path, handler)
end
function Server:options(path, handler)
return self:_add_route("OPTIONS", path, handler)
end
function Server:all(path, handler)
local methods = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
for _, method in ipairs(methods) do
self:_add_route(method, path, handler)
end
return self
end
function Server:listen(port, host, callback)
-- Workers should not listen
if _G.__IS_WORKER then
if callback then callback() end
return self
end
if type(host) == "function" then
callback = host
host = "localhost"
end
host = host or "localhost"
local addr = host .. ":" .. tostring(port)
-- Spawn workers first
local success, err = moonshark.http_spawn_workers()
if not success then
error("Failed to spawn workers: " .. (err or "unknown error"))
end
-- Then start listening
success, err = moonshark.http_listen(addr)
if not success then
error("Failed to start server: " .. (err or "unknown error"))
end
if callback then
callback()
end
return self
end
function Server:close()
if _G.__IS_WORKER then
return true
end
return moonshark.http_close_server()
end
-- ======================================================================
-- ENHANCED REQUEST OBJECT
-- ======================================================================
function Request.new(req_table)
local req = setmetatable({
method = req_table.method,
path = req_table.path,
query = req_table.query or {},
headers = req_table.headers or {},
params = {},
body = req_table.body or "",
cookies = {}
}, Request)
local cookie_header = req.headers["Cookie"] or req.headers["cookie"]
if cookie_header then
req.cookies = parse_cookies(cookie_header)
end
return req
end
function Request:get(header_name)
local lower_name = string.lower(header_name)
return self.headers[header_name] or self.headers[lower_name]
end
function Request:header(header_name)
return self:get(header_name)
end
function Request:param(name, default_value)
return self.params[name] or default_value
end
function Request:query_param(name, default_value)
return self.query[name] or default_value
end
function Request:cookie(name, default_value)
return self.cookies[name] or default_value
end
function Request:has_cookie(name)
return self.cookies[name] ~= nil
end
function Request:get_cookies()
-- Return a copy to prevent modification
local copy = {}
for k, v in pairs(self.cookies) do
copy[k] = v
end
return copy
end
function Request:cookie_matches(name, pattern)
local cookie_value = self.cookies[name]
if not cookie_value then
return false
end
return string.match(pattern, cookie_value)
end
function Request:get_cookies_by_names(names)
local result = {}
for _, name in ipairs(names) do
result[name] = self.cookies[name]
end
return result
end
function Request:has_auth_cookies()
local auth_cookie_names = {"session", "token", "auth", "jwt", "access_token"}
for _, name in ipairs(auth_cookie_names) do
if self.cookies[name] then
return true
end
end
return false
end
function Request:json()
if string.is_empty(self.body) then
return nil
end
local success, result = pcall(function()
return json.decode(self.body)
end)
if success then
return result
else
error("Invalid JSON in request body")
end
end
function Request:is_json()
local content_type = self:get("content-type") or ""
return string.contains(content_type, "application/json")
end
function Request:is_form()
local content_type = self:get("content-type") or ""
return string.contains(content_type, "application/x-www-form-urlencoded")
end
function Request:is_multipart()
local content_type = self:get("content-type") or ""
return string.contains(content_type, "multipart/form-data")
end
function Request:is_xml()
local content_type = self:get("content-type") or ""
return string.contains(content_type, "application/xml") or string.contains(content_type, "text/xml")
end
function Request:accepts(mime_type)
local accept_header = self:get("accept") or ""
return string.contains(accept_header, mime_type) or string.contains(accept_header, "*/*")
end
function Request:user_agent()
return self:get("user-agent") or ""
end
function Request:ip()
return self:get("x-forwarded-for") or self:get("x-real-ip") or "unknown"
end
function Request:is_secure()
local proto = self:get("x-forwarded-proto")
return proto == "https" or string.starts_with(self:get("host") or "", "https://")
end
-- ======================================================================
-- ENHANCED RESPONSE OBJECT
-- ======================================================================
function Response.new(res_table)
local res = setmetatable({
_table = res_table,
_sent = false
}, Response)
return res
end
function Response:status(code)
if self._sent then
error("Cannot set status after response has been sent")
end
self._table.status = code
return self
end
function Response:header(name, value)
if self._sent then
error("Cannot set headers after response has been sent")
end
self._table.headers[name] = value
return self
end
function Response:set(name, value)
return self:header(name, value)
end
function Response:type(content_type)
return self:header("Content-Type", content_type)
end
function Response:send(data)
if self._sent then
error("Response already sent")
end
if type(data) == "table" then
self:json(data)
elseif type(data) == "number" then
self._table.status = data
self._table.body = ""
else
self._table.body = tostring(data or "")
end
self._sent = true
return self
end
function Response:json(data)
if self._sent then
error("Response already sent")
end
self:type("application/json; charset=utf-8")
local success, json_str = pcall(function()
return json.encode(data)
end)
if success then
self._table.body = json_str
else
error("Failed to encode JSON response")
end
self._sent = true
return self
end
function Response:text(text)
if self._sent then
error("Response already sent")
end
self:type("text/plain; charset=utf-8")
self._table.body = tostring(text or "")
self._sent = true
return self
end
function Response:html(html)
if self._sent then
error("Response already sent")
end
self:type("text/html; charset=utf-8")
self._table.body = tostring(html or "")
self._sent = true
return self
end
function Response:xml(xml)
if self._sent then
error("Response already sent")
end
self:type("application/xml; charset=utf-8")
self._table.body = tostring(xml or "")
self._sent = true
return self
end
function Response:redirect(url, status)
if self._sent then
error("Response already sent")
end
status = status or 302
self:status(status)
self:header("Location", url)
self._table.body = ""
self._sent = true
return self
end
function Response:cookie(name, value, options)
if self._sent then
error("Cannot set cookies after response has been sent")
end
options = options or {}
local cookie_value = tostring(value)
-- URL encode the cookie value if it contains special characters
if string.match("[;,\\s]", cookie_value) then
cookie_value = string.url_encode(cookie_value)
end
local cookie = name .. "=" .. cookie_value
if options.expires then
cookie = cookie .. "; Expires=" .. options.expires
end
if options.max_age then
cookie = cookie .. "; Max-Age=" .. tostring(options.max_age)
end
if options.domain then
cookie = cookie .. "; Domain=" .. options.domain
end
if options.path then
cookie = cookie .. "; Path=" .. options.path
end
if options.secure then
cookie = cookie .. "; Secure"
end
if options.http_only then
cookie = cookie .. "; HttpOnly"
end
if options.same_site then
cookie = cookie .. "; SameSite=" .. options.same_site
end
local existing = self._table.headers["Set-Cookie"]
if existing then
if type(existing) == "table" then
table.insert(existing, cookie)
else
self._table.headers["Set-Cookie"] = {existing, cookie}
end
else
self._table.headers["Set-Cookie"] = cookie
end
return self
end
function Response:clear_cookie(name, options)
options = options or {}
options.expires = "Thu, 01 Jan 1970 00:00:00 GMT"
options.max_age = 0
return self:cookie(name, "", options)
end
function Response:attachment(filename)
if filename then
self:header("Content-Disposition", 'attachment; filename="' .. filename .. '"')
else
self:header("Content-Disposition", "attachment")
end
return self
end
function Response:download(data, filename, content_type)
if filename then
self:attachment(filename)
end
if content_type then
self:type(content_type)
elseif filename then
-- Try to guess content type from extension
if string.ends_with(filename, ".pdf") then
self:type("application/pdf")
elseif string.ends_with(filename, ".zip") then
self:type("application/zip")
elseif string.ends_with(filename, ".json") then
self:type("application/json")
elseif string.ends_with(filename, ".csv") then
self:type("text/csv")
else
self:type("application/octet-stream")
end
else
self:type("application/octet-stream")
end
return self:send(data)
end
-- ======================================================================
-- MIDDLEWARE HELPERS
-- ======================================================================
function http.cors(options)
options = options or {}
local origin = options.origin or "*"
local methods = options.methods or "GET,HEAD,PUT,PATCH,POST,DELETE"
local headers = options.headers or "Content-Type,Authorization"
return function(req, res, next)
res:header("Access-Control-Allow-Origin", origin)
res:header("Access-Control-Allow-Methods", methods)
res:header("Access-Control-Allow-Headers", headers)
if options.credentials then
res:header("Access-Control-Allow-Credentials", "true")
end
if string.iequals(req.method, "OPTIONS") then
res:status(204):send("")
else
next()
end
end
end
function http.static(root_path, url_prefix)
url_prefix = url_prefix or "/"
-- Ensure prefix starts with /
if not string.starts_with(url_prefix, "/") then
url_prefix = "/" .. url_prefix
end
if not _G.__IS_WORKER then
local success, err = moonshark.http_register_static(url_prefix, root_path)
if not success then
error("Failed to register static handler: " .. (err or "unknown error"))
end
end
-- Return no-op middleware
return function(req, res, next)
next()
end
end
function http.json_parser()
return function(req, res, next)
if req:is_json() and not string.is_empty(req.body) then
local success, data = pcall(function()
return req:json()
end)
if success then
req.json_body = data
else
res:status(400):json({error = "Invalid JSON"})
return
end
end
next()
end
end
function http.logger(format)
format = format or "${method} ${path} ${status} ${response-time} ms"
return function(req, res, next)
local start_time = os.clock()
next()
local duration = (os.clock() - start_time) * 1000
local status = res._table.status or 200
local log_message = string.template(format, {
method = req.method,
path = req.path,
status = status,
["response-time"] = string.format("%.2f", duration),
["user-agent"] = req:user_agent(),
ip = req:ip()
})
print(log_message)
end
end
function http.compression()
return function(req, res, next)
local accept_encoding = req:get("accept-encoding") or ""
if string.contains(accept_encoding, "gzip") then
res:header("Content-Encoding", "gzip")
elseif string.contains(accept_encoding, "deflate") then
res:header("Content-Encoding", "deflate")
end
next()
end
end
function http.security()
return function(req, res, next)
res:header("X-Content-Type-Options", "nosniff")
res:header("X-Frame-Options", "DENY")
res:header("X-XSS-Protection", "1; mode=block")
res:header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
res:header("Referrer-Policy", "strict-origin-when-cross-origin")
next()
end
end
function http.rate_limit(options)
options = options or {}
local max_requests = options.max or 100
local window_ms = options.window or 60000 -- 1 minute
local clients = {}
return function(req, res, next)
local client_ip = req:ip()
local now = os.time() * 1000
if not clients[client_ip] then
clients[client_ip] = {count = 1, reset_time = now + window_ms}
else
local client = clients[client_ip]
if now > client.reset_time then
client.count = 1
client.reset_time = now + window_ms
else
client.count = client.count + 1
if client.count > max_requests then
res:status(429):json({
error = "Too many requests",
retry_after = math.ceil((client.reset_time - now) / 1000)
})
return
end
end
end
next()
end
end
function http.create_server(callback)
local app = http.server()
if callback then
callback(app)
end
return app
end
return http