1071 lines
28 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,
same_site = "Lax"
}
}
-- ======================================================================
-- UTILITY FUNCTIONS
-- ======================================================================
local function parse_cookies(cookie_header)
local cookies = {}
if string.is_empty(cookie_header) then return cookies end
for _, cookie_pair in ipairs(string.split(cookie_header, ";")) 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(string.url_decode, value)
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 split_path(path)
if string.is_empty(path) or path == "/" then return {} end
local clean_path = string.trim(path, "/")
return string.is_empty(clean_path) and {} or string.split(clean_path, "/")
end
local function safe_call(fn, ...)
local success, result = pcall(fn, ...)
if not success then
print("Error: " .. tostring(result))
end
return success, result
end
-- ======================================================================
-- SESSION OBJECT
-- ======================================================================
local Session = {}
Session.__index = Session
function Session.new(cookies, response)
return setmetatable({
_cookies = cookies,
_response = response,
_loaded = false,
_dirty = false,
_data = {},
_flash_now = {},
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]
if session_id then
local json_str = kv.get(config.store_name, "session:" .. session_id)
self._data = json_str and json.decode(json_str) or {}
self._response:cookie(config.cookie_name, session_id, config.cookie_options)
else
session_id = crypto.random_alphanumeric(32)
self._data = {}
self._response:cookie(config.cookie_name, session_id, config.cookie_options)
end
self.id = session_id
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 or not self.id then return false end
local config = _G._http_session_config
return kv.set(config.store_name, "session:" .. self.id, json.encode(self._data))
end
function Session:destroy()
self:_ensure_loaded()
local config = _G._http_session_config
kv.delete(config.store_name, "session:" .. self.id)
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
-- Save current data
local current_data = {}
for k, v in pairs(self._data) do
current_data[k] = v
end
-- Delete old session
if self.id then
kv.delete(config.store_name, "session:" .. self.id)
end
-- Create new session ID and restore data
local new_id = crypto.random_alphanumeric(32)
self.id = new_id
self._data = current_data
self._dirty = true
-- Replace the cookie directly (don't append)
self._response._table.headers["Set-Cookie"] = config.cookie_name .. "=" .. self.id ..
"; Max-Age=" .. tostring(config.cookie_options.max_age) ..
"; Path=" .. (config.cookie_options.path or "/") ..
(config.cookie_options.http_only and "; HttpOnly" or "") ..
(config.cookie_options.same_site and ("; SameSite=" .. config.cookie_options.same_site) or "")
return self
end
-- CSRF methods
function Session:csrf_token()
self:_ensure_loaded()
local token = self._data._csrf_token
if not token then
token = crypto.random_alphanumeric(40)
self._data._csrf_token = token
self._dirty = true
end
return token
end
function Session:verify_csrf_token(token)
if not token then return false end
self:_ensure_loaded()
return self._data._csrf_token == token
end
function Session:regenerate_csrf_token()
self:_ensure_loaded()
self._data._csrf_token = crypto.random_alphanumeric(40)
self._dirty = true
return self._data._csrf_token
end
-- Flash methods
function Session:flash(key, message)
self:_ensure_loaded()
if not self._data._flash then self._data._flash = {} end
if message ~= nil then
self._data._flash[key] = message
self._dirty = true
return self
else
local msg = self._data._flash and self._data._flash[key]
if msg and self._data._flash then
self._data._flash[key] = nil
self._dirty = true
end
return msg
end
end
function Session:flash_now(key, message)
if message ~= nil then
self._flash_now[key] = message
return self
else
return self._flash_now[key]
end
end
function Session:get_all_flash()
self:_ensure_loaded()
local messages = {}
if self._data._flash then
for k, v in pairs(self._data._flash) do messages[k] = v end
self._data._flash = {}
self._dirty = true
end
if self._flash_now then
for k, v in pairs(self._flash_now) do messages[k] = v end
end
return messages
end
function Session:clear_flash()
self:_ensure_loaded()
if self._data._flash then
self._data._flash = {}
self._dirty = true
end
self._flash_now = {}
return self
end
-- Flash convenience methods
function Session:flash_success(msg) return self:flash("success", msg) end
function Session:flash_error(msg) return self:flash("error", msg) end
function Session:flash_warning(msg) return self:flash("warning", msg) end
function Session:flash_info(msg) return self:flash("info", msg) 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
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) return self.params[name] or default end
function Request:query_param(name, default) return self.query[name] or default end
function Request:cookie(name, default) return self.cookies[name] or default end
function Request:has_cookie(name) return self.cookies[name] ~= nil 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:json()
if string.is_empty(self.body) then return nil end
local success, result = pcall(json.decode, self.body)
return success and result or error("Invalid JSON in request body")
end
function Request:is_json()
local content_type = self:get("content-type") or ""
return string.contains(content_type, "application/json")
end
-- Session shortcuts
function Request:csrf_token() return self.session:csrf_token() end
function Request:verify_csrf(token) return self.session:verify_csrf_token(token) end
function Request:flash(key, message) return self.session:flash(key, message) end
function Request:flash_now(key, message) return self.session:flash_now(key, message) end
function Request:get_flash() return self.session:get_all_flash() end
function Request:is_authenticated() return self.session:get("authenticated", false) end
function Request:current_user() return self.session:get("user") end
function Request:login(user_data)
self.session:set("user", user_data)
self.session:set("authenticated", true)
self.session:regenerate()
end
function Request:logout()
self.session:clear()
self.session:regenerate()
end
-- ======================================================================
-- RESPONSE CLASS
-- ======================================================================
local Response = {}
Response.__index = Response
function Response.new(res_table)
return setmetatable({
_table = res_table,
_sent = false,
locals = {}
}, 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(json.encode, data)
self._table.body = success and json_str or error("Failed to encode JSON response")
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(json.encode, data)
self._table.body = success and json_str or error("Failed to encode JSON response")
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
self:status(status or 302):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(cookie_value, "[;,\\s]") 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
-- Flash redirect methods (set via middleware)
function Response:redirect_with_flash(url, flash_type, message, status)
error("Use flash_middleware to enable this method")
end
function Response:redirect_with_success(url, message, status)
error("Use flash_middleware to enable this method")
end
function Response:redirect_with_error(url, message, status)
error("Use flash_middleware to enable this method")
end
-- ======================================================================
-- ROUTING BASE CLASS
-- ======================================================================
local RouteBuilder = {}
RouteBuilder.__index = RouteBuilder
function RouteBuilder:_add_route(method, path, ...)
local args = {...}
if #args == 0 then error("Route handler is required") end
local handler = args[#args]
local route_middleware = {}
for i = 1, #args - 1 do
if type(args[i]) == "function" then
table.insert(route_middleware, args[i])
else
error("Route middleware must be functions")
end
end
if not string.starts_with(path, "/") then path = "/" .. path end
local full_path = (self._prefix or "") .. path
local segments = split_path(full_path)
-- Build complete middleware chain
local complete_middleware = {}
-- Global middleware
for _, mw in ipairs(_G._http_middleware) do
if mw.path == nil or string.starts_with(full_path, mw.path) then
table.insert(complete_middleware, mw.handler)
end
end
-- Router middleware
if self._middleware then
for _, mw in ipairs(self._middleware) do
if type(mw) == "function" then
table.insert(complete_middleware, mw)
elseif type(mw) == "table" and mw.path and string.starts_with(full_path, mw.path) then
table.insert(complete_middleware, mw.handler)
end
end
end
-- Route-specific middleware
for _, mw in ipairs(route_middleware) do
table.insert(complete_middleware, mw)
end
table.insert(_G._http_routes, {
method = method,
path = full_path,
segments = segments,
handler = handler,
middleware = complete_middleware
})
return self
end
-- HTTP method helpers
function RouteBuilder:get(path, ...) return self:_add_route("GET", path, ...) end
function RouteBuilder:post(path, ...) return self:_add_route("POST", path, ...) end
function RouteBuilder:put(path, ...) return self:_add_route("PUT", path, ...) end
function RouteBuilder:delete(path, ...) return self:_add_route("DELETE", path, ...) end
function RouteBuilder:patch(path, ...) return self:_add_route("PATCH", path, ...) end
function RouteBuilder:head(path, ...) return self:_add_route("HEAD", path, ...) end
function RouteBuilder:options(path, ...) return self:_add_route("OPTIONS", path, ...) end
function RouteBuilder:all(path, ...)
local methods = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
for _, method in ipairs(methods) do
self:_add_route(method, path, ...)
end
return self
end
-- ======================================================================
-- ROUTER CLASS
-- ======================================================================
local Router = {}
Router.__index = Router
setmetatable(Router, {__index = RouteBuilder})
function Router.new()
return setmetatable({
_middleware = {},
_prefix = ""
}, Router)
end
function Router:use(...)
local args = {...}
if #args == 1 and type(args[1]) == "function" then
table.insert(self._middleware, args[1])
elseif #args == 2 and type(args[1]) == "string" and type(args[2]) == "function" then
table.insert(self._middleware, {path = args[1], handler = args[2]})
else
error("Invalid arguments to use()")
end
return self
end
function Router:group(path_prefix, callback)
local group_router = Router.new()
group_router._prefix = self._prefix .. path_prefix
-- Inherit parent middleware
for _, mw in ipairs(self._middleware) do
table.insert(group_router._middleware, mw)
end
if callback then callback(group_router) end
return group_router
end
-- ======================================================================
-- SERVER CLASS
-- ======================================================================
local Server = {}
Server.__index = Server
setmetatable(Server, {__index = RouteBuilder})
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:group(path_prefix, callback)
local router = Router.new()
router._prefix = path_prefix
if callback then callback(router) end
return router
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: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()
return _G.__IS_WORKER or moonshark.http_close_server()
end
-- ======================================================================
-- ROUTING ENGINE
-- ======================================================================
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
params[string.slice(route_seg, 2, -1)] = 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
-- ======================================================================
-- REQUEST HANDLING
-- ======================================================================
function _http_handle_request(req_table, res_table)
local res = Response.new(res_table)
local req = Request.new(req_table, res)
local route, params = match_route(req.method, req.path)
req.params = params
if not route then
res:send(404)
return
end
-- Enhanced middleware runner
local function run_middleware(index)
if index > #route.middleware then
safe_call(route.handler, req, res)
return
end
local middleware = route.middleware[index]
if not middleware or type(middleware) ~= "function" then
run_middleware(index + 1)
return
end
local next_called = false
local function next()
if next_called then
print("Warning: next() called multiple times")
return
end
next_called = true
run_middleware(index + 1)
end
local success = safe_call(middleware, req, res, next)
if success and not next_called and not res._sent then
next()
end
end
if route.middleware and #route.middleware > 0 then
run_middleware(1)
else
safe_call(route.handler, req, res)
end
-- Auto-save sessions
if req.session._loaded and req.session._dirty then
safe_call(req.session.save, req.session)
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
-- ======================================================================
-- MIDDLEWARE
-- ======================================================================
function http.router()
return Router.new()
end
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(options)
options = options or {}
local strict = options.strict ~= false
return function(req, res, next)
if req:is_json() and not string.is_empty(req.body) then
local success, data = pcall(req.json, req)
if success then
req.json_body = data
elseif strict then
res:status(400):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
print(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()
}))
end
end
function http.csrf_protection(options)
options = options or {}
local safe_methods = options.safe_methods or {"GET", "HEAD", "OPTIONS"}
local token_field = options.token_field or "_csrf_token"
local header_name = options.header_name or "X-CSRF-Token"
return function(req, res, next)
-- Skip for safe methods
for _, method in ipairs(safe_methods) do
if string.iequals(req.method, method) then
next()
return
end
end
local session_token = req.session:csrf_token()
local request_token = req:header(header_name)
-- Check form data
if not request_token and req.body then
request_token = string.match(req.body, token_field .. "=([^&]*)")
if request_token then
request_token = string.url_decode(request_token)
end
end
-- Check JSON body
if not request_token and req:is_json() then
local json_data = req:json()
if json_data then request_token = json_data[token_field] end
end
if not req.session:verify_csrf_token(request_token) then
res:status(403):json({error = "CSRF token mismatch"})
return
end
next()
end
end
function http.flash_middleware()
return function(req, res, next)
req.flash = function(key, message) return req.session:flash(key, message) end
req.flash_now = function(key, message) return req.session:flash_now(key, message) end
req.get_flash = function() return req.session:get_all_flash() end
res.redirect_with_flash = function(self, url, flash_type, message, status)
req.session:flash(flash_type, message)
return self:redirect(url, status)
end
res.redirect_with_success = function(self, url, message, status)
return self:redirect_with_flash(url, "success", message, status)
end
res.redirect_with_error = function(self, url, message, status)
return self:redirect_with_flash(url, "error", message, status)
end
next()
end
end
function http.session_middleware(options)
return function(req, res, next)
if options and not _G._session_configured then
local config = _G._http_session_config
config.store_name = options.store_name or config.store_name
config.cookie_name = options.cookie_name or config.cookie_name
config.filename = options.filename or config.filename
if options.cookie_options then
for k, v in pairs(options.cookie_options) do
config.cookie_options[k] = v
end
end
ensure_session_store()
_G._session_configured = true
end
next()
end
end
function http.require_auth(redirect_url)
redirect_url = redirect_url or "/login"
return function(req, res, next)
if not req:is_authenticated() then
if req:header("Accept") and string.contains(req:header("Accept"), "application/json") then
res:status(401):json({error = "Authentication required"})
else
req:flash("error", "You must be logged in to access this page")
res:redirect(redirect_url)
end
return
end
next()
end
end
function http.require_guest(redirect_url)
redirect_url = redirect_url or "/"
return function(req, res, next)
if req:is_authenticated() then
res:redirect(redirect_url)
return
end
next()
end
end
function http.template_helpers()
return function(req, res, next)
res.locals.csrf_token = req:csrf_token()
res.locals.csrf_token_tag = '<input type="hidden" name="_csrf_token" value="' .. req:csrf_token() .. '">'
res.locals.flash_messages = req:get_flash()
res.locals.current_user = req:current_user()
res.locals.is_authenticated = req:is_authenticated()
res.locals.flash_html = function(flash_type, css_class)
css_class = css_class or "alert alert-" .. flash_type
local messages = res.locals.flash_messages
return messages[flash_type] and '<div class="' .. css_class .. '">' .. tostring(messages[flash_type]) .. '</div>' or ""
end
next()
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 and
current_time - session_data._last_accessed > max_age then
kv.delete(config.store_name, key)
deleted = deleted + 1
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