1242 lines
33 KiB
Lua
1242 lines
33 KiB
Lua
local kv = require("kv")
|
|
local crypto = require("crypto")
|
|
|
|
local http = {}
|
|
|
|
-- Global routing tables
|
|
_G._http_routes = _G._http_routes or {}
|
|
_G._http_middleware = _G._http_middleware or {}
|
|
_G._http_session_config = _G._http_session_config or {
|
|
store_name = "sessions",
|
|
filename = "sessions.json",
|
|
cookie_name = "session_id",
|
|
cookie_options = {
|
|
path = "/",
|
|
http_only = true,
|
|
max_age = 86400,
|
|
same_site = "Lax"
|
|
}
|
|
}
|
|
|
|
-- ======================================================================
|
|
-- UTILITY FUNCTIONS
|
|
-- ======================================================================
|
|
|
|
local function parse_cookies(cookie_header)
|
|
local cookies = {}
|
|
if string.is_empty(cookie_header) then return cookies end
|
|
|
|
for _, cookie_pair in ipairs(string.split(cookie_header, ";")) do
|
|
local trimmed = string.trim(cookie_pair)
|
|
if not string.is_empty(trimmed) then
|
|
local parts = string.split(trimmed, "=")
|
|
if #parts >= 2 then
|
|
local name = string.trim(parts[1])
|
|
local value = string.trim(parts[2])
|
|
local success, decoded = pcall(string.url_decode, value)
|
|
cookies[name] = success and decoded or value
|
|
elseif #parts == 1 then
|
|
cookies[string.trim(parts[1])] = ""
|
|
end
|
|
end
|
|
end
|
|
return cookies
|
|
end
|
|
|
|
local function ensure_session_store()
|
|
local config = _G._http_session_config
|
|
if not kv.open(config.store_name, config.filename) then
|
|
error("Failed to initialize session store: " .. config.store_name)
|
|
end
|
|
end
|
|
|
|
local function split_path(path)
|
|
if string.is_empty(path) or path == "/" then return {} end
|
|
local clean_path = string.trim(path, "/")
|
|
return string.is_empty(clean_path) and {} or string.split(clean_path, "/")
|
|
end
|
|
|
|
local function safe_call(fn, ...)
|
|
local success, result = pcall(fn, ...)
|
|
if not success then
|
|
print("Error: " .. tostring(result))
|
|
end
|
|
return success, result
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- FORM PARSING UTILITIES
|
|
-- ======================================================================
|
|
|
|
local function parse_url_encoded(data)
|
|
local form = {}
|
|
if string.is_empty(data) then return form end
|
|
|
|
for pair in string.gmatch(data, "[^&]+") do
|
|
local key, value = string.match(pair, "([^=]+)=?(.*)")
|
|
if key then
|
|
key = string.url_decode(key)
|
|
value = string.url_decode(value or "")
|
|
|
|
-- Handle array-style inputs (name[], name[0], etc.)
|
|
local array_key = string.match(key, "^(.+)%[%]$")
|
|
if array_key then
|
|
if not form[array_key] then form[array_key] = {} end
|
|
table.insert(form[array_key], value)
|
|
else
|
|
local indexed_key, index = string.match(key, "^(.+)%[([^%]]+)%]$")
|
|
if indexed_key then
|
|
if not form[indexed_key] then form[indexed_key] = {} end
|
|
form[indexed_key][index] = value
|
|
else
|
|
form[key] = value
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return form
|
|
end
|
|
|
|
local function parse_multipart_boundary(content_type)
|
|
return string.match(content_type, "boundary=([^;%s]+)")
|
|
end
|
|
|
|
local function parse_multipart_header(header_line)
|
|
local headers = {}
|
|
for pair in string.gmatch(header_line, "[^;]+") do
|
|
local trimmed = string.trim(pair)
|
|
local key, value = string.match(trimmed, "^([^=]+)=?(.*)$")
|
|
if key then
|
|
key = string.trim(string.lower(key))
|
|
if value and string.starts_with(value, '"') and string.ends_with(value, '"') then
|
|
value = string.slice(value, 2, -2)
|
|
end
|
|
headers[key] = value or ""
|
|
end
|
|
end
|
|
return headers
|
|
end
|
|
|
|
local function parse_multipart_data(data, boundary)
|
|
local form = {}
|
|
local files = {}
|
|
|
|
if string.is_empty(data) or string.is_empty(boundary) then
|
|
return form, files
|
|
end
|
|
|
|
local delimiter = "--" .. boundary
|
|
local parts = string.split(data, delimiter)
|
|
|
|
for i = 2, #parts do -- Skip first empty part
|
|
local part = string.trim(parts[i])
|
|
if string.is_empty(part) or part == "--" then break end
|
|
|
|
-- Find headers/body boundary
|
|
local header_end = string.find(part, "\r?\n\r?\n")
|
|
if not header_end then goto continue end
|
|
|
|
local headers_section = string.slice(part, 1, header_end - 1)
|
|
local body = string.slice(part, header_end + 1, -1)
|
|
body = string.gsub(body, "\r?\n$", "") -- Remove trailing newline
|
|
|
|
-- Parse Content-Disposition header
|
|
local disposition_line = string.match(headers_section, "[Cc]ontent%-[Dd]isposition: ([^\r\n]+)")
|
|
if not disposition_line then goto continue end
|
|
|
|
local disposition = parse_multipart_header(disposition_line)
|
|
local field_name = disposition.name
|
|
if not field_name then goto continue end
|
|
|
|
-- Check if it's a file upload
|
|
if disposition.filename then
|
|
local content_type = string.match(headers_section, "[Cc]ontent%-[Tt]ype: ([^\r\n]+)") or "application/octet-stream"
|
|
|
|
local file_info = {
|
|
filename = disposition.filename,
|
|
content_type = string.trim(content_type),
|
|
size = #body,
|
|
data = body
|
|
}
|
|
|
|
-- Handle file arrays
|
|
if string.ends_with(field_name, "[]") then
|
|
local base_name = string.slice(field_name, 1, -3)
|
|
if not files[base_name] then files[base_name] = {} end
|
|
table.insert(files[base_name], file_info)
|
|
else
|
|
files[field_name] = file_info
|
|
end
|
|
else
|
|
-- Regular form field
|
|
if string.ends_with(field_name, "[]") then
|
|
local base_name = string.slice(field_name, 1, -3)
|
|
if not form[base_name] then form[base_name] = {} end
|
|
table.insert(form[base_name], body)
|
|
else
|
|
form[field_name] = body
|
|
end
|
|
end
|
|
|
|
::continue::
|
|
end
|
|
|
|
return form, files
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- CONTEXT CLASS (UNIFIED REQUEST/RESPONSE/SESSION)
|
|
-- ======================================================================
|
|
|
|
local Context = {}
|
|
Context.__index = Context
|
|
|
|
function Context.new(req_table, res_table)
|
|
local ctx = setmetatable({
|
|
-- Request data
|
|
_method = req_table.method,
|
|
_path = req_table.path,
|
|
_query = req_table.query or {},
|
|
_headers = req_table.headers or {},
|
|
_body = req_table.body or "",
|
|
_params = {},
|
|
_cookies = {},
|
|
|
|
-- Response data
|
|
_res_table = res_table,
|
|
_sent = false,
|
|
|
|
-- Session data
|
|
_session_loaded = false,
|
|
_session_dirty = false,
|
|
_session_data = {},
|
|
_flash_now = {},
|
|
_session_id = nil,
|
|
|
|
-- Parsed data cache
|
|
_json_body = nil,
|
|
_json_parsed = false,
|
|
_form_data = nil,
|
|
_files = nil,
|
|
_form_parsed = false,
|
|
|
|
-- Response locals
|
|
locals = {}
|
|
}, Context)
|
|
|
|
-- Parse cookies
|
|
local cookie_header = ctx._headers["Cookie"] or ctx._headers["cookie"]
|
|
if cookie_header then
|
|
ctx._cookies = parse_cookies(cookie_header)
|
|
end
|
|
|
|
return ctx
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- REQUEST DATA ACCESS
|
|
-- ======================================================================
|
|
|
|
function Context:method() return self._method end
|
|
function Context:path() return self._path end
|
|
function Context:body() return self._body end
|
|
|
|
function Context:param(name, default)
|
|
return self._params[name] or default
|
|
end
|
|
|
|
function Context:query(name, default)
|
|
return self._query[name] or default
|
|
end
|
|
|
|
function Context:header(name, default)
|
|
local lower_name = string.lower(name)
|
|
return self._headers[name] or self._headers[lower_name] or default
|
|
end
|
|
|
|
function Context:cookie(name, default)
|
|
return self._cookies[name] or default
|
|
end
|
|
|
|
function Context:has_cookie(name)
|
|
return self._cookies[name] ~= nil
|
|
end
|
|
|
|
function Context:user_agent()
|
|
return self:header("user-agent", "")
|
|
end
|
|
|
|
function Context:ip()
|
|
return self:header("x-forwarded-for") or self:header("x-real-ip") or "unknown"
|
|
end
|
|
|
|
function Context:is_json()
|
|
local content_type = self:header("content-type", "")
|
|
return string.contains(content_type, "application/json")
|
|
end
|
|
|
|
function Context:json_body()
|
|
if not self._json_parsed then
|
|
self._json_parsed = true
|
|
if not string.is_empty(self._body) then
|
|
local success, result = pcall(json.decode, self._body)
|
|
self._json_body = success and result or nil
|
|
end
|
|
end
|
|
return self._json_body
|
|
end
|
|
|
|
function Context:is_multipart()
|
|
local content_type = self:header("content-type", "")
|
|
return string.contains(content_type, "multipart/form-data")
|
|
end
|
|
|
|
function Context:is_form_data()
|
|
local content_type = self:header("content-type", "")
|
|
return string.contains(content_type, "application/x-www-form-urlencoded") or
|
|
string.contains(content_type, "multipart/form-data") or
|
|
(string.iequals(self._method, "POST") and not string.is_empty(self._body) and not self:is_json())
|
|
end
|
|
|
|
function Context:_ensure_form_parsed()
|
|
if self._form_parsed then return end
|
|
self._form_parsed = true
|
|
|
|
if string.is_empty(self._body) then
|
|
self._form_data = {}
|
|
self._files = {}
|
|
return
|
|
end
|
|
|
|
local content_type = self:header("content-type", "")
|
|
|
|
if string.contains(content_type, "multipart/form-data") then
|
|
local boundary = parse_multipart_boundary(content_type)
|
|
if boundary then
|
|
self._form_data, self._files = parse_multipart_data(self._body, boundary)
|
|
else
|
|
self._form_data = {}
|
|
self._files = {}
|
|
end
|
|
else
|
|
-- Default to URL-encoded parsing for POST with body
|
|
self._form_data = parse_url_encoded(self._body)
|
|
self._files = {}
|
|
end
|
|
end
|
|
|
|
function Context:form(name, default)
|
|
self:_ensure_form_parsed()
|
|
return self._form_data[name] or default
|
|
end
|
|
|
|
function Context:file(name)
|
|
self:_ensure_form_parsed()
|
|
return self._files[name]
|
|
end
|
|
|
|
function Context:files()
|
|
self:_ensure_form_parsed()
|
|
local copy = {}
|
|
for k, v in pairs(self._files) do copy[k] = v end
|
|
return copy
|
|
end
|
|
|
|
function Context:form_data()
|
|
self:_ensure_form_parsed()
|
|
local copy = {}
|
|
for k, v in pairs(self._form_data) do copy[k] = v end
|
|
return copy
|
|
end
|
|
|
|
function Context:has_form(name)
|
|
self:_ensure_form_parsed()
|
|
return self._form_data[name] ~= nil
|
|
end
|
|
|
|
function Context:has_file(name)
|
|
self:_ensure_form_parsed()
|
|
return self._files[name] ~= nil
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- RESPONSE METHODS
|
|
-- ======================================================================
|
|
|
|
function Context:status(code)
|
|
if self._sent then error("Cannot set status after response has been sent") end
|
|
self._res_table.status = code
|
|
return self
|
|
end
|
|
|
|
function Context:set_header(name, value)
|
|
if self._sent then error("Cannot set headers after response has been sent") end
|
|
self._res_table.headers[name] = value
|
|
return self
|
|
end
|
|
|
|
function Context:type(content_type)
|
|
return self:set_header("Content-Type", content_type)
|
|
end
|
|
|
|
function Context:send(data)
|
|
if self._sent then error("Response already sent") end
|
|
|
|
if type(data) == "table" then
|
|
self:type("application/json; charset=utf-8")
|
|
local success, json_str = pcall(json.encode, data)
|
|
self._res_table.body = success and json_str or error("Failed to encode JSON response")
|
|
elseif type(data) == "number" then
|
|
self._res_table.status = data
|
|
self._res_table.body = ""
|
|
else
|
|
self._res_table.body = tostring(data or "")
|
|
end
|
|
|
|
self._sent = true
|
|
return self
|
|
end
|
|
|
|
function Context:json(data)
|
|
if self._sent then error("Response already sent") end
|
|
self:type("application/json; charset=utf-8")
|
|
local success, json_str = pcall(json.encode, data)
|
|
self._res_table.body = success and json_str or error("Failed to encode JSON response")
|
|
self._sent = true
|
|
return self
|
|
end
|
|
|
|
function Context:text(text)
|
|
if self._sent then error("Response already sent") end
|
|
self:type("text/plain; charset=utf-8")
|
|
self._res_table.body = tostring(text or "")
|
|
self._sent = true
|
|
return self
|
|
end
|
|
|
|
function Context:html(html)
|
|
if self._sent then error("Response already sent") end
|
|
self:type("text/html; charset=utf-8")
|
|
self._res_table.body = tostring(html or "")
|
|
self._sent = true
|
|
return self
|
|
end
|
|
|
|
function Context:redirect(url, status)
|
|
if self._sent then error("Response already sent") end
|
|
self:status(status or 302):set_header("Location", url)
|
|
self._res_table.body = ""
|
|
self._sent = true
|
|
return self
|
|
end
|
|
|
|
function Context:set_cookie(name, value, options)
|
|
if self._sent then error("Cannot set cookies after response has been sent") end
|
|
options = options or {}
|
|
local cookie_value = tostring(value)
|
|
|
|
if string.match(cookie_value, "[;,\\s]") then
|
|
cookie_value = string.url_encode(cookie_value)
|
|
end
|
|
|
|
local cookie = name .. "=" .. cookie_value
|
|
|
|
if options.expires then cookie = cookie .. "; Expires=" .. options.expires end
|
|
if options.max_age then cookie = cookie .. "; Max-Age=" .. tostring(options.max_age) end
|
|
if options.domain then cookie = cookie .. "; Domain=" .. options.domain end
|
|
if options.path then cookie = cookie .. "; Path=" .. options.path end
|
|
if options.secure then cookie = cookie .. "; Secure" end
|
|
if options.http_only then cookie = cookie .. "; HttpOnly" end
|
|
if options.same_site then cookie = cookie .. "; SameSite=" .. options.same_site end
|
|
|
|
local existing = self._res_table.headers["Set-Cookie"]
|
|
if existing then
|
|
if type(existing) == "table" then
|
|
table.insert(existing, cookie)
|
|
else
|
|
self._res_table.headers["Set-Cookie"] = {existing, cookie}
|
|
end
|
|
else
|
|
self._res_table.headers["Set-Cookie"] = cookie
|
|
end
|
|
|
|
return self
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- SESSION METHODS (FLATTENED)
|
|
-- ======================================================================
|
|
|
|
function Context:_ensure_session_loaded()
|
|
if self._session_loaded then return end
|
|
|
|
ensure_session_store()
|
|
local config = _G._http_session_config
|
|
local session_id = self._cookies[config.cookie_name]
|
|
|
|
if session_id then
|
|
local json_str = kv.get(config.store_name, "session:" .. session_id)
|
|
self._session_data = json_str and json.decode(json_str) or {}
|
|
self:set_cookie(config.cookie_name, session_id, config.cookie_options)
|
|
else
|
|
session_id = crypto.random_alphanumeric(32)
|
|
self._session_data = {}
|
|
self:set_cookie(config.cookie_name, session_id, config.cookie_options)
|
|
end
|
|
|
|
self._session_id = session_id
|
|
self._session_loaded = true
|
|
end
|
|
|
|
function Context:session_get(key, default)
|
|
self:_ensure_session_loaded()
|
|
return self._session_data[key] or default
|
|
end
|
|
|
|
function Context:session_set(key, value)
|
|
self:_ensure_session_loaded()
|
|
self._session_data[key] = value
|
|
self._session_dirty = true
|
|
return self
|
|
end
|
|
|
|
function Context:session_delete(key)
|
|
self:_ensure_session_loaded()
|
|
self._session_data[key] = nil
|
|
self._session_dirty = true
|
|
return self
|
|
end
|
|
|
|
function Context:session_clear()
|
|
self:_ensure_session_loaded()
|
|
self._session_data = {}
|
|
self._session_dirty = true
|
|
return self
|
|
end
|
|
|
|
function Context:session_has(key)
|
|
self:_ensure_session_loaded()
|
|
return self._session_data[key] ~= nil
|
|
end
|
|
|
|
function Context:session_data()
|
|
self:_ensure_session_loaded()
|
|
local copy = {}
|
|
for k, v in pairs(self._session_data) do copy[k] = v end
|
|
return copy
|
|
end
|
|
|
|
function Context:session_save()
|
|
if not self._session_loaded or not self._session_id then return false end
|
|
local config = _G._http_session_config
|
|
return kv.set(config.store_name, "session:" .. self._session_id, json.encode(self._session_data))
|
|
end
|
|
|
|
function Context:session_destroy()
|
|
self:_ensure_session_loaded()
|
|
local config = _G._http_session_config
|
|
kv.delete(config.store_name, "session:" .. self._session_id)
|
|
self:set_cookie(config.cookie_name, "", {
|
|
expires = "Thu, 01 Jan 1970 00:00:00 GMT",
|
|
path = config.cookie_options.path or "/"
|
|
})
|
|
self._session_id = nil
|
|
self._session_data = {}
|
|
return self
|
|
end
|
|
|
|
function Context:session_regenerate()
|
|
self:_ensure_session_loaded()
|
|
local config = _G._http_session_config
|
|
|
|
-- Save current data
|
|
local current_data = {}
|
|
for k, v in pairs(self._session_data) do
|
|
current_data[k] = v
|
|
end
|
|
|
|
-- Delete old session
|
|
if self._session_id then
|
|
kv.delete(config.store_name, "session:" .. self._session_id)
|
|
end
|
|
|
|
-- Create new session ID and restore data
|
|
local new_id = crypto.random_alphanumeric(32)
|
|
self._session_id = new_id
|
|
self._session_data = current_data
|
|
self._session_dirty = true
|
|
|
|
-- Replace the cookie
|
|
self._res_table.headers["Set-Cookie"] = config.cookie_name .. "=" .. self._session_id ..
|
|
"; Max-Age=" .. tostring(config.cookie_options.max_age) ..
|
|
"; Path=" .. (config.cookie_options.path or "/") ..
|
|
(config.cookie_options.http_only and "; HttpOnly" or "") ..
|
|
(config.cookie_options.same_site and ("; SameSite=" .. config.cookie_options.same_site) or "")
|
|
|
|
return self
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- CSRF METHODS (FLATTENED)
|
|
-- ======================================================================
|
|
|
|
function Context:csrf_token()
|
|
self:_ensure_session_loaded()
|
|
local token = self._session_data._csrf_token
|
|
if not token then
|
|
token = crypto.random_alphanumeric(40)
|
|
self._session_data._csrf_token = token
|
|
self._session_dirty = true
|
|
end
|
|
return token
|
|
end
|
|
|
|
function Context:verify_csrf(token)
|
|
if not token then return false end
|
|
self:_ensure_session_loaded()
|
|
return self._session_data._csrf_token == token
|
|
end
|
|
|
|
function Context:csrf_field()
|
|
return '<input type="hidden" name="_csrf_token" value="'.. self:csrf_token() ..'">'
|
|
end
|
|
|
|
function Context:regenerate_csrf_token()
|
|
self:_ensure_session_loaded()
|
|
self._session_data._csrf_token = crypto.random_alphanumeric(40)
|
|
self._session_dirty = true
|
|
return self._session_data._csrf_token
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- FLASH METHODS (FLATTENED)
|
|
-- ======================================================================
|
|
|
|
function Context:flash(key, message)
|
|
self:_ensure_session_loaded()
|
|
if not self._session_data._flash then self._session_data._flash = {} end
|
|
|
|
if message ~= nil then
|
|
self._session_data._flash[key] = message
|
|
self._session_dirty = true
|
|
return self
|
|
else
|
|
local msg = self._session_data._flash and self._session_data._flash[key]
|
|
if msg and self._session_data._flash then
|
|
self._session_data._flash[key] = nil
|
|
self._session_dirty = true
|
|
end
|
|
return msg
|
|
end
|
|
end
|
|
|
|
function Context:flash_now(key, message)
|
|
if message ~= nil then
|
|
self._flash_now[key] = message
|
|
return self
|
|
else
|
|
return self._flash_now[key]
|
|
end
|
|
end
|
|
|
|
function Context:get_flash()
|
|
self:_ensure_session_loaded()
|
|
local messages = {}
|
|
|
|
if self._session_data._flash then
|
|
for k, v in pairs(self._session_data._flash) do messages[k] = v end
|
|
self._session_data._flash = {}
|
|
self._session_dirty = true
|
|
end
|
|
|
|
if self._flash_now then
|
|
for k, v in pairs(self._flash_now) do messages[k] = v end
|
|
end
|
|
|
|
return messages
|
|
end
|
|
|
|
function Context:clear_flash()
|
|
self:_ensure_session_loaded()
|
|
if self._session_data._flash then
|
|
self._session_data._flash = {}
|
|
self._session_dirty = true
|
|
end
|
|
self._flash_now = {}
|
|
return self
|
|
end
|
|
|
|
-- Flash convenience methods
|
|
function Context:flash_success(msg) return self:flash("success", msg) end
|
|
function Context:flash_error(msg) return self:flash("error", msg) end
|
|
function Context:flash_warning(msg) return self:flash("warning", msg) end
|
|
function Context:flash_info(msg) return self:flash("info", msg) end
|
|
|
|
-- ======================================================================
|
|
-- AUTH METHODS (FLATTENED)
|
|
-- ======================================================================
|
|
|
|
function Context:is_authenticated()
|
|
return self:session_get("authenticated", false)
|
|
end
|
|
|
|
function Context:current_user()
|
|
return self:session_get("user")
|
|
end
|
|
|
|
function Context:login(user_data)
|
|
self:session_set("user", user_data)
|
|
self:session_set("authenticated", true)
|
|
self:session_regenerate()
|
|
return self
|
|
end
|
|
|
|
function Context:logout()
|
|
self:session_clear()
|
|
self:session_regenerate()
|
|
return self
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- REDIRECT WITH FLASH CONVENIENCE
|
|
-- ======================================================================
|
|
|
|
function Context:redirect_with_flash(url, flash_type, message, status)
|
|
self:flash(flash_type, message)
|
|
return self:redirect(url, status)
|
|
end
|
|
|
|
function Context:redirect_with_success(url, message, status)
|
|
return self:redirect_with_flash(url, "success", message, status)
|
|
end
|
|
|
|
function Context:redirect_with_error(url, message, status)
|
|
return self:redirect_with_flash(url, "error", message, status)
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- ROUTING BASE CLASS
|
|
-- ======================================================================
|
|
|
|
local RouteBuilder = {}
|
|
RouteBuilder.__index = RouteBuilder
|
|
|
|
function RouteBuilder:_add_route(method, path, ...)
|
|
local args = {...}
|
|
if #args == 0 then error("Route handler is required") end
|
|
|
|
local handler = args[#args]
|
|
local route_middleware = {}
|
|
|
|
for i = 1, #args - 1 do
|
|
if type(args[i]) == "function" then
|
|
table.insert(route_middleware, args[i])
|
|
else
|
|
error("Route middleware must be functions")
|
|
end
|
|
end
|
|
|
|
if not string.starts_with(path, "/") then path = "/" .. path end
|
|
local full_path = (self._prefix or "") .. path
|
|
local segments = split_path(full_path)
|
|
|
|
-- Build complete middleware chain
|
|
local complete_middleware = {}
|
|
|
|
-- Global middleware
|
|
for _, mw in ipairs(_G._http_middleware) do
|
|
if mw.path == nil or string.starts_with(full_path, mw.path) then
|
|
table.insert(complete_middleware, mw.handler)
|
|
end
|
|
end
|
|
|
|
-- Router middleware
|
|
if self._middleware then
|
|
for _, mw in ipairs(self._middleware) do
|
|
if type(mw) == "function" then
|
|
table.insert(complete_middleware, mw)
|
|
elseif type(mw) == "table" and mw.path and string.starts_with(full_path, mw.path) then
|
|
table.insert(complete_middleware, mw.handler)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Route-specific middleware
|
|
for _, mw in ipairs(route_middleware) do
|
|
table.insert(complete_middleware, mw)
|
|
end
|
|
|
|
table.insert(_G._http_routes, {
|
|
method = method,
|
|
path = full_path,
|
|
segments = segments,
|
|
handler = handler,
|
|
middleware = complete_middleware
|
|
})
|
|
|
|
return self
|
|
end
|
|
|
|
-- HTTP method helpers
|
|
function RouteBuilder:get(path, ...) return self:_add_route("GET", path, ...) end
|
|
function RouteBuilder:post(path, ...) return self:_add_route("POST", path, ...) end
|
|
function RouteBuilder:put(path, ...) return self:_add_route("PUT", path, ...) end
|
|
function RouteBuilder:delete(path, ...) return self:_add_route("DELETE", path, ...) end
|
|
function RouteBuilder:patch(path, ...) return self:_add_route("PATCH", path, ...) end
|
|
function RouteBuilder:head(path, ...) return self:_add_route("HEAD", path, ...) end
|
|
function RouteBuilder:options(path, ...) return self:_add_route("OPTIONS", path, ...) end
|
|
|
|
function RouteBuilder:all(path, ...)
|
|
local methods = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
|
|
for _, method in ipairs(methods) do
|
|
self:_add_route(method, path, ...)
|
|
end
|
|
return self
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- ROUTER CLASS
|
|
-- ======================================================================
|
|
|
|
local Router = {}
|
|
Router.__index = Router
|
|
setmetatable(Router, {__index = RouteBuilder})
|
|
|
|
function Router.new()
|
|
return setmetatable({
|
|
_middleware = {},
|
|
_prefix = ""
|
|
}, Router)
|
|
end
|
|
|
|
function Router:use(...)
|
|
local args = {...}
|
|
if #args == 1 and type(args[1]) == "function" then
|
|
table.insert(self._middleware, args[1])
|
|
elseif #args == 2 and type(args[1]) == "string" and type(args[2]) == "function" then
|
|
table.insert(self._middleware, {path = args[1], handler = args[2]})
|
|
else
|
|
error("Invalid arguments to use()")
|
|
end
|
|
return self
|
|
end
|
|
|
|
function Router:group(path_prefix, callback)
|
|
local group_router = Router.new()
|
|
group_router._prefix = self._prefix .. path_prefix
|
|
|
|
-- Inherit parent middleware
|
|
for _, mw in ipairs(self._middleware) do
|
|
table.insert(group_router._middleware, mw)
|
|
end
|
|
|
|
if callback then callback(group_router) end
|
|
return group_router
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- SERVER CLASS
|
|
-- ======================================================================
|
|
|
|
local Server = {}
|
|
Server.__index = Server
|
|
setmetatable(Server, {__index = RouteBuilder})
|
|
|
|
function http.server()
|
|
if _G.__IS_WORKER then
|
|
return setmetatable({}, Server)
|
|
end
|
|
|
|
local server = setmetatable({_server_created = false}, Server)
|
|
local success, err = moonshark.http_create_server()
|
|
if not success then
|
|
error("Failed to create HTTP server: " .. (err or "unknown error"))
|
|
end
|
|
server._server_created = true
|
|
return server
|
|
end
|
|
|
|
function Server:configure_sessions(options)
|
|
options = options or {}
|
|
local config = _G._http_session_config
|
|
config.store_name = options.store_name or config.store_name
|
|
config.filename = options.filename or config.filename
|
|
config.cookie_name = options.cookie_name or config.cookie_name
|
|
|
|
if options.cookie_options then
|
|
for k, v in pairs(options.cookie_options) do
|
|
config.cookie_options[k] = v
|
|
end
|
|
end
|
|
|
|
ensure_session_store()
|
|
return self
|
|
end
|
|
|
|
function Server:group(path_prefix, callback)
|
|
local router = Router.new()
|
|
router._prefix = path_prefix
|
|
if callback then callback(router) end
|
|
return router
|
|
end
|
|
|
|
function Server:use(...)
|
|
local args = {...}
|
|
if #args == 1 and type(args[1]) == "function" then
|
|
table.insert(_G._http_middleware, {path = nil, handler = args[1]})
|
|
elseif #args == 2 and type(args[1]) == "string" and type(args[2]) == "function" then
|
|
table.insert(_G._http_middleware, {path = args[1], handler = args[2]})
|
|
else
|
|
error("Invalid arguments to use()")
|
|
end
|
|
return self
|
|
end
|
|
|
|
function Server:listen(port, host, callback)
|
|
if _G.__IS_WORKER then
|
|
if callback then callback() end
|
|
return self
|
|
end
|
|
|
|
if type(host) == "function" then
|
|
callback = host
|
|
host = "localhost"
|
|
end
|
|
|
|
host = host or "localhost"
|
|
local addr = host .. ":" .. tostring(port)
|
|
|
|
local success, err = moonshark.http_spawn_workers()
|
|
if not success then error("Failed to spawn workers: " .. (err or "unknown error")) end
|
|
|
|
success, err = moonshark.http_listen(addr)
|
|
if not success then error("Failed to start server: " .. (err or "unknown error")) end
|
|
|
|
if callback then callback() end
|
|
return self
|
|
end
|
|
|
|
function Server:close()
|
|
return _G.__IS_WORKER or moonshark.http_close_server()
|
|
end
|
|
|
|
function Server:static(root_path, url_prefix, no_cache)
|
|
if not no_cache or no_cache ~= true then no_cache = false end
|
|
|
|
url_prefix = url_prefix or "/"
|
|
if not string.starts_with(url_prefix, "/") then
|
|
url_prefix = "/" .. url_prefix
|
|
end
|
|
|
|
if not _G.__IS_WORKER then
|
|
local success, err = moonshark.http_register_static(url_prefix, root_path, no_cache)
|
|
if not success then
|
|
error("Failed to register static handler: " .. (err or "unknown error"))
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- ROUTING ENGINE
|
|
-- ======================================================================
|
|
|
|
local function match_route(method, path)
|
|
local path_segments = split_path(path)
|
|
|
|
for _, route in ipairs(_G._http_routes) do
|
|
if route.method == method then
|
|
local params = {}
|
|
local route_segments = route.segments
|
|
local match = true
|
|
local i = 1
|
|
|
|
while i <= #route_segments and match do
|
|
local route_seg = route_segments[i]
|
|
|
|
if route_seg == "*" then
|
|
local remaining = {}
|
|
for j = i, #path_segments do
|
|
table.insert(remaining, path_segments[j])
|
|
end
|
|
params["*"] = string.join(remaining, "/")
|
|
break
|
|
elseif string.starts_with(route_seg, ":") then
|
|
if i <= #path_segments then
|
|
params[string.slice(route_seg, 2, -1)] = path_segments[i]
|
|
else
|
|
match = false
|
|
end
|
|
else
|
|
if i > #path_segments or route_seg ~= path_segments[i] then
|
|
match = false
|
|
end
|
|
end
|
|
i = i + 1
|
|
end
|
|
|
|
if match and (i > #path_segments or (route_segments[i-1] and route_segments[i-1] == "*")) then
|
|
return route, params
|
|
end
|
|
end
|
|
end
|
|
return nil, {}
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- REQUEST HANDLING (UPDATED FOR CONTEXT)
|
|
-- ======================================================================
|
|
|
|
function _http_handle_request(req_table, res_table)
|
|
local ctx = Context.new(req_table, res_table)
|
|
|
|
local route, params = match_route(ctx:method(), ctx:path())
|
|
ctx._params = params
|
|
|
|
if not route then
|
|
ctx:send(404)
|
|
return
|
|
end
|
|
|
|
-- Enhanced middleware runner
|
|
local function run_middleware(index)
|
|
if index > #route.middleware then
|
|
safe_call(route.handler, ctx)
|
|
return
|
|
end
|
|
|
|
local middleware = route.middleware[index]
|
|
if not middleware or type(middleware) ~= "function" then
|
|
run_middleware(index + 1)
|
|
return
|
|
end
|
|
|
|
local next_called = false
|
|
local function next()
|
|
if next_called then
|
|
print("Warning: next() called multiple times")
|
|
return
|
|
end
|
|
next_called = true
|
|
run_middleware(index + 1)
|
|
end
|
|
|
|
local success = safe_call(middleware, ctx, next)
|
|
if success and not next_called and not ctx._sent then
|
|
next()
|
|
end
|
|
end
|
|
|
|
if route.middleware and #route.middleware > 0 then
|
|
run_middleware(1)
|
|
else
|
|
safe_call(route.handler, ctx)
|
|
end
|
|
|
|
-- Auto-save sessions
|
|
if ctx._session_loaded and ctx._session_dirty then
|
|
safe_call(ctx.session_save, ctx)
|
|
end
|
|
end
|
|
|
|
function _http_get_routes()
|
|
return {routes = _G._http_routes, middleware = _G._http_middleware}
|
|
end
|
|
|
|
function _http_sync_worker_routes()
|
|
local data = _G._http_routes_data
|
|
if data and data.routes and data.middleware then
|
|
_G._http_routes = data.routes
|
|
_G._http_middleware = data.middleware
|
|
end
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- MIDDLEWARE
|
|
-- ======================================================================
|
|
|
|
function http.router()
|
|
return Router.new()
|
|
end
|
|
|
|
function http.cors(options)
|
|
options = options or {}
|
|
local origin = options.origin or "*"
|
|
local methods = options.methods or "GET,HEAD,PUT,PATCH,POST,DELETE"
|
|
local headers = options.headers or "Content-Type,Authorization"
|
|
|
|
return function(ctx, next)
|
|
ctx:set_header("Access-Control-Allow-Origin", origin)
|
|
ctx:set_header("Access-Control-Allow-Methods", methods)
|
|
ctx:set_header("Access-Control-Allow-Headers", headers)
|
|
|
|
if options.credentials then
|
|
ctx:set_header("Access-Control-Allow-Credentials", "true")
|
|
end
|
|
|
|
if string.iequals(ctx:method(), "OPTIONS") then
|
|
ctx:send("")
|
|
else
|
|
next()
|
|
end
|
|
end
|
|
end
|
|
|
|
function http.json_parser(options)
|
|
options = options or {}
|
|
local strict = options.strict ~= false
|
|
|
|
return function(ctx, next)
|
|
if ctx:is_json() and not string.is_empty(ctx:body()) then
|
|
local json_data = ctx:json_body()
|
|
if json_data then
|
|
ctx.json_body_parsed = json_data
|
|
elseif strict then
|
|
ctx:status(400):json({error = "Invalid JSON"})
|
|
return
|
|
end
|
|
end
|
|
next()
|
|
end
|
|
end
|
|
|
|
function http.logger(format)
|
|
format = format or "${method} ${path} ${status}"
|
|
|
|
return function(ctx, next)
|
|
local start_time = os.clock()
|
|
next()
|
|
|
|
local duration = (os.clock() - start_time) * 1000
|
|
local status = ctx._res_table.status or 200
|
|
|
|
print(string.template(format, {
|
|
method = ctx:method(),
|
|
path = ctx:path(),
|
|
status = status,
|
|
["response-time"] = string.format("%.2f", duration),
|
|
["user-agent"] = ctx:user_agent(),
|
|
ip = ctx:ip()
|
|
}))
|
|
end
|
|
end
|
|
|
|
function http.csrf_protection(options)
|
|
options = options or {}
|
|
local safe_methods = options.safe_methods or {"GET", "HEAD", "OPTIONS"}
|
|
local token_field = options.token_field or "_csrf_token"
|
|
local header_name = options.header_name or "X-CSRF-Token"
|
|
|
|
return function(ctx, next)
|
|
-- Skip for safe methods
|
|
for _, method in ipairs(safe_methods) do
|
|
if string.iequals(ctx:method(), method) then
|
|
next()
|
|
return
|
|
end
|
|
end
|
|
|
|
local session_token = ctx:csrf_token()
|
|
local request_token = ctx:header(header_name)
|
|
|
|
-- Check form data (new fast method)
|
|
if not request_token and ctx:is_form_data() then
|
|
request_token = ctx:form(token_field)
|
|
end
|
|
|
|
-- Check JSON body
|
|
if not request_token and ctx:is_json() then
|
|
local json_data = ctx:json_body()
|
|
if json_data then request_token = json_data[token_field] end
|
|
end
|
|
|
|
if not ctx:verify_csrf(request_token) then
|
|
ctx:status(403):json({error = "CSRF token mismatch"})
|
|
return
|
|
end
|
|
|
|
next()
|
|
end
|
|
end
|
|
|
|
function http.require_auth(redirect_url)
|
|
redirect_url = redirect_url or "/login"
|
|
|
|
return function(ctx, next)
|
|
if not ctx:is_authenticated() then
|
|
if ctx:header("Accept") and string.contains(ctx:header("Accept"), "application/json") then
|
|
ctx:status(401):json({error = "Authentication required"})
|
|
else
|
|
ctx:flash("error", "You must be logged in to access this page")
|
|
ctx:redirect(redirect_url)
|
|
end
|
|
return
|
|
end
|
|
next()
|
|
end
|
|
end
|
|
|
|
function http.require_guest(redirect_url)
|
|
redirect_url = redirect_url or "/"
|
|
|
|
return function(ctx, next)
|
|
if ctx:is_authenticated() then
|
|
ctx:redirect(redirect_url)
|
|
return
|
|
end
|
|
next()
|
|
end
|
|
end
|
|
|
|
function http.create_server(callback)
|
|
local app = http.server()
|
|
if callback then callback(app) end
|
|
return app
|
|
end
|
|
|
|
-- ======================================================================
|
|
-- SESSION UTILITIES
|
|
-- ======================================================================
|
|
|
|
function http.cleanup_expired_sessions(max_age)
|
|
max_age = max_age or 86400
|
|
ensure_session_store()
|
|
|
|
local config = _G._http_session_config
|
|
local keys = kv.keys(config.store_name)
|
|
local current_time = os.time()
|
|
local deleted = 0
|
|
|
|
for _, key in ipairs(keys) do
|
|
if string.starts_with(key, "session:") then
|
|
local json_str = kv.get(config.store_name, key)
|
|
if json_str then
|
|
local session_data = json.decode(json_str)
|
|
if session_data and session_data._last_accessed and
|
|
current_time - session_data._last_accessed > max_age then
|
|
kv.delete(config.store_name, key)
|
|
deleted = deleted + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return deleted
|
|
end
|
|
|
|
function http.get_session_count()
|
|
ensure_session_store()
|
|
local config = _G._http_session_config
|
|
local keys = kv.keys(config.store_name)
|
|
local count = 0
|
|
|
|
for _, key in ipairs(keys) do
|
|
if string.starts_with(key, "session:") then
|
|
count = count + 1
|
|
end
|
|
end
|
|
|
|
return count
|
|
end
|
|
|
|
return http
|