diff --git a/modules/http/http.lua b/modules/http/http.lua index fb3e24d..a4affdf 100644 --- a/modules/http/http.lua +++ b/modules/http/http.lua @@ -1,9 +1,8 @@ --- modules/http.lua - Express-like HTTP server with pure Lua routing - local http = {} local json = require("json") +local string = require("string") --- Global routing tables (shared across all states) +-- Global routing tables _G._http_routes = _G._http_routes or {} _G._http_middleware = _G._http_middleware or {} @@ -15,15 +14,57 @@ local Response = {} Response.__index = Response -- ====================================================================== --- ROUTER IMPLEMENTATION +-- 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) - local segments = {} - for segment in path:gmatch("[^/]+") do - table.insert(segments, segment) + if string.is_empty(path) or path == "/" then + return {} end - return segments + + -- 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) @@ -45,12 +86,12 @@ local function match_route(method, path) for j = i, #path_segments do table.insert(remaining, path_segments[j]) end - params["*"] = table.concat(remaining, "/") + params["*"] = string.join(remaining, "/") break - elseif route_seg:sub(1,1) == ":" then + elseif string.starts_with(route_seg, ":") then -- Parameter segment if i <= #path_segments then - local param_name = route_seg:sub(2) + local param_name = string.slice(route_seg, 2, -1) params[param_name] = path_segments[i] else match = false @@ -65,7 +106,7 @@ local function match_route(method, path) end -- Must consume all segments unless wildcard - if match and (i > #path_segments or route_segments[i-1] == "*") then + if match and (i > #path_segments or (route_segments[i-1] and route_segments[i-1] == "*")) then return route, params end end @@ -94,7 +135,7 @@ function _http_handle_request(req_table, res_table) end local mw = _G._http_middleware[index] - if mw.path == nil or req.path:match("^" .. mw.path:gsub("([%(%)%.%+%-%*%?%[%]%^%$%%])", "%%%1")) then + if mw.path == nil or string.starts_with(req.path, mw.path) then mw.handler(req, res, function() run_middleware(index + 1) end) @@ -130,7 +171,6 @@ local Server = {} Server.__index = Server function http.server() - -- Workers should not create servers if _G.__IS_WORKER then return setmetatable({}, Server) end @@ -139,7 +179,6 @@ function http.server() _server_created = false }, Server) - -- Create the fasthttp server immediately local success, err = moonshark.http_create_server() if not success then error("Failed to create HTTP server: " .. (err or "unknown error")) @@ -159,16 +198,12 @@ function Server:use(...) error("Invalid arguments to use()") end - -- Only sync in main state - if not _G.__IS_WORKER then - self:_sync_routes() - end return self end function Server:_add_route(method, path, handler) -- Ensure path starts with / - if path:sub(1,1) ~= "/" then + if not string.starts_with(path, "/") then path = "/" .. path end @@ -181,17 +216,9 @@ function Server:_add_route(method, path, handler) handler = handler }) - -- Only sync in main state - if not _G.__IS_WORKER then - self:_sync_routes() - end return self end -function Server:_sync_routes() - -- No-op - routes are in global Lua tables, workers inherit them automatically -end - -- HTTP method helpers function Server:get(path, handler) return self:_add_route("GET", path, handler) @@ -271,7 +298,7 @@ function Server:close() end -- ====================================================================== --- REQUEST OBJECT +-- ENHANCED REQUEST OBJECT -- ====================================================================== function Request.new(req_table) @@ -281,14 +308,21 @@ function Request.new(req_table) query = req_table.query or {}, headers = req_table.headers or {}, params = {}, - body = req_table.body or "" + 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) - return self.headers[header_name] or self.headers[header_name:lower()] + local lower_name = string.lower(header_name) + return self.headers[header_name] or self.headers[lower_name] end function Request:header(header_name) @@ -303,8 +337,51 @@ 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 self.body == "" then + if string.is_empty(self.body) then return nil end @@ -321,16 +398,44 @@ end function Request:is_json() local content_type = self:get("content-type") or "" - return content_type:find("application/json") ~= nil + return string.contains(content_type, "application/json") end function Request:is_form() local content_type = self:get("content-type") or "" - return content_type:find("application/x-www-form-urlencoded") ~= nil + 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 -- ====================================================================== --- RESPONSE OBJECT +-- ENHANCED RESPONSE OBJECT -- ====================================================================== function Response.new(res_table) @@ -389,7 +494,7 @@ function Response:json(data) error("Response already sent") end - self:type("application/json") + self:type("application/json; charset=utf-8") local success, json_str = pcall(function() return json.encode(data) @@ -410,7 +515,7 @@ function Response:text(text) error("Response already sent") end - self:type("text/plain") + self:type("text/plain; charset=utf-8") self._table.body = tostring(text or "") self._sent = true return self @@ -421,12 +526,23 @@ function Response:html(html) error("Response already sent") end - self:type("text/html") + 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") @@ -446,7 +562,14 @@ function Response:cookie(name, value, options) end options = options or {} - local cookie = name .. "=" .. tostring(value) + 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 @@ -497,6 +620,42 @@ function Response:clear_cookie(name, options) 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 -- ====================================================================== @@ -516,7 +675,7 @@ function http.cors(options) res:header("Access-Control-Allow-Credentials", "true") end - if req.method == "OPTIONS" then + if string.iequals(req.method, "OPTIONS") then res:status(204):send("") else next() @@ -527,6 +686,11 @@ 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 @@ -542,7 +706,7 @@ end function http.json_parser() return function(req, res, next) - if req:is_json() and req.body ~= "" then + if req:is_json() and not string.is_empty(req.body) then local success, data = pcall(function() return req:json() end) @@ -558,7 +722,9 @@ function http.json_parser() end end -function http.logger() +function http.logger(format) + format = format or ":method :path :status :response-time ms" + return function(req, res, next) local start_time = os.clock() @@ -566,7 +732,73 @@ function http.logger() local duration = (os.clock() - start_time) * 1000 local status = res._table.status or 200 - print(string.format("%s %s %d %.2fms", req.method, req.path, status, duration)) + + 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