Initial commit, blog system works

This commit is contained in:
2026-07-16 21:08:50 -05:00
commit 3841122960
22 changed files with 5628 additions and 0 deletions

205
.lua/web.lua Normal file
View 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&#8217;re looking for doesn&#8217;t exist.</p>
<a href="/">&#8592; 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="/">&#8592; 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