diff --git a/modules/http/http.lua b/modules/http/http.lua index 864d772..1761715 100644 --- a/modules/http/http.lua +++ b/modules/http/http.lua @@ -12,7 +12,7 @@ _G._http_session_config = _G._http_session_config or { cookie_options = { path = "/", http_only = true, - max_age = 86400, -- 24 hours + max_age = 86400, same_site = "Lax" } } @@ -23,30 +23,22 @@ _G._http_session_config = _G._http_session_config or { local function parse_cookies(cookie_header) local cookies = {} - if string.is_empty(cookie_header) then - return cookies - end + 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 + 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(function() - return string.url_decode(value) - end) - + 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 @@ -57,12 +49,22 @@ local function ensure_session_store() end end -local function generate_session_id() - return crypto.random_alphanumeric(32) +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 (LAZY LOADING) +-- SESSION OBJECT -- ====================================================================== local Session = {} @@ -75,6 +77,7 @@ function Session.new(cookies, response) _loaded = false, _dirty = false, _data = {}, + _flash_now = {}, id = nil }, Session) end @@ -83,31 +86,20 @@ 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._data = json_str and json.decode(json_str) or {} self._response:cookie(config.cookie_name, session_id, config.cookie_options) else - -- No cookie - create new session - session_id = generate_session_id() - session_data = {} + 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._data = session_data self._loaded = true end @@ -145,34 +137,24 @@ end function Session:data() self:_ensure_loaded() local copy = {} - for k, v in pairs(self._data) do - copy[k] = v - end + 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 - + if not self._loaded or not self.id 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 + 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) - - -- 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 @@ -180,120 +162,120 @@ 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 - -- Generate new ID and set cookie - self.id = generate_session_id() - self._response:cookie(config.cookie_name, self.id, config.cookie_options) + -- 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 --- ====================================================================== --- ROUTER CLASS --- ====================================================================== - -local Router = {} -Router.__index = Router - -function Router.new() - return setmetatable({ - _middleware = {}, - _prefix = "" - }, Router) +-- 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 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 - -- Path-specific middleware for this router - table.insert(self._middleware, {path = args[1], handler = args[2]}) +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 - 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 - -function Router:_add_route(method, path, handler) - if not string.starts_with(path, "/") then - path = "/" .. path - end - - local full_path = self._prefix .. path - local segments = split_path(full_path) - - -- Create middleware chain for this specific route - local route_middleware = {} - - -- Add global middleware first - for _, mw in ipairs(_G._http_middleware) do - if mw.path == nil or string.starts_with(full_path, mw.path) then - table.insert(route_middleware, mw.handler) + 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 - -- Add router middleware - for _, mw in ipairs(self._middleware) do - if type(mw) == "function" then - table.insert(route_middleware, mw) - elseif type(mw) == "table" and mw.path then - -- Check if path-specific middleware applies - if string.starts_with(full_path, mw.path) then - table.insert(route_middleware, mw.handler) - end - end + if self._flash_now then + for k, v in pairs(self._flash_now) do messages[k] = v end end - table.insert(_G._http_routes, { - method = method, - path = full_path, - segments = segments, - handler = handler, - middleware = route_middleware -- Store middleware per route - }) + 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 --- HTTP method helpers for Router -function Router:get(path, handler) return self:_add_route("GET", path, handler) end -function Router:post(path, handler) return self:_add_route("POST", path, handler) end -function Router:put(path, handler) return self:_add_route("PUT", path, handler) end -function Router:delete(path, handler) return self:_add_route("DELETE", path, handler) end -function Router:patch(path, handler) return self:_add_route("PATCH", path, handler) end -function Router:head(path, handler) return self:_add_route("HEAD", path, handler) end -function Router:options(path, handler) return self:_add_route("OPTIONS", path, handler) end - -function Router: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 +-- 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 @@ -319,9 +301,7 @@ function Request.new(req_table, response) req.cookies = parse_cookies(cookie_header) end - -- Create lazy session object req.session = Session.new(req.cookies, response) - return req end @@ -331,25 +311,17 @@ function Request:get(header_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: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(function() - return json.decode(self.body) - end) - - if success then - return result - else - error("Invalid JSON in request body") - end + 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() @@ -357,8 +329,25 @@ function Request:is_json() 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 +-- 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 @@ -370,22 +359,19 @@ Response.__index = Response function Response.new(res_table) return setmetatable({ _table = res_table, - _sent = false + _sent = false, + locals = {} }, Response) end function Response:status(code) - if self._sent then - error("Cannot set status after response has been sent") - end + 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 + if self._sent then error("Cannot set headers after response has been sent") end self._table.headers[name] = value return self end @@ -394,20 +380,12 @@ 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 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 + 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 = "" @@ -420,31 +398,16 @@ function Response:send(data) end function Response:json(data) - if self._sent then - error("Response already sent") - end - + 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 - + 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 - + 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 @@ -452,10 +415,7 @@ function Response:text(text) end function Response:html(html) - if self._sent then - error("Response already sent") - end - + 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 @@ -463,59 +423,31 @@ function Response:html(html) 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) + 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 - + 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 + 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 + 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 @@ -531,23 +463,229 @@ function Response:cookie(name, value, options) 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, "/") +-- 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) @@ -570,8 +708,7 @@ local function match_route(method, path) 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] + params[string.slice(route_seg, 2, -1)] = path_segments[i] else match = false end @@ -592,14 +729,13 @@ local function match_route(method, path) end -- ====================================================================== --- OPTIMIZED REQUEST HANDLING +-- REQUEST HANDLING -- ====================================================================== function _http_handle_request(req_table, res_table) local res = Response.new(res_table) local req = Request.new(req_table, res) - -- Fast route lookup local route, params = match_route(req.method, req.path) req.params = params @@ -608,42 +744,49 @@ function _http_handle_request(req_table, res_table) return end - -- Fast path: no middleware - if not route.middleware or #route.middleware == 0 then - route.handler(req, res) - - -- Auto-save dirty sessions - if req.session._loaded and req.session._dirty then - req.session:save() - end - return - end - - -- Run route-specific middleware chain + -- Enhanced middleware runner local function run_middleware(index) if index > #route.middleware then - route.handler(req, res) + safe_call(route.handler, req, res) return end - route.middleware[index](req, res, function() + local middleware = route.middleware[index] + if not middleware or type(middleware) ~= "function" then run_middleware(index + 1) - end) + 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 - run_middleware(1) + if route.middleware and #route.middleware > 0 then + run_middleware(1) + else + safe_call(route.handler, req, res) + end - -- Auto-save dirty sessions + -- Auto-save sessions if req.session._loaded and req.session._dirty then - req.session:save() + safe_call(req.session.save, req.session) end end function _http_get_routes() - return { - routes = _G._http_routes, - middleware = _G._http_middleware - } + return {routes = _G._http_routes, middleware = _G._http_middleware} end function _http_sync_worker_routes() @@ -655,151 +798,7 @@ function _http_sync_worker_routes() 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:group(path_prefix, callback) - local router = Router.new() - router._prefix = path_prefix - - if callback then - callback(router) - end - return router -end - -function Server:use(...) - -- Global middleware - stored per route during registration - 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) - - -- Apply global middleware to this route - local route_middleware = {} - for _, mw in ipairs(_G._http_middleware) do - if mw.path == nil or string.starts_with(path, mw.path) then - table.insert(route_middleware, mw.handler) - end - end - - table.insert(_G._http_routes, { - method = method, - path = path, - segments = segments, - handler = handler, - middleware = route_middleware - }) - - 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 +-- MIDDLEWARE -- ====================================================================== function http.router() @@ -831,7 +830,6 @@ 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 @@ -843,22 +841,20 @@ function http.static(root_path, url_prefix) end end - return function(req, res, next) - next() - end + return function(req, res, next) next() end end -function http.json_parser() +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(function() - return req:json() - end) - + local success, data = pcall(req.json, req) if success then req.json_body = data - else - res:json({error = "Invalid JSON"}) + elseif strict then + res:status(400):json({error = "Invalid JSON"}) return end end @@ -871,30 +867,158 @@ function http.logger(format) 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, { + 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 - print(log_message) +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 = '' + 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 '
' .. tostring(messages[flash_type]) .. '
' or "" + end + + next() end end function http.create_server(callback) local app = http.server() - if callback then - callback(app) - end + if callback then callback(app) end return app end @@ -916,11 +1040,10 @@ function http.cleanup_expired_sessions(max_age) 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 + 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