-- web.lua — request plumbing shared by all route modules. -- -- Owns: the route() wrapper (security headers + pcall guard), CSRF -- helpers, redirects, client IP + rate limiting, and small string -- helpers (dates, slugs, tags). local fm = require "fm" local render = require "render" local M = {} -- ── Error bodies ────────────────────────────────────────────────────── M.BODY_404 = [[
]] M.BODY_500 = [[ ]] --- Set 404 status and return the rendered not-found page. function M.not_found() SetStatus(404) return render.respond("Not Found", M.BODY_404) end -- ── Security headers / route wrapper ────────────────────────────────── local SEC_HEADERS = { ["Content-Security-Policy"] = "default-src 'self'; script-src 'self' 'unsafe-inline';" .. " style-src 'self' 'unsafe-inline'; img-src 'self' data:;" .. " connect-src 'self'; font-src 'self'; frame-ancestors 'none';" -- Radio streams live on other origins; audio is the only remote media. .. " media-src 'self' https: http:", ["X-Frame-Options"] = "DENY", ["X-Content-Type-Options"] = "nosniff", ["Referrer-Policy"] = "strict-origin-when-cross-origin", } -- Route wrapper: applies security headers and guards each handler with pcall. -- Passes string handlers (static asset patterns) straight through unchanged. -- -- Security headers are applied AFTER the handler returns, not before. -- Reason: SetStatus() resets the entire response buffer (see Redbean docs). -- Any SetHeader/SetCookie call made before a SetStatus call is silently -- discarded. By applying SEC_HEADERS after pcall(handler), we ensure they -- are always set after the last SetStatus call in the handler. function M.route(pattern, handler) if type(handler) == "string" then fm.setRoute(pattern, handler) return end fm.setRoute(pattern, function(r) local ok, result, extra = pcall(handler, r) for k, v in pairs(SEC_HEADERS) do SetHeader(k, v) end if not ok then Log(kLogWarn, "route error: " .. tostring(result)) SetStatus(500) -- Re-apply after SetStatus(500) which also resets the buffer. for k, v in pairs(SEC_HEADERS) do SetHeader(k, v) end return render.respond("Error", M.BODY_500) end -- Forward the optional second return value so Fullmoon uses it as the -- response headers table, suppressing detectType() auto-detection. -- Binary routes (e.g. /docs/:slug) use this to pass {ContentType = mime}. return result, extra end) end -- ── Randomness ──────────────────────────────────────────────────────── local function to_hex(s) return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end)) end --- Cryptographically random hex string of 2*nbytes chars. function M.random_hex(nbytes) local ok, bytes = pcall(GetRandomBytes, nbytes) if ok and bytes and #bytes == nbytes then return to_hex(bytes) end local ok2, bytes2 = pcall(unix.getrandom, nbytes) if ok2 and bytes2 and #bytes2 == nbytes then return to_hex(bytes2) end local f = io.open("/dev/urandom", "rb") if f then local b = f:read(nbytes); f:close() if b and #b == nbytes then return to_hex(b) end end error("no source of randomness available") end -- ── CSRF ────────────────────────────────────────────────────────────── --- Return (and mint if absent) the CSRF token for this browser. --- Sets a Set-Cookie header when a new token is generated. function M.csrf_token() local tok = GetCookie("csrf") if not tok or tok == "" then tok = M.random_hex(16) SetHeader("Set-Cookie", "csrf=" .. tok .. "; Path=/; HttpOnly; SameSite=Strict") end return tok end --- True only when the submitted csrf_token matches the cookie. function M.csrf_check(r) local tok = GetCookie("csrf") local sub = (r.params and r.params.csrf_token) or "" return tok ~= nil and tok ~= "" and sub == tok end -- ── Redirects ───────────────────────────────────────────────────────── -- Issue a redirect that actually reaches the browser. -- -- Redbean's SetHeader calls accumulate in the C layer but are only flushed -- when Write() is called. Fullmoon only calls Write() when the handler -- returns a non-empty string. Returning " " (one space) forces the flush. -- SetStatus must come first: it resets the response buffer. function M.redirect(url) SetStatus(302) SetHeader("Location", url) return " " end -- ── Client IP + rate limiting ───────────────────────────────────────── function M.client_ip() -- GetRemoteAddr already consults X-Forwarded-For when the socket IP is a -- trusted proxy, so no manual XFF parsing needed. local ip = GetRemoteAddr() if ip then return FormatIp(ip) end local fwd = GetHeader("X-Forwarded-For") if fwd and fwd ~= "" then local a = fwd:match("^%s*([^,%s]+)") if a then return a end end return "unknown" end --- Build an in-memory sliding-window rate limiter. --- Note: per-process state — redbean forks per request, so this is a --- best-effort limit, acceptable for login throttling. function M.make_limiter(max_count, window_secs) local log = {} -- [ip] = { unix_timestamp, ... } return function(ip) local now = os.time() local fresh = {} for _, t in ipairs(log[ip] or {}) do if now - t < window_secs then fresh[#fresh + 1] = t end end if #fresh >= max_count then log[ip] = fresh return false end fresh[#fresh + 1] = now log[ip] = fresh return true end end -- ── String helpers ──────────────────────────────────────────────────── --- Extract YYYY-MM-DD from an ISO timestamp. function M.fmt_date(iso) return iso and iso:sub(1, 10) or "" end --- Trim surrounding whitespace. function M.trim(s) return (s or ""):match("^%s*(.-)%s*$") end function M.slugify(s) return (s:lower():gsub("[^a-z0-9]+", "-"):gsub("^%-+", ""):gsub("%-+$", "")) end function M.valid_slug(s) return s ~= "" and (s:match("^[a-z0-9]$") or s:match("^[a-z0-9][a-z0-9%-]*[a-z0-9]$")) ~= nil end --- Split a comma-separated tag string into a trimmed list. function M.split_tags(s) if not s or s == "" then return {} end local list = {} for tag in s:gmatch("[^,]+") do tag = tag:match("^%s*(.-)%s*$") if tag ~= "" then list[#list+1] = tag end end return list end return M