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()