Initial commit, blog system works
This commit is contained in:
205
.lua/web.lua
Normal file
205
.lua/web.lua
Normal file
@@ -0,0 +1,205 @@
|
||||
-- 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 = [[
|
||||
<div class="error-page">
|
||||
<div class="error-code">404</div>
|
||||
<h1>Page Not Found</h1>
|
||||
<p>The page you’re looking for doesn’t exist.</p>
|
||||
<a href="/">← Go home</a>
|
||||
</div>
|
||||
]]
|
||||
|
||||
M.BODY_500 = [[
|
||||
<div class="error-page">
|
||||
<div class="error-code">500</div>
|
||||
<h1>Internal Error</h1>
|
||||
<p>Something went wrong on our end. Please try again later.</p>
|
||||
<a href="/">← Go home</a>
|
||||
</div>
|
||||
]]
|
||||
|
||||
--- 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
|
||||
Reference in New Issue
Block a user