From f09c9f345ad962fb13c37387b0621987a371a1a7 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Thu, 24 Jul 2025 22:30:23 -0500 Subject: [PATCH] merge sessions into http module directly --- modules/http/http.lua | 871 +++++++++++++++++----------------- modules/sessions/sessions.lua | 211 -------- 2 files changed, 435 insertions(+), 647 deletions(-) delete mode 100644 modules/sessions/sessions.lua diff --git a/modules/http/http.lua b/modules/http/http.lua index a83d3aa..be487a5 100644 --- a/modules/http/http.lua +++ b/modules/http/http.lua @@ -1,18 +1,24 @@ +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 {} - --- Request/Response classes -local Request = {} -Request.__index = Request - -local Response = {} -Response.__index = Response +_G._http_session_config = _G._http_session_config or { + store_name = "sessions", + cookie_name = "session_id", + cookie_options = { + path = "/", + http_only = true, + max_age = 86400, -- 24 hours + same_site = "Lax" + } +} -- ====================================================================== --- ENHANCED COOKIE PARSING +-- UTILITY FUNCTIONS -- ====================================================================== local function parse_cookies(cookie_header) @@ -21,7 +27,6 @@ local function parse_cookies(cookie_header) return cookies end - -- Split by semicolon and parse each cookie local cookie_pairs = string.split(cookie_header, ";") for _, cookie_pair in ipairs(cookie_pairs) do local trimmed = string.trim(cookie_pair) @@ -31,14 +36,12 @@ local function parse_cookies(cookie_header) local name = string.trim(parts[1]) local value = string.trim(parts[2]) - -- URL decode the value local success, decoded = pcall(function() return string.url_decode(value) end) cookies[name] = success and decoded or value elseif #parts == 1 then - -- Cookie without value cookies[string.trim(parts[1])] = "" end end @@ -47,259 +50,157 @@ local function parse_cookies(cookie_header) return cookies end --- ====================================================================== --- ENHANCED ROUTER IMPLEMENTATION --- ====================================================================== - -local function split_path(path) - if string.is_empty(path) or path == "/" then - return {} +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 - - -- Remove leading/trailing slashes and split - local clean_path = string.trim(path, "/") - if string.is_empty(clean_path) then - return {} - end - - return string.split(clean_path, "/") end -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 - -- Wildcard captures everything remaining - 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 - -- Parameter segment - if i <= #path_segments then - local param_name = string.slice(route_seg, 2, -1) - params[param_name] = path_segments[i] - else - match = false - end - else - -- Static segment - if i > #path_segments or route_seg ~= path_segments[i] then - match = false - end - end - i = i + 1 - end - - -- Must consume all segments unless wildcard - 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, {} +local function generate_session_id() + return crypto.random_alphanumeric(32) end -function _http_handle_request(req_table, res_table) - local req = Request.new(req_table) - local res = Response.new(res_table) +-- ====================================================================== +-- SESSION OBJECT (LAZY LOADING) +-- ====================================================================== - -- Execute middleware chain first - local function run_middleware(index) - if index > #_G._http_middleware then - local route, params = match_route(req.method, req.path) - req.params = params +local Session = {} +Session.__index = Session - if not route then - res:status(404):send("Not Found") - return - end +function Session.new(cookies, response) + return setmetatable({ + _cookies = cookies, + _response = response, + _loaded = false, + _dirty = false, + _data = {}, + id = nil + }, Session) +end - -- Run route handler - route.handler(req, res) - return - end +function Session:_ensure_loaded() + if self._loaded then return end - local mw = _G._http_middleware[index] - if mw.path == nil or string.starts_with(req.path, mw.path) then - mw.handler(req, res, function() - run_middleware(index + 1) - end) + ensure_session_store() + + local config = _G._http_session_config + local session_id = self._cookies[config.cookie_name] + local session_data = nil + + if session_id then + -- Cookie exists, try to load session data + local json_str = kv.get(config.store_name, "session:" .. session_id) + if json_str then + session_data = json.decode(json_str) else - run_middleware(index + 1) + -- Cookie exists but no data - keep same ID, create empty data + session_data = {} end - end - - run_middleware(1) -end - --- Functions for route synchronization -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 - --- ====================================================================== --- SERVER CLASS --- ====================================================================== - -local Server = {} -Server.__index = Server - -function http.server() - if _G.__IS_WORKER then - return setmetatable({}, Server) - end - - local server = setmetatable({ - _server_created = false - }, Server) - - local success, err = moonshark.http_create_server() - if not success then - error("Failed to create HTTP server: " .. (err or "unknown error")) - end - server._server_created = true - - return server -end - -function Server: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]}) + -- Always refresh cookie age for existing sessions + self._response:cookie(config.cookie_name, session_id, config.cookie_options) else - error("Invalid arguments to use()") + -- No cookie - create new session + session_id = generate_session_id() + session_data = {} + self._response:cookie(config.cookie_name, session_id, config.cookie_options) end + self.id = session_id + self._data = session_data + self._loaded = true +end + +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 Server:_add_route(method, path, handler) - -- Ensure path starts with / - if not string.starts_with(path, "/") then - path = "/" .. path +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 - local segments = split_path(path) +function Session:save() + if not self._loaded then return false end - table.insert(_G._http_routes, { - method = method, - path = path, - segments = segments, - handler = handler + local config = _G._http_session_config + local session_json = json.encode(self._data) + return kv.set(config.store_name, "session:" .. self.id, session_json) +end + +function Session:destroy() + self:_ensure_loaded() + + local config = _G._http_session_config + kv.delete(config.store_name, "session:" .. self.id) + + -- Clear session cookie + self._response:cookie(config.cookie_name, "", { + expires = "Thu, 01 Jan 1970 00:00:00 GMT", + path = config.cookie_options.path or "/" }) - + + self.id = nil + self._data = {} return self end --- HTTP method helpers -function Server:get(path, handler) - return self:_add_route("GET", path, handler) -end - -function Server:post(path, handler) - return self:_add_route("POST", path, handler) -end - -function Server:put(path, handler) - return self:_add_route("PUT", path, handler) -end - -function Server:delete(path, handler) - return self:_add_route("DELETE", path, handler) -end - -function Server:patch(path, handler) - return self:_add_route("PATCH", path, handler) -end - -function Server:head(path, handler) - return self:_add_route("HEAD", path, handler) -end - -function Server:options(path, handler) - return self:_add_route("OPTIONS", path, handler) -end - -function Server:all(path, handler) - local methods = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} - for _, method in ipairs(methods) do - self:_add_route(method, path, handler) +function Session:regenerate() + self:_ensure_loaded() + + local config = _G._http_session_config + + -- Delete old session + if self.id then + kv.delete(config.store_name, "session:" .. self.id) end + + -- Generate new ID and set cookie + self.id = generate_session_id() + self._response:cookie(config.cookie_name, self.id, config.cookie_options) + return self end -function Server:listen(port, host, callback) - -- Workers should not listen - 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) - - -- Spawn workers first - local success, err = moonshark.http_spawn_workers() - if not success then - error("Failed to spawn workers: " .. (err or "unknown error")) - end - - -- Then start listening - success, err = moonshark.http_listen(addr) - if not success then - error("Failed to start server: " .. (err or "unknown error")) - end - - if callback then - callback() - end - - return self -end - -function Server:close() - if _G.__IS_WORKER then - return true - end - return moonshark.http_close_server() -end - -- ====================================================================== --- ENHANCED REQUEST OBJECT +-- REQUEST CLASS -- ====================================================================== -function Request.new(req_table) +local Request = {} +Request.__index = Request + +function Request.new(req_table, response) local req = setmetatable({ method = req_table.method, path = req_table.path, @@ -307,7 +208,8 @@ function Request.new(req_table) headers = req_table.headers or {}, params = {}, body = req_table.body or "", - cookies = {} + cookies = {}, + session = nil }, Request) local cookie_header = req.headers["Cookie"] or req.headers["cookie"] @@ -315,6 +217,9 @@ function Request.new(req_table) req.cookies = parse_cookies(cookie_header) end + -- Create lazy session object + req.session = Session.new(req.cookies, response) + return req end @@ -323,60 +228,11 @@ function Request:get(header_name) return self.headers[header_name] or self.headers[lower_name] end -function Request:header(header_name) - return self:get(header_name) -end - -function Request:param(name, default_value) - return self.params[name] or default_value -end - -function Request:query_param(name, default_value) - return self.query[name] or default_value -end - -function Request:cookie(name, default_value) - return self.cookies[name] or default_value -end - -function Request:has_cookie(name) - return self.cookies[name] ~= nil -end - -function Request:get_cookies() - -- Return a copy to prevent modification - local copy = {} - for k, v in pairs(self.cookies) do - copy[k] = v - end - return copy -end - -function Request:cookie_matches(name, pattern) - local cookie_value = self.cookies[name] - if not cookie_value then - return false - end - return string.match(pattern, cookie_value) -end - -function Request:get_cookies_by_names(names) - local result = {} - for _, name in ipairs(names) do - result[name] = self.cookies[name] - end - return result -end - -function Request:has_auth_cookies() - local auth_cookie_names = {"session", "token", "auth", "jwt", "access_token"} - for _, name in ipairs(auth_cookie_names) do - if self.cookies[name] then - return true - end - end - return false -end +function Request:header(header_name) return self:get(header_name) end +function Request:param(name, default_value) return self.params[name] or default_value end +function Request:query_param(name, default_value) return self.query[name] or default_value end +function Request:cookie(name, default_value) return self.cookies[name] or default_value end +function Request:has_cookie(name) return self.cookies[name] ~= nil end function Request:json() if string.is_empty(self.body) then @@ -399,50 +255,21 @@ function Request:is_json() return string.contains(content_type, "application/json") end -function Request:is_form() - local content_type = self:get("content-type") or "" - return string.contains(content_type, "application/x-www-form-urlencoded") -end - -function Request:is_multipart() - local content_type = self:get("content-type") or "" - return string.contains(content_type, "multipart/form-data") -end - -function Request:is_xml() - local content_type = self:get("content-type") or "" - return string.contains(content_type, "application/xml") or string.contains(content_type, "text/xml") -end - -function Request:accepts(mime_type) - local accept_header = self:get("accept") or "" - return string.contains(accept_header, mime_type) or string.contains(accept_header, "*/*") -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:is_secure() - local proto = self:get("x-forwarded-proto") - return proto == "https" or string.starts_with(self:get("host") or "", "https://") -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 -- ====================================================================== --- ENHANCED RESPONSE OBJECT +-- RESPONSE CLASS -- ====================================================================== +local Response = {} +Response.__index = Response + function Response.new(res_table) - local res = setmetatable({ + return setmetatable({ _table = res_table, _sent = false }, Response) - - return res end function Response:status(code) @@ -461,13 +288,8 @@ function Response:header(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 Response:set(name, value) return self:header(name, value) end +function Response:type(content_type) return self:header("Content-Type", content_type) end function Response:send(data) if self._sent then @@ -475,7 +297,15 @@ function Response:send(data) end if type(data) == "table" then - self:json(data) + self:type("application/json; charset=utf-8") + local success, json_str = pcall(function() + return json.encode(data) + end) + if success then + self._table.body = json_str + else + error("Failed to encode JSON response") + end elseif type(data) == "number" then self._table.status = data self._table.body = "" @@ -530,17 +360,6 @@ function Response:html(html) return self end -function Response:xml(xml) - if self._sent then - error("Response already sent") - end - - self:type("application/xml; charset=utf-8") - self._table.body = tostring(xml or "") - self._sent = true - return self -end - function Response:redirect(url, status) if self._sent then error("Response already sent") @@ -562,7 +381,6 @@ function Response:cookie(name, value, options) options = options or {} local cookie_value = tostring(value) - -- URL encode the cookie value if it contains special characters if string.match("[;,\\s]", cookie_value) then cookie_value = string.url_encode(cookie_value) end @@ -611,47 +429,240 @@ function Response:cookie(name, value, options) return self end -function Response:clear_cookie(name, options) - options = options or {} - options.expires = "Thu, 01 Jan 1970 00:00:00 GMT" - options.max_age = 0 - return self:cookie(name, "", options) +-- ====================================================================== +-- ROUTING +-- ====================================================================== + +local function split_path(path) + if string.is_empty(path) or path == "/" then + return {} + end + + local clean_path = string.trim(path, "/") + if string.is_empty(clean_path) then + return {} + end + + return string.split(clean_path, "/") end -function Response:attachment(filename) - if filename then - self:header("Content-Disposition", 'attachment; filename="' .. filename .. '"') +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 + local param_name = string.slice(route_seg, 2, -1) + params[param_name] = 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 + +function _http_handle_request(req_table, res_table) + local res = Response.new(res_table) + local req = Request.new(req_table, res) + + local function run_middleware(index) + if index > #_G._http_middleware then + local route, params = match_route(req.method, req.path) + req.params = params + + if not route then + res:send(404) + return + end + + route.handler(req, res) + return + end + + local mw = _G._http_middleware[index] + if mw.path == nil or string.starts_with(req.path, mw.path) then + mw.handler(req, res, function() + run_middleware(index + 1) + end) + else + run_middleware(index + 1) + end + end + + run_middleware(1) + + -- Auto-save dirty sessions + if req.session._loaded and req.session._dirty then + req.session:save() + 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 + +-- ====================================================================== +-- SERVER CLASS +-- ====================================================================== + +local Server = {} +Server.__index = Server + +function http.server() + if _G.__IS_WORKER then + return setmetatable({}, Server) + end + + local server = setmetatable({ + _server_created = false + }, Server) + + local success, err = moonshark.http_create_server() + if not success then + error("Failed to create HTTP server: " .. (err or "unknown error")) + end + server._server_created = true + + return server +end + +function Server:configure_sessions(options) + options = options or {} + + local config = _G._http_session_config + config.store_name = options.store_name or config.store_name + config.filename = options.filename or config.filename + config.cookie_name = options.cookie_name or config.cookie_name + + if options.cookie_options then + for k, v in pairs(options.cookie_options) do + config.cookie_options[k] = v + end + end + + ensure_session_store() + return self +end + +function Server: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 - self:header("Content-Disposition", "attachment") + error("Invalid arguments to use()") + end + + return self +end + +function Server:_add_route(method, path, handler) + if not string.starts_with(path, "/") then + path = "/" .. path + end + + local segments = split_path(path) + + table.insert(_G._http_routes, { + method = method, + path = path, + segments = segments, + handler = handler + }) + + return self +end + +-- HTTP method helpers +function Server:get(path, handler) return self:_add_route("GET", path, handler) end +function Server:post(path, handler) return self:_add_route("POST", path, handler) end +function Server:put(path, handler) return self:_add_route("PUT", path, handler) end +function Server:delete(path, handler) return self:_add_route("DELETE", path, handler) end +function Server:patch(path, handler) return self:_add_route("PATCH", path, handler) end +function Server:head(path, handler) return self:_add_route("HEAD", path, handler) end +function Server:options(path, handler) return self:_add_route("OPTIONS", path, handler) end + +function Server:all(path, handler) + local methods = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} + for _, method in ipairs(methods) do + self:_add_route(method, path, handler) end return self end -function Response:download(data, filename, content_type) - if filename then - self:attachment(filename) +function Server:listen(port, host, callback) + if _G.__IS_WORKER then + if callback then callback() end + return self end - if content_type then - self:type(content_type) - elseif filename then - -- Try to guess content type from extension - if string.ends_with(filename, ".pdf") then - self:type("application/pdf") - elseif string.ends_with(filename, ".zip") then - self:type("application/zip") - elseif string.ends_with(filename, ".json") then - self:type("application/json") - elseif string.ends_with(filename, ".csv") then - self:type("text/csv") - else - self:type("application/octet-stream") - end - else - self:type("application/octet-stream") + if type(host) == "function" then + callback = host + host = "localhost" end - return self:send(data) + host = host or "localhost" + local addr = host .. ":" .. tostring(port) + + local success, err = moonshark.http_spawn_workers() + if not success then + error("Failed to spawn workers: " .. (err or "unknown error")) + end + + success, err = moonshark.http_listen(addr) + if not success then + error("Failed to start server: " .. (err or "unknown error")) + end + + if callback then callback() end + return self +end + +function Server:close() + if _G.__IS_WORKER then + return true + end + return moonshark.http_close_server() end -- ====================================================================== @@ -674,7 +685,7 @@ function http.cors(options) end if string.iequals(req.method, "OPTIONS") then - res:status(204):send("") + res:send("") else next() end @@ -684,7 +695,6 @@ end function http.static(root_path, url_prefix) url_prefix = url_prefix or "/" - -- Ensure prefix starts with / if not string.starts_with(url_prefix, "/") then url_prefix = "/" .. url_prefix end @@ -696,7 +706,6 @@ function http.static(root_path, url_prefix) end end - -- Return no-op middleware return function(req, res, next) next() end @@ -712,7 +721,7 @@ function http.json_parser() if success then req.json_body = data else - res:status(400):json({error = "Invalid JSON"}) + res:json({error = "Invalid JSON"}) return end end @@ -721,7 +730,7 @@ function http.json_parser() end function http.logger(format) - format = format or "${method} ${path} ${status} ${response-time} ms" + format = format or "${method} ${path} ${status}" return function(req, res, next) local start_time = os.clock() @@ -744,62 +753,6 @@ function http.logger(format) end end -function http.compression() - return function(req, res, next) - local accept_encoding = req:get("accept-encoding") or "" - if string.contains(accept_encoding, "gzip") then - res:header("Content-Encoding", "gzip") - elseif string.contains(accept_encoding, "deflate") then - res:header("Content-Encoding", "deflate") - end - next() - end -end - -function http.security() - return function(req, res, next) - res:header("X-Content-Type-Options", "nosniff") - res:header("X-Frame-Options", "DENY") - res:header("X-XSS-Protection", "1; mode=block") - res:header("Strict-Transport-Security", "max-age=31536000; includeSubDomains") - res:header("Referrer-Policy", "strict-origin-when-cross-origin") - next() - end -end - -function http.rate_limit(options) - options = options or {} - local max_requests = options.max or 100 - local window_ms = options.window or 60000 -- 1 minute - local clients = {} - - return function(req, res, next) - local client_ip = req:ip() - local now = os.time() * 1000 - - if not clients[client_ip] then - clients[client_ip] = {count = 1, reset_time = now + window_ms} - else - local client = clients[client_ip] - if now > client.reset_time then - client.count = 1 - client.reset_time = now + window_ms - else - client.count = client.count + 1 - if client.count > max_requests then - res:status(429):json({ - error = "Too many requests", - retry_after = math.ceil((client.reset_time - now) / 1000) - }) - return - end - end - end - - next() - end -end - function http.create_server(callback) local app = http.server() if callback then @@ -808,4 +761,50 @@ function http.create_server(callback) return app end -return http +-- ====================================================================== +-- 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 then + if current_time - session_data._last_accessed > max_age then + kv.delete(config.store_name, key) + deleted = deleted + 1 + end + 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 \ No newline at end of file diff --git a/modules/sessions/sessions.lua b/modules/sessions/sessions.lua deleted file mode 100644 index 16abcc0..0000000 --- a/modules/sessions/sessions.lua +++ /dev/null @@ -1,211 +0,0 @@ -local kv = require("kv") -local crypto = require("crypto") - -local sessions = {} -local stores = {} -local default_store = nil - --- ====================================================================== --- CORE FUNCTIONS --- ====================================================================== - -function sessions.init(store_name, filename) - store_name = store_name or "sessions" - if not kv.open(store_name, filename) then return false end - stores[store_name] = true - if not default_store then default_store = store_name end - return true -end - -function sessions.create(session_id, data, store_name) - if type(session_id) ~= "string" then error("session ID must be a string", 2) end - if data ~= nil and type(data) ~= "table" then error("data must be a table", 2) end - - store_name = store_name or default_store - if not store_name then error("No session store initialized", 2) end - - local session_data = { - data = data or {}, - _created = os.time(), - _last_accessed = os.time() - } - - return kv.set(store_name, "session:" .. session_id, json.encode(session_data)) -end - -function sessions.get(session_id, store_name) - if type(session_id) ~= "string" then error("session ID must be a string", 2) end - - store_name = store_name or default_store - if not store_name then error("No session store initialized", 2) end - - local json_str = kv.get(store_name, "session:" .. session_id) - if not json_str then return nil end - - local session_data = json.decode(json_str) - if not session_data then return nil end - - -- Update last accessed - session_data._last_accessed = os.time() - kv.set(store_name, "session:" .. session_id, json.encode(session_data)) - - -- Return flattened data with metadata - local result = session_data.data or {} - result._created = session_data._created - result._last_accessed = session_data._last_accessed - return result -end - -function sessions.update(session_id, data, store_name) - if type(session_id) ~= "string" then error("session ID must be a string", 2) end - if type(data) ~= "table" then error("data must be a table", 2) end - - store_name = store_name or default_store - if not store_name then error("No session store initialized", 2) end - - local json_str = kv.get(store_name, "session:" .. session_id) - if not json_str then return false end - - local session_data = json.decode(json_str) - if not session_data then return false end - - session_data.data = data - session_data._last_accessed = os.time() - - return kv.set(store_name, "session:" .. session_id, json.encode(session_data)) -end - -function sessions.delete(session_id, store_name) - if type(session_id) ~= "string" then error("session ID must be a string", 2) end - - store_name = store_name or default_store - if not store_name then error("No session store initialized", 2) end - return kv.delete(store_name, "session:" .. session_id) -end - -function sessions.cleanup(max_age, store_name) - store_name = store_name or default_store - if not store_name then error("No session store initialized", 2) end - - local keys = kv.keys(store_name) - local current_time = os.time() - local deleted = 0 - - for _, key in ipairs(keys) do - if key:match("^session:") then - local json_str = kv.get(store_name, key) - if json_str then - local session_data = json.decode(json_str) - if session_data and session_data._last_accessed then - if current_time - session_data._last_accessed > max_age then - kv.delete(store_name, key) - deleted = deleted + 1 - end - end - end - end - end - - return deleted -end - -function sessions.close(store_name) - local success = kv.close(store_name) - stores[store_name] = nil - if default_store == store_name then - default_store = next(stores) - end - return success -end - --- ====================================================================== --- UTILITIES --- ====================================================================== - -function sessions.generate_id() - return crypto.random_alphanumeric(32) -end - -function sessions.exists(session_id, store_name) - store_name = store_name or default_store - if not store_name then error("No session store initialized", 2) end - return kv.has(store_name, "session:" .. session_id) -end - -function sessions.list(store_name) - store_name = store_name or default_store - if not store_name then error("No session store initialized", 2) end - - local keys = kv.keys(store_name) - local session_ids = {} - - for _, key in ipairs(keys) do - local session_id = key:match("^session:(.+)") - if session_id then - table.insert(session_ids, session_id) - end - end - - return session_ids -end - -function sessions.count(store_name) - return #sessions.list(store_name) -end - -function sessions.reset() - stores = {} - default_store = nil -end - --- ====================================================================== --- OOP INTERFACE --- ====================================================================== - -local SessionStore = {} -SessionStore.__index = SessionStore - -function sessions.create_store(store_name, filename) - if not sessions.init(store_name, filename) then - error("Failed to initialize store '" .. store_name .. "'", 2) - end - return setmetatable({name = store_name}, SessionStore) -end - -function SessionStore:create(session_id, data) - return sessions.create(session_id, data, self.name) -end - -function SessionStore:get(session_id) - return sessions.get(session_id, self.name) -end - -function SessionStore:update(session_id, data) - return sessions.update(session_id, data, self.name) -end - -function SessionStore:delete(session_id) - return sessions.delete(session_id, self.name) -end - -function SessionStore:cleanup(max_age) - return sessions.cleanup(max_age, self.name) -end - -function SessionStore:exists(session_id) - return sessions.exists(session_id, self.name) -end - -function SessionStore:list() - return sessions.list(self.name) -end - -function SessionStore:count() - return sessions.count(self.name) -end - -function SessionStore:close() - return sessions.close(self.name) -end - -return sessions