From 4d0d5b6757fc336de3973ab1903acc40e54ae065 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Fri, 25 Jul 2025 19:01:26 -0500 Subject: [PATCH] merge request and response into context --- modules/http/http.lua | 968 +++++++++++++++++++++++++----------------- 1 file changed, 583 insertions(+), 385 deletions(-) diff --git a/modules/http/http.lua b/modules/http/http.lua index 441ee29..05a1de4 100644 --- a/modules/http/http.lua +++ b/modules/http/http.lua @@ -65,399 +65,372 @@ local function safe_call(fn, ...) end -- ====================================================================== --- SESSION OBJECT +-- FORM PARSING UTILITIES -- ====================================================================== -local Session = {} -Session.__index = Session +local function parse_url_encoded(data) + local form = {} + if string.is_empty(data) then return form end -function Session.new(cookies, response) - return setmetatable({ - _cookies = cookies, - _response = response, - _loaded = false, - _dirty = false, - _data = {}, - _flash_now = {}, - id = nil - }, Session) -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 "") -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 + -- 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 - return msg end + return form 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 +local function parse_multipart_boundary(content_type) + return string.match(content_type, "boundary=([^;%s]+)") 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 +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 - - if self._flash_now then - for k, v in pairs(self._flash_now) do messages[k] = v end - end - - return messages + return headers 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 +local function parse_multipart_data(data, boundary) + local form = {} + local files = {} --- 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 + 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 -- ====================================================================== --- REQUEST CLASS +-- CONTEXT CLASS (UNIFIED REQUEST/RESPONSE/SESSION) -- ====================================================================== -local Request = {} -Request.__index = Request +local Context = {} +Context.__index = Context -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) +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 = {}, - local cookie_header = req.headers["Cookie"] or req.headers["cookie"] + -- 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 - req.cookies = parse_cookies(cookie_header) + ctx._cookies = parse_cookies(cookie_header) end - req.session = Session.new(req.cookies, response) - return req + return ctx end -function Request:get(header_name) - local lower_name = string.lower(header_name) - return self.headers[header_name] or self.headers[lower_name] +-- ====================================================================== +-- 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 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") +function Context:query(name, default) + return self._query[name] or default end -function Request:is_json() - local content_type = self:get("content-type") or "" +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 --- Static flash methods (no dynamic assignment) -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:clear_flash() return self.session:clear_flash() end -function Request:flash_success(msg) return self.session:flash_success(msg) end -function Request:flash_error(msg) return self.session:flash_error(msg) end -function Request:flash_warning(msg) return self.session:flash_warning(msg) end -function Request:flash_info(msg) return self.session:flash_info(msg) 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:is_authenticated() return self.session:get("authenticated", false) end -function Request:current_user() return self.session:get("user") end - -function Request:csrf_field() - return '' +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 Request:login(user_data) - self.session:set("user", user_data) - self.session:set("authenticated", true) - self.session:regenerate() +function Context:is_multipart() + local content_type = self:header("content-type", "") + return string.contains(content_type, "multipart/form-data") end -function Request:logout() - self.session:clear() - self.session:regenerate() +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 CLASS +-- RESPONSE METHODS -- ====================================================================== -local Response = {} -Response.__index = Response - -function Response.new(res_table) - return setmetatable({ - _table = res_table, - _sent = false, - locals = {} - }, Response) -end - -function Response:status(code) +function Context:status(code) if self._sent then error("Cannot set status after response has been sent") end - self._table.status = code + self._res_table.status = code return self end -function Response:header(name, value) +function Context:set_header(name, value) if self._sent then error("Cannot set headers after response has been sent") end - self._table.headers[name] = value + self._res_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 Context:type(content_type) + return self:set_header("Content-Type", content_type) +end -function Response:send(data) +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._table.body = success and json_str or error("Failed to encode JSON response") + self._res_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 = "" + self._res_table.status = data + self._res_table.body = "" else - self._table.body = tostring(data or "") + self._res_table.body = tostring(data or "") end self._sent = true return self end -function Response:json(data) +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._table.body = success and json_str or error("Failed to encode JSON response") + self._res_table.body = success and json_str or error("Failed to encode JSON response") self._sent = true return self end -function Response:text(text) +function Context: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._res_table.body = tostring(text or "") self._sent = true return self end -function Response:html(html) +function Context: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._res_table.body = tostring(html or "") self._sent = true return self end -function Response:redirect(url, status) +function Context:redirect(url, status) if self._sent then error("Response already sent") end - self:status(status or 302):header("Location", url) - self._table.body = "" + self:status(status or 302):set_header("Location", url) + self._res_table.body = "" self._sent = true return self end --- Static redirect with flash methods -function Response:redirect_with_flash(url, flash_type, message, status, request) - if not request then error("Request object required for flash redirect") end - request:flash(flash_type, message) - return self:redirect(url, status) -end - -function Response:redirect_with_success(url, message, status, request) - return self:redirect_with_flash(url, "success", message, status, request) -end - -function Response:redirect_with_error(url, message, status, request) - return self:redirect_with_flash(url, "error", message, status, request) -end - -function Response:cookie(name, value, options) +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) @@ -476,20 +449,271 @@ function Response:cookie(name, value, options) 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"] + local existing = self._res_table.headers["Set-Cookie"] if existing then if type(existing) == "table" then table.insert(existing, cookie) else - self._table.headers["Set-Cookie"] = {existing, cookie} + self._res_table.headers["Set-Cookie"] = {existing, cookie} end else - self._table.headers["Set-Cookie"] = cookie + 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 -- ====================================================================== @@ -757,25 +981,24 @@ local function match_route(method, path) end -- ====================================================================== --- REQUEST HANDLING +-- REQUEST HANDLING (UPDATED FOR CONTEXT) -- ====================================================================== function _http_handle_request(req_table, res_table) - local res = Response.new(res_table) - local req = Request.new(req_table, res) + local ctx = Context.new(req_table, res_table) - local route, params = match_route(req.method, req.path) - req.params = params + local route, params = match_route(ctx:method(), ctx:path()) + ctx._params = params if not route then - res:send(404) + ctx:send(404) return end -- Enhanced middleware runner local function run_middleware(index) if index > #route.middleware then - safe_call(route.handler, req, res) + safe_call(route.handler, ctx) return end @@ -795,8 +1018,8 @@ function _http_handle_request(req_table, res_table) run_middleware(index + 1) end - local success = safe_call(middleware, req, res, next) - if success and not next_called and not res._sent then + local success = safe_call(middleware, ctx, next) + if success and not next_called and not ctx._sent then next() end end @@ -804,12 +1027,12 @@ function _http_handle_request(req_table, res_table) if route.middleware and #route.middleware > 0 then run_middleware(1) else - safe_call(route.handler, req, res) + safe_call(route.handler, ctx) end -- Auto-save sessions - if req.session._loaded and req.session._dirty then - safe_call(req.session.save, req.session) + if ctx._session_loaded and ctx._session_dirty then + safe_call(ctx.session_save, ctx) end end @@ -839,17 +1062,17 @@ function http.cors(options) 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) + 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 - res:header("Access-Control-Allow-Credentials", "true") + ctx:set_header("Access-Control-Allow-Credentials", "true") end - if string.iequals(req.method, "OPTIONS") then - res:send("") + if string.iequals(ctx:method(), "OPTIONS") then + ctx:send("") else next() end @@ -860,13 +1083,13 @@ 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 + 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 - res:status(400):json({error = "Invalid JSON"}) + ctx:status(400):json({error = "Invalid JSON"}) return end end @@ -877,20 +1100,20 @@ end function http.logger(format) format = format or "${method} ${path} ${status}" - return function(req, res, next) + return function(ctx, next) local start_time = os.clock() next() local duration = (os.clock() - start_time) * 1000 - local status = res._table.status or 200 + local status = ctx._res_table.status or 200 print(string.template(format, { - method = req.method, - path = req.path, + method = ctx:method(), + path = ctx:path(), status = status, ["response-time"] = string.format("%.2f", duration), - ["user-agent"] = req:user_agent(), - ip = req:ip() + ["user-agent"] = ctx:user_agent(), + ip = ctx:ip() })) end end @@ -901,34 +1124,31 @@ function http.csrf_protection(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) + return function(ctx, next) -- Skip for safe methods for _, method in ipairs(safe_methods) do - if string.iequals(req.method, method) then + if string.iequals(ctx:method(), method) then next() return end end - local session_token = req.session:csrf_token() - local request_token = req:header(header_name) + local session_token = ctx:csrf_token() + local request_token = ctx: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 + -- 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 req:is_json() then - local json_data = req:json() + 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 req.session:verify_csrf_token(request_token) then - res:status(403):json({error = "CSRF token mismatch"}) + if not ctx:verify_csrf(request_token) then + ctx:status(403):json({error = "CSRF token mismatch"}) return end @@ -936,38 +1156,16 @@ function http.csrf_protection(options) 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"}) + 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 - req:flash("error", "You must be logged in to access this page") - res:redirect(redirect_url) + ctx:flash("error", "You must be logged in to access this page") + ctx:redirect(redirect_url) end return end @@ -978,9 +1176,9 @@ 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 function(ctx, next) + if ctx:is_authenticated() then + ctx:redirect(redirect_url) return end next()