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", filename = "sessions.json", 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 -- ====================================================================== -- FORM PARSING UTILITIES -- ====================================================================== local function parse_url_encoded(data) local form = {} if string.is_empty(data) then return form end for pair in string.gmatch(data, "[^&]+") do local key, value = string.match(pair, "([^=]+)=?(.*)") if key then key = string.url_decode(key) value = string.url_decode(value or "") -- Handle array-style inputs (name[], name[0], etc.) local array_key = string.match(key, "^(.+)%[%]$") if array_key then if not form[array_key] then form[array_key] = {} end table.insert(form[array_key], value) else local indexed_key, index = string.match(key, "^(.+)%[([^%]]+)%]$") if indexed_key then if not form[indexed_key] then form[indexed_key] = {} end form[indexed_key][index] = value else form[key] = value end end end end return form end local function parse_multipart_boundary(content_type) return string.match(content_type, "boundary=([^;%s]+)") end local function parse_multipart_header(header_line) local headers = {} for pair in string.gmatch(header_line, "[^;]+") do local trimmed = string.trim(pair) local key, value = string.match(trimmed, "^([^=]+)=?(.*)$") if key then key = string.trim(string.lower(key)) if value and string.starts_with(value, '"') and string.ends_with(value, '"') then value = string.slice(value, 2, -2) end headers[key] = value or "" end end return headers end local function parse_multipart_data(data, boundary) local form = {} local files = {} if string.is_empty(data) or string.is_empty(boundary) then return form, files end local delimiter = "--" .. boundary local parts = string.split(data, delimiter) for i = 2, #parts do -- Skip first empty part local part = string.trim(parts[i]) if string.is_empty(part) or part == "--" then break end -- Find headers/body boundary local header_end = string.find(part, "\r?\n\r?\n") if not header_end then goto continue end local headers_section = string.slice(part, 1, header_end - 1) local body = string.slice(part, header_end + 1, -1) body = string.gsub(body, "\r?\n$", "") -- Remove trailing newline -- Parse Content-Disposition header local disposition_line = string.match(headers_section, "[Cc]ontent%-[Dd]isposition: ([^\r\n]+)") if not disposition_line then goto continue end local disposition = parse_multipart_header(disposition_line) local field_name = disposition.name if not field_name then goto continue end -- Check if it's a file upload if disposition.filename then local content_type = string.match(headers_section, "[Cc]ontent%-[Tt]ype: ([^\r\n]+)") or "application/octet-stream" local file_info = { filename = disposition.filename, content_type = string.trim(content_type), size = #body, data = body } -- Handle file arrays if string.ends_with(field_name, "[]") then local base_name = string.slice(field_name, 1, -3) if not files[base_name] then files[base_name] = {} end table.insert(files[base_name], file_info) else files[field_name] = file_info end else -- Regular form field if string.ends_with(field_name, "[]") then local base_name = string.slice(field_name, 1, -3) if not form[base_name] then form[base_name] = {} end table.insert(form[base_name], body) else form[field_name] = body end end ::continue:: end return form, files end -- ====================================================================== -- CONTEXT CLASS (UNIFIED REQUEST/RESPONSE/SESSION) -- ====================================================================== local Context = {} Context.__index = Context function Context.new(req_table, res_table) local ctx = setmetatable({ -- Request data _method = req_table.method, _path = req_table.path, _query = req_table.query or {}, _headers = req_table.headers or {}, _body = req_table.body or "", _params = {}, _cookies = {}, -- Response data _res_table = res_table, _sent = false, -- Session data _session_loaded = false, _session_dirty = false, _session_data = {}, _flash_now = {}, _session_id = nil, -- Parsed data cache _json_body = nil, _json_parsed = false, _form_data = nil, _files = nil, _form_parsed = false, -- Response locals locals = {} }, Context) -- Parse cookies local cookie_header = ctx._headers["Cookie"] or ctx._headers["cookie"] if cookie_header then ctx._cookies = parse_cookies(cookie_header) end return ctx end -- ====================================================================== -- REQUEST DATA ACCESS -- ====================================================================== function Context:method() return self._method end function Context:path() return self._path end function Context:body() return self._body end function Context:param(name, default) return self._params[name] or default end function Context:query(name, default) return self._query[name] or default end function Context:header(name, default) local lower_name = string.lower(name) return self._headers[name] or self._headers[lower_name] or default end function Context:cookie(name, default) return self._cookies[name] or default end function Context:has_cookie(name) return self._cookies[name] ~= nil end function Context:user_agent() return self:header("user-agent", "") end function Context:ip() return self:header("x-forwarded-for") or self:header("x-real-ip") or "unknown" end function Context:is_json() local content_type = self:header("content-type", "") return string.contains(content_type, "application/json") end function Context:json_body() if not self._json_parsed then self._json_parsed = true if not string.is_empty(self._body) then local success, result = pcall(json.decode, self._body) self._json_body = success and result or nil end end return self._json_body end function Context:is_multipart() local content_type = self:header("content-type", "") return string.contains(content_type, "multipart/form-data") end function Context:is_form_data() local content_type = self:header("content-type", "") return string.contains(content_type, "application/x-www-form-urlencoded") or string.contains(content_type, "multipart/form-data") or (string.iequals(self._method, "POST") and not string.is_empty(self._body) and not self:is_json()) end function Context:_ensure_form_parsed() if self._form_parsed then return end self._form_parsed = true if string.is_empty(self._body) then self._form_data = {} self._files = {} return end local content_type = self:header("content-type", "") if string.contains(content_type, "multipart/form-data") then local boundary = parse_multipart_boundary(content_type) if boundary then self._form_data, self._files = parse_multipart_data(self._body, boundary) else self._form_data = {} self._files = {} end else -- Default to URL-encoded parsing for POST with body self._form_data = parse_url_encoded(self._body) self._files = {} end end function Context:form(name, default) self:_ensure_form_parsed() return self._form_data[name] or default end function Context:file(name) self:_ensure_form_parsed() return self._files[name] end function Context:files() self:_ensure_form_parsed() local copy = {} for k, v in pairs(self._files) do copy[k] = v end return copy end function Context:form_data() self:_ensure_form_parsed() local copy = {} for k, v in pairs(self._form_data) do copy[k] = v end return copy end function Context:has_form(name) self:_ensure_form_parsed() return self._form_data[name] ~= nil end function Context:has_file(name) self:_ensure_form_parsed() return self._files[name] ~= nil end -- ====================================================================== -- RESPONSE METHODS -- ====================================================================== function Context:status(code) if self._sent then error("Cannot set status after response has been sent") end self._res_table.status = code return self end function Context:set_header(name, value) if self._sent then error("Cannot set headers after response has been sent") end self._res_table.headers[name] = value return self end function Context:type(content_type) return self:set_header("Content-Type", content_type) end function Context: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._res_table.body = success and json_str or error("Failed to encode JSON response") elseif type(data) == "number" then self._res_table.status = data self._res_table.body = "" else self._res_table.body = tostring(data or "") end self._sent = true return self end function Context: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._res_table.body = success and json_str or error("Failed to encode JSON response") self._sent = true return self end function Context:text(text) if self._sent then error("Response already sent") end self:type("text/plain; charset=utf-8") self._res_table.body = tostring(text or "") self._sent = true return self end function Context:html(html) if self._sent then error("Response already sent") end self:type("text/html; charset=utf-8") self._res_table.body = tostring(html or "") self._sent = true return self end function Context:redirect(url, status) if self._sent then error("Response already sent") end self:status(status or 302):set_header("Location", url) self._res_table.body = "" self._sent = true return self end function Context:set_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._res_table.headers["Set-Cookie"] if existing then if type(existing) == "table" then table.insert(existing, cookie) else self._res_table.headers["Set-Cookie"] = {existing, cookie} end else self._res_table.headers["Set-Cookie"] = cookie end return self end -- ====================================================================== -- SESSION METHODS (FLATTENED) -- ====================================================================== function Context:_ensure_session_loaded() if self._session_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._session_data = json_str and json.decode(json_str) or {} self:set_cookie(config.cookie_name, session_id, config.cookie_options) else session_id = crypto.random_alphanumeric(32) self._session_data = {} self:set_cookie(config.cookie_name, session_id, config.cookie_options) end self._session_id = session_id self._session_loaded = true end function Context:session_get(key, default) self:_ensure_session_loaded() return self._session_data[key] or default end function Context:session_set(key, value) self:_ensure_session_loaded() self._session_data[key] = value self._session_dirty = true return self end function Context:session_delete(key) self:_ensure_session_loaded() self._session_data[key] = nil self._session_dirty = true return self end function Context:session_clear() self:_ensure_session_loaded() self._session_data = {} self._session_dirty = true return self end function Context:session_has(key) self:_ensure_session_loaded() return self._session_data[key] ~= nil end function Context:session_data() self:_ensure_session_loaded() local copy = {} for k, v in pairs(self._session_data) do copy[k] = v end return copy end function Context:session_save() if not self._session_loaded or not self._session_id then return false end local config = _G._http_session_config return kv.set(config.store_name, "session:" .. self._session_id, json.encode(self._session_data)) end function Context:session_destroy() self:_ensure_session_loaded() local config = _G._http_session_config kv.delete(config.store_name, "session:" .. self._session_id) self:set_cookie(config.cookie_name, "", { expires = "Thu, 01 Jan 1970 00:00:00 GMT", path = config.cookie_options.path or "/" }) self._session_id = nil self._session_data = {} return self end function Context:session_regenerate() self:_ensure_session_loaded() local config = _G._http_session_config -- Save current data local current_data = {} for k, v in pairs(self._session_data) do current_data[k] = v end -- Delete old session if self._session_id then kv.delete(config.store_name, "session:" .. self._session_id) end -- Create new session ID and restore data local new_id = crypto.random_alphanumeric(32) self._session_id = new_id self._session_data = current_data self._session_dirty = true -- Replace the cookie self._res_table.headers["Set-Cookie"] = config.cookie_name .. "=" .. self._session_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 (FLATTENED) -- ====================================================================== function Context:csrf_token() self:_ensure_session_loaded() local token = self._session_data._csrf_token if not token then token = crypto.random_alphanumeric(40) self._session_data._csrf_token = token self._session_dirty = true end return token end function Context:verify_csrf(token) if not token then return false end self:_ensure_session_loaded() return self._session_data._csrf_token == token end function Context:csrf_field() return '' end function Context:regenerate_csrf_token() self:_ensure_session_loaded() self._session_data._csrf_token = crypto.random_alphanumeric(40) self._session_dirty = true return self._session_data._csrf_token end -- ====================================================================== -- FLASH METHODS (FLATTENED) -- ====================================================================== function Context:flash(key, message) self:_ensure_session_loaded() if not self._session_data._flash then self._session_data._flash = {} end if message ~= nil then self._session_data._flash[key] = message self._session_dirty = true return self else local msg = self._session_data._flash and self._session_data._flash[key] if msg and self._session_data._flash then self._session_data._flash[key] = nil self._session_dirty = true end return msg end end function Context:flash_now(key, message) if message ~= nil then self._flash_now[key] = message return self else return self._flash_now[key] end end function Context:get_flash() self:_ensure_session_loaded() local messages = {} if self._session_data._flash then for k, v in pairs(self._session_data._flash) do messages[k] = v end self._session_data._flash = {} self._session_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 Context:clear_flash() self:_ensure_session_loaded() if self._session_data._flash then self._session_data._flash = {} self._session_dirty = true end self._flash_now = {} return self end -- Flash convenience methods function Context:flash_success(msg) return self:flash("success", msg) end function Context:flash_error(msg) return self:flash("error", msg) end function Context:flash_warning(msg) return self:flash("warning", msg) end function Context:flash_info(msg) return self:flash("info", msg) end -- ====================================================================== -- AUTH METHODS (FLATTENED) -- ====================================================================== function Context:is_authenticated() return self:session_get("authenticated", false) end function Context:current_user() return self:session_get("user") end function Context:login(user_data) self:session_set("user", user_data) self:session_set("authenticated", true) self:session_regenerate() return self end function Context:logout() self:session_clear() self:session_regenerate() return self end -- ====================================================================== -- REDIRECT WITH FLASH CONVENIENCE -- ====================================================================== function Context:redirect_with_flash(url, flash_type, message, status) self:flash(flash_type, message) return self:redirect(url, status) end function Context:redirect_with_success(url, message, status) return self:redirect_with_flash(url, "success", message, status) end function Context:redirect_with_error(url, message, status) return self:redirect_with_flash(url, "error", message, status) 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 function Server:static(root_path, url_prefix, no_cache) if not no_cache or no_cache ~= true then no_cache = false end 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, no_cache) if not success then error("Failed to register static handler: " .. (err or "unknown error")) end end 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 (UPDATED FOR CONTEXT) -- ====================================================================== function _http_handle_request(req_table, res_table) local ctx = Context.new(req_table, res_table) local route, params = match_route(ctx:method(), ctx:path()) ctx._params = params if not route then ctx:send(404) return end -- Enhanced middleware runner local function run_middleware(index) if index > #route.middleware then safe_call(route.handler, ctx) 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, ctx, next) if success and not next_called and not ctx._sent then next() end end if route.middleware and #route.middleware > 0 then run_middleware(1) else safe_call(route.handler, ctx) end -- Auto-save sessions if ctx._session_loaded and ctx._session_dirty then safe_call(ctx.session_save, ctx) 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(ctx, next) ctx:set_header("Access-Control-Allow-Origin", origin) ctx:set_header("Access-Control-Allow-Methods", methods) ctx:set_header("Access-Control-Allow-Headers", headers) if options.credentials then ctx:set_header("Access-Control-Allow-Credentials", "true") end if string.iequals(ctx:method(), "OPTIONS") then ctx:send("") else next() end end end function http.json_parser(options) options = options or {} local strict = options.strict ~= false return function(ctx, next) if ctx:is_json() and not string.is_empty(ctx:body()) then local json_data = ctx:json_body() if json_data then ctx.json_body_parsed = json_data elseif strict then ctx:status(400):json({error = "Invalid JSON"}) return end end next() end end function http.logger(format) format = format or "${method} ${path} ${status}" return function(ctx, next) local start_time = os.clock() next() local duration = (os.clock() - start_time) * 1000 local status = ctx._res_table.status or 200 print(string.template(format, { method = ctx:method(), path = ctx:path(), status = status, ["response-time"] = string.format("%.2f", duration), ["user-agent"] = ctx:user_agent(), ip = ctx: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(ctx, next) -- Skip for safe methods for _, method in ipairs(safe_methods) do if string.iequals(ctx:method(), method) then next() return end end local session_token = ctx:csrf_token() local request_token = ctx:header(header_name) -- Check form data (new fast method) if not request_token and ctx:is_form_data() then request_token = ctx:form(token_field) end -- Check JSON body if not request_token and ctx:is_json() then local json_data = ctx:json_body() if json_data then request_token = json_data[token_field] end end if not ctx:verify_csrf(request_token) then ctx:status(403):json({error = "CSRF token mismatch"}) return end next() end end function http.require_auth(redirect_url) redirect_url = redirect_url or "/login" return function(ctx, next) if not ctx:is_authenticated() then if ctx:header("Accept") and string.contains(ctx:header("Accept"), "application/json") then ctx:status(401):json({error = "Authentication required"}) else ctx:flash("error", "You must be logged in to access this page") ctx:redirect(redirect_url) end return end next() end end function http.require_guest(redirect_url) redirect_url = redirect_url or "/" return function(ctx, next) if ctx:is_authenticated() then ctx:redirect(redirect_url) return 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