812 lines
19 KiB
Lua
812 lines
19 KiB
Lua
local kv = require("kv")
|
|
local crypto = require("crypto")
|
|
|
|
local http = {}
|
|
|
|
-- Global routing tables
|
|
_G._http_routes = _G._http_routes or {}
|
|
_G._http_middleware = _G._http_middleware or {}
|
|
_G._http_session_config = _G._http_session_config or {
|
|
store_name = "sessions",
|
|
cookie_name = "session_id",
|
|
cookie_options = {
|
|
path = "/",
|
|
http_only = true,
|
|
max_age = 86400, -- 24 hours
|
|
same_site = "Lax"
|
|
}
|
|
}
|
|
|
|
-- ======================================================================
|
|
-- UTILITY FUNCTIONS
|
|
-- ======================================================================
|
|
|
|
local function parse_cookies(cookie_header)
|
|
local cookies = {}
|
|
if string.is_empty(cookie_header) then
|
|
return cookies
|
|
end
|
|
|
|
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])
|
|
|
|
local success, decoded = pcall(function()
|
|
return string.url_decode(value)
|
|
end)
|
|
|
|
cookies[name] = success and decoded or value
|
|
elseif #parts == 1 then
|
|
cookies[string.trim(parts[1])] = ""
|
|
end
|
|
end
|
|
end
|
|
|
|
return cookies
|
|
end
|
|
|
|
local function ensure_session_store()
|
|
local config = _G._http_session_config
|
|
if not kv.open(config.store_name, config.filename) then
|
|
error("Failed to initialize session store: " .. config.store_name)
|
|
end
|
|
end
|
|
|
|
local function generate_session_id()
|
|
return crypto.random_alphanumeric(32)
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- SESSION OBJECT (LAZY LOADING)
|
|
-- ======================================================================
|
|
|
|
local Session = {}
|
|
Session.__index = Session
|
|
|
|
function Session.new(cookies, response)
|
|
return setmetatable({
|
|
_cookies = cookies,
|
|
_response = response,
|
|
_loaded = false,
|
|
_dirty = false,
|
|
_data = {},
|
|
id = nil
|
|
}, Session)
|
|
end
|
|
|
|
function Session:_ensure_loaded()
|
|
if self._loaded then return end
|
|
|
|
ensure_session_store()
|
|
|
|
local config = _G._http_session_config
|
|
local session_id = self._cookies[config.cookie_name]
|
|
local session_data = nil
|
|
|
|
if session_id then
|
|
-- Cookie exists, try to load session data
|
|
local json_str = kv.get(config.store_name, "session:" .. session_id)
|
|
if json_str then
|
|
session_data = json.decode(json_str)
|
|
else
|
|
-- Cookie exists but no data - keep same ID, create empty data
|
|
session_data = {}
|
|
end
|
|
-- Always refresh cookie age for existing sessions
|
|
self._response:cookie(config.cookie_name, session_id, config.cookie_options)
|
|
else
|
|
-- No cookie - create new session
|
|
session_id = generate_session_id()
|
|
session_data = {}
|
|
self._response:cookie(config.cookie_name, session_id, config.cookie_options)
|
|
end
|
|
|
|
self.id = session_id
|
|
self._data = session_data
|
|
self._loaded = true
|
|
end
|
|
|
|
function Session:get(key, default)
|
|
self:_ensure_loaded()
|
|
return self._data[key] or default
|
|
end
|
|
|
|
function Session:set(key, value)
|
|
self:_ensure_loaded()
|
|
self._data[key] = value
|
|
self._dirty = true
|
|
return self
|
|
end
|
|
|
|
function Session:delete(key)
|
|
self:_ensure_loaded()
|
|
self._data[key] = nil
|
|
self._dirty = true
|
|
return self
|
|
end
|
|
|
|
function Session:clear()
|
|
self:_ensure_loaded()
|
|
self._data = {}
|
|
self._dirty = true
|
|
return self
|
|
end
|
|
|
|
function Session:has(key)
|
|
self:_ensure_loaded()
|
|
return self._data[key] ~= nil
|
|
end
|
|
|
|
function Session:data()
|
|
self:_ensure_loaded()
|
|
local copy = {}
|
|
for k, v in pairs(self._data) do
|
|
copy[k] = v
|
|
end
|
|
return copy
|
|
end
|
|
|
|
function Session:save()
|
|
if not self._loaded then return false end
|
|
|
|
local config = _G._http_session_config
|
|
local session_json = json.encode(self._data)
|
|
local success = kv.set(config.store_name, "session:" .. self.id, session_json)
|
|
|
|
return success
|
|
end
|
|
|
|
function Session:destroy()
|
|
self:_ensure_loaded()
|
|
|
|
local config = _G._http_session_config
|
|
kv.delete(config.store_name, "session:" .. self.id)
|
|
|
|
-- Clear session cookie
|
|
self._response:cookie(config.cookie_name, "", {
|
|
expires = "Thu, 01 Jan 1970 00:00:00 GMT",
|
|
path = config.cookie_options.path or "/"
|
|
})
|
|
|
|
self.id = nil
|
|
self._data = {}
|
|
return self
|
|
end
|
|
|
|
function Session:regenerate()
|
|
self:_ensure_loaded()
|
|
|
|
local config = _G._http_session_config
|
|
|
|
-- Delete old session
|
|
if self.id then
|
|
kv.delete(config.store_name, "session:" .. self.id)
|
|
end
|
|
|
|
-- Generate new ID and set cookie
|
|
self.id = generate_session_id()
|
|
self._response:cookie(config.cookie_name, self.id, config.cookie_options)
|
|
|
|
return self
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- REQUEST CLASS
|
|
-- ======================================================================
|
|
|
|
local Request = {}
|
|
Request.__index = Request
|
|
|
|
function Request.new(req_table, response)
|
|
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 = {},
|
|
session = nil
|
|
}, Request)
|
|
|
|
local cookie_header = req.headers["Cookie"] or req.headers["cookie"]
|
|
if cookie_header then
|
|
req.cookies = parse_cookies(cookie_header)
|
|
end
|
|
|
|
-- Create lazy session object
|
|
req.session = Session.new(req.cookies, response)
|
|
|
|
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: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: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
|
|
|
|
-- ======================================================================
|
|
-- RESPONSE CLASS
|
|
-- ======================================================================
|
|
|
|
local Response = {}
|
|
Response.__index = Response
|
|
|
|
function Response.new(res_table)
|
|
return setmetatable({
|
|
_table = res_table,
|
|
_sent = false
|
|
}, Response)
|
|
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: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
|
|
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: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)
|
|
|
|
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
|
|
|
|
-- ======================================================================
|
|
-- ROUTING
|
|
-- ======================================================================
|
|
|
|
local function split_path(path)
|
|
if string.is_empty(path) or path == "/" then
|
|
return {}
|
|
end
|
|
|
|
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
|
|
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
|
|
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
|
|
if i > #path_segments or route_seg ~= path_segments[i] then
|
|
match = false
|
|
end
|
|
end
|
|
i = i + 1
|
|
end
|
|
|
|
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 res = Response.new(res_table)
|
|
local req = Request.new(req_table, res)
|
|
|
|
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:send(404)
|
|
return
|
|
end
|
|
|
|
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)
|
|
|
|
-- Auto-save dirty sessions
|
|
if req.session._loaded and req.session._dirty then
|
|
req.session:save()
|
|
end
|
|
end
|
|
|
|
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:configure_sessions(options)
|
|
options = options or {}
|
|
|
|
local config = _G._http_session_config
|
|
config.store_name = options.store_name or config.store_name
|
|
config.filename = options.filename or config.filename
|
|
config.cookie_name = options.cookie_name or config.cookie_name
|
|
|
|
if options.cookie_options then
|
|
for k, v in pairs(options.cookie_options) do
|
|
config.cookie_options[k] = v
|
|
end
|
|
end
|
|
|
|
ensure_session_store()
|
|
return self
|
|
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)
|
|
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)
|
|
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)
|
|
|
|
local success, err = moonshark.http_spawn_workers()
|
|
if not success then
|
|
error("Failed to spawn workers: " .. (err or "unknown error"))
|
|
end
|
|
|
|
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
|
|
|
|
-- ======================================================================
|
|
-- 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:send("")
|
|
else
|
|
next()
|
|
end
|
|
end
|
|
end
|
|
|
|
function http.static(root_path, url_prefix)
|
|
url_prefix = url_prefix or "/"
|
|
|
|
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 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:json({error = "Invalid JSON"})
|
|
return
|
|
end
|
|
end
|
|
next()
|
|
end
|
|
end
|
|
|
|
function http.logger(format)
|
|
format = format or "${method} ${path} ${status}"
|
|
|
|
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.create_server(callback)
|
|
local app = http.server()
|
|
if callback then
|
|
callback(app)
|
|
end
|
|
return app
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- SESSION UTILITIES
|
|
-- ======================================================================
|
|
|
|
function http.cleanup_expired_sessions(max_age)
|
|
max_age = max_age or 86400
|
|
ensure_session_store()
|
|
|
|
local config = _G._http_session_config
|
|
local keys = kv.keys(config.store_name)
|
|
local current_time = os.time()
|
|
local deleted = 0
|
|
|
|
for _, key in ipairs(keys) do
|
|
if string.starts_with(key, "session:") then
|
|
local json_str = kv.get(config.store_name, key)
|
|
if json_str then
|
|
local session_data = json.decode(json_str)
|
|
if session_data and session_data._last_accessed then
|
|
if current_time - session_data._last_accessed > max_age then
|
|
kv.delete(config.store_name, key)
|
|
deleted = deleted + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return deleted
|
|
end
|
|
|
|
function http.get_session_count()
|
|
ensure_session_store()
|
|
local config = _G._http_session_config
|
|
local keys = kv.keys(config.store_name)
|
|
local count = 0
|
|
|
|
for _, key in ipairs(keys) do
|
|
if string.starts_with(key, "session:") then
|
|
count = count + 1
|
|
end
|
|
end
|
|
|
|
return count
|
|
end
|
|
|
|
return http |