use string utils to simplify some http utils
This commit is contained in:
parent
6f20540720
commit
d3dcf95e0c
@ -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
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user