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

20
.lua/app.lua Normal file
View File

@@ -0,0 +1,20 @@
-- app.lua — application wiring, loaded by .init.lua via require "app".
--
-- Route handlers live in routes_public.lua and routes_admin.lua; this
-- module opens the database, loads them, and registers the catch-all
-- 404 (which must be the last route).
local fm = require "fm"
local db = require "db"
local web = require "web"
-- Open blog.db and apply schema (idempotent).
db.init()
require "routes_public"
require "routes_admin"
-- Catch-all 404 (must be registered last).
web.route("/*", function(r)
return web.not_found()
end)

101
.lua/auth.lua Normal file
View File

@@ -0,0 +1,101 @@
-- auth.lua — password-based session authentication using argon2id.
--
-- Sessions are stored in the `sessions` SQLite table so they survive
-- Redbean's per-request fork model (each request gets a fresh process,
-- so in-memory tables cannot be shared between requests).
--
-- The admin password hash is stored in the `settings` table under the
-- key "admin_password_hash". Use setup_admin.lua to set it.
local db = require "db"
local M = {}
local SESSION_TTL = 7 * 24 * 3600 -- 7 days in seconds
-- ── Utilities ─────────────────────────────────────────────────────────
local function to_hex(s)
return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end))
end
-- Generate a cryptographically random 64-char hex token (32 bytes).
local function random_token()
local okg, rnd = pcall(GetRandomBytes, 32)
if okg and rnd and #rnd == 32 then return to_hex(rnd) end
local ok, bytes = pcall(unix.getrandom, 32)
if ok and bytes and #bytes == 32 then return to_hex(bytes) end
local f = io.open("/dev/urandom", "rb")
if f then
local b = f:read(32); f:close()
if b and #b == 32 then return to_hex(b) end
end
error("no source of randomness available")
end
-- Returns the raw token if the current request carries a live DB session.
local function current_token()
local tok = GetCookie("session")
if not tok or tok == "" then return nil end
local row = db.get_session(tok)
return row and tok or nil
end
-- ── Public API ────────────────────────────────────────────────────────
--- Returns true when the current request has a valid admin session.
function M.has_session()
return current_token() ~= nil
end
--- Enforce a valid admin session.
--- Returns nil when authorised.
--- When the session is missing, sets 302 headers and returns a non-empty
--- body (" ") so that Fullmoon calls Write(), which flushes SetHeader/
--- SetStatus to the socket. (Returning "" skips Write and the headers
--- are never sent.)
function M.require_session()
if current_token() then return nil end
SetStatus(302)
SetHeader("Location", "/login")
return " "
end
--- Verify a password and, if correct, create a DB-backed session.
--- Returns: token (string), nil on success.
--- nil, error_msg on failure.
function M.login(password)
if not password or password == "" then
return nil, "Password is required."
end
local hash = db.get_setting("admin_password_hash")
if not hash or hash == "" then
return nil, "Admin account not configured. Run setup_admin.lua first."
end
local ok, result = pcall(function()
return argon2.verify(hash, password)
end)
if not ok then
Log(kLogWarn, "argon2.verify error: " .. tostring(result))
return nil, "Internal error during authentication."
end
if not result then
return nil, "Incorrect password."
end
-- Clean up stale sessions before creating a new one.
pcall(db.prune_sessions)
local tok = random_token()
db.create_session(tok, os.time() + SESSION_TTL)
return tok, nil
end
--- Invalidate a session (logout).
function M.logout(token)
if token then pcall(db.delete_session, token) end
end
return M

77
.lua/config.lua Normal file
View File

@@ -0,0 +1,77 @@
-- config.lua — site configuration backed by the `settings` table.
--
-- Every value has a default here; anything set through the admin
-- Settings page (stored in SQLite) overrides it. Reads go to the DB
-- each time: redbean forks per request, so a module-level cache would
-- go stale after the admin saves new settings.
local db = require "db"
local M = {}
M.DEFAULTS = {
site_title = "btmx",
site_tagline = "",
site_author = "",
-- Absolute URL of the deployed site, no trailing slash.
-- Required for the Atom feed to produce valid absolute IDs/links.
base_url = "http://localhost:8080",
posts_per_page = "10",
-- Feature flags ("1" = on). The wiki is parked by default:
-- content is preserved in the DB but routes 404 and nav hides it.
feature_wiki = "0",
-- Markdown body of the /links page. Empty = page disabled and
-- hidden from the nav. Edited at /edit/links.
links_page = "",
-- Up to four custom nav links (label + URL pairs). Empty = unused.
nav_link1_label = "", nav_link1_url = "",
nav_link2_label = "", nav_link2_url = "",
nav_link3_label = "", nav_link3_url = "",
nav_link4_label = "", nav_link4_url = "",
-- Floating radio player (bottom-left). radio_url is an m3u playlist
-- fetched server-side and offered as a channel list.
radio_enabled = "0",
radio_url = "",
}
--- The custom nav links as a list of {label, url} pairs.
function M.nav_links()
local out = {}
for n = 1, 4 do
local label = M.get("nav_link" .. n .. "_label")
local url = M.get("nav_link" .. n .. "_url")
if label ~= "" and url ~= "" then
out[#out + 1] = { label = label, url = url }
end
end
return out
end
--- Get a setting as a string, falling back to the default.
function M.get(key)
local v = db.get_setting(key)
if v == nil or v == "" then return M.DEFAULTS[key] end
return v
end
--- Get a setting as a number (falls back to default, then 0).
function M.get_number(key)
return tonumber(M.get(key)) or tonumber(M.DEFAULTS[key]) or 0
end
--- True when a feature flag is enabled.
function M.enabled(key)
return M.get(key) == "1"
end
--- Persist a setting. Empty string clears the override (default applies).
function M.set(key, value)
db.set_setting(key, value or "")
end
--- base_url with no trailing slash (for feed/link building).
function M.base_url()
return (M.get("base_url"):gsub("/+$", ""))
end
return M

314
.lua/db.lua Normal file
View File

@@ -0,0 +1,314 @@
-- db.lua — database layer using Fullmoon's makeStorage (lsqlite3 wrapper)
-- The storage handle is opened once via M.init() and reused across all requests.
-- makeStorage handles WAL pragmas and fork-safety automatically.
--
-- NOTE: FTS5 full-text search is intentionally omitted.
-- The redbean.com binary shipped does not include SQLite's FTS5 extension.
-- When a custom APE fatbin with FTS5 is available, replace M.search() with
-- the FTS5 implementation and add the virtual table + triggers back to SCHEMA.
--
-- Tags are stored as a comma-separated string in the `tags` column.
-- Splitting, filtering, and counting are done in Lua at query time.
local fm = require "fm"
local M = {}
-- ── Schema ────────────────────────────────────────────────────────────────
-- Pragmas must come first; makeStorage extracts them for re-application
-- after a fork. All DDL uses IF NOT EXISTS so init() is idempotent.
local SCHEMA = [[
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS content (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK(kind IN ('blog','wiki','docs')),
slug TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '',
published INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE(kind, slug)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
]]
-- ── Module-level storage handle ───────────────────────────────────────────
local store -- set by M.init()
-- Convert NONE sentinel → nil (single-row lookups).
local function one(row)
if row == store.NONE then return nil end
return row
end
-- Convert NONE sentinel → {} (list queries).
local function many(rows)
if rows == store.NONE then return {} end
return rows
end
-- Columns returned by general content queries.
-- file_data is intentionally excluded: blobs are only fetched by get_doc().
local CONTENT_COLS =
"id, kind, slug, title, body, summary, tags, published, created_at, updated_at, mime_type"
-- ── Initialisation ────────────────────────────────────────────────────────
--- Open blog.db and apply the schema. Safe to call multiple times.
--- path defaults to "blog.db" in the current working directory.
function M.init(path)
store = fm.makeStorage(path or "blog.db", SCHEMA)
-- Migrations: ALTER TABLE ignores duplicate-column errors via pcall.
pcall(function()
store:execute("ALTER TABLE content ADD COLUMN tags TEXT NOT NULL DEFAULT ''")
end)
pcall(function()
store:execute("ALTER TABLE content ADD COLUMN file_data BLOB")
end)
pcall(function()
store:execute("ALTER TABLE content ADD COLUMN mime_type TEXT NOT NULL DEFAULT ''")
end)
end
-- ── Content CRUD ──────────────────────────────────────────────────────────
--- Fetch one published row by kind + slug. Returns nil if missing.
--- Pass include_drafts=true (admin preview) to also match drafts.
--- Does NOT include file_data; use get_doc() for binary blobs.
function M.get_content(kind, slug, include_drafts)
local sql = "SELECT " .. CONTENT_COLS .. " FROM content WHERE kind=? AND slug=?"
if not include_drafts then sql = sql .. " AND published=1" end
return one(store:fetchOne(sql, kind, slug))
end
--- All rows for a kind, newest first (drafts included).
function M.list_content(kind)
return many(store:fetchAll(
"SELECT " .. CONTENT_COLS .. " FROM content WHERE kind=? ORDER BY created_at DESC", kind))
end
--- Published-only rows for a kind, newest first.
function M.list_published(kind)
return many(store:fetchAll(
"SELECT " .. CONTENT_COLS .. " FROM content WHERE kind=? AND published=1 ORDER BY created_at DESC",
kind))
end
--- One page of published rows for a kind, newest first.
function M.list_published_page(kind, limit, offset)
return many(store:fetchAll(
"SELECT " .. CONTENT_COLS .. " FROM content WHERE kind=? AND published=1"
.. " ORDER BY created_at DESC LIMIT ? OFFSET ?",
kind, limit, offset or 0))
end
--- Count of published rows for a kind.
function M.count_published(kind)
local row = one(store:fetchOne(
"SELECT COUNT(*) AS n FROM content WHERE kind=? AND published=1", kind))
return row and row.n or 0
end
--- Fetch a single content row by its primary key. Returns nil if missing.
--- Does NOT include file_data; use get_doc() for binary blobs.
function M.get_by_id(id)
return one(store:fetchOne(
"SELECT " .. CONTENT_COLS .. " FROM content WHERE id=?", id))
end
--- Every row across all kinds (admin dashboard).
function M.list_all()
return many(store:fetchAll(
"SELECT " .. CONTENT_COLS .. " FROM content ORDER BY kind, created_at DESC"))
end
--- Most recently updated published rows across all kinds, newest first.
function M.list_recent(n)
return many(store:fetchAll(
"SELECT " .. CONTENT_COLS .. " FROM content WHERE published=1 ORDER BY updated_at DESC LIMIT ?", n or 5))
end
--- Fetch a published docs entry including its binary blob.
--- Pass include_drafts=true (admin) to also match drafts.
--- Only call this when you actually need to serve the file.
function M.get_doc(slug, include_drafts)
local sql = "SELECT id, kind, slug, title, mime_type, file_data, created_at, updated_at"
.. " FROM content WHERE kind='docs' AND slug=?"
if not include_drafts then sql = sql .. " AND published=1" end
return one(store:fetchOne(sql, slug))
end
--- Insert a new docs entry (binary file). Returns the new row id.
function M.insert_doc(slug, title, mime_type, file_data, published)
local id, err = store:execute([[
INSERT INTO content (kind, slug, title, body, mime_type, file_data, published)
VALUES ('docs', ?, ?, '', ?, ?, ?)
]], slug, title, mime_type or "", file_data or "", published == 1 and 1 or 0)
assert(id, err)
return id
end
--- Substring search across title and body using LIKE.
--- Pass kind to restrict to one section, or omit for a global search.
--- Pass tag to restrict to rows whose tags comma-list contains that tag exactly.
--- Returns rows with an `excerpt` column (first 200 chars of body).
--- TODO: replace with FTS5 snippet() once a build with FTS5 is available.
function M.search(q, kind, tag)
local has_q = q and not q:match("^%s*$")
if not has_q and not tag then return {} end
local wheres = {"published=1", "kind!='docs'"}
local vals = {}
if has_q then
local pattern = "%" .. q:gsub("[%%_]", "%%%1") .. "%"
wheres[#wheres+1] = "(title LIKE ? OR body LIKE ?)"
vals[#vals+1] = pattern
vals[#vals+1] = pattern
end
if kind then
wheres[#wheres+1] = "kind=?"
vals[#vals+1] = kind
end
if tag then
-- Exact comma-boundary match: `,tag,` inside `,tags_string,`
wheres[#wheres+1] = "(',' || tags || ',') LIKE ?"
vals[#vals+1] = "%," .. tag .. ",%"
end
local sql = "SELECT id, kind, slug, title, summary, tags, published, created_at,"
.. " substr(body, 1, 200) AS excerpt"
.. " FROM content WHERE " .. table.concat(wheres, " AND ")
.. " ORDER BY created_at DESC"
return many(store:fetchAll(sql, table.unpack(vals)))
end
--- Return a sorted list of {tag, count} pairs for published content.
--- Pass kind to restrict to one section, or omit for all kinds.
function M.list_tags(kind)
local rows
if kind then
rows = many(store:fetchAll(
"SELECT tags FROM content WHERE kind=? AND published=1 AND tags!=''", kind))
else
rows = many(store:fetchAll(
"SELECT tags FROM content WHERE published=1 AND tags!=''"))
end
local counts = {}
for _, row in ipairs(rows) do
for tag in row.tags:gmatch("[^,]+") do
tag = tag:match("^%s*(.-)%s*$")
if tag ~= "" then
counts[tag] = (counts[tag] or 0) + 1
end
end
end
local list = {}
for tag, count in pairs(counts) do
list[#list+1] = { tag=tag, count=count }
end
table.sort(list, function(a, b) return a.tag < b.tag end)
return list
end
--- Insert a new content row. Returns the new row id.
--- tags is an optional comma-separated string (e.g. "lua,web,programming").
function M.insert_content(kind, slug, title, body, summary, published, tags)
local id, err = store:execute([[
INSERT INTO content (kind, slug, title, body, summary, published, tags)
VALUES (?, ?, ?, ?, ?, ?, ?)
]], kind, slug, title, body, summary or "", published == 1 and 1 or 0, tags or "")
assert(id, err)
return id
end
--- Update fields on an existing row by id.
--- Accepted field keys: kind, slug, title, body, summary, published, tags, mime_type, file_data.
function M.update_content(id, fields)
local allowed = { kind=1, slug=1, title=1, body=1, summary=1, published=1, tags=1,
mime_type=1, file_data=1 }
local sets, vals = {}, {}
for k, v in pairs(fields) do
if allowed[k] then
sets[#sets + 1] = k .. "=?"
vals[#vals + 1] = v
end
end
if #sets == 0 then return end
sets[#sets + 1] = "updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')"
vals[#vals + 1] = id
local ok, err = store:execute(
"UPDATE content SET " .. table.concat(sets, ", ") .. " WHERE id=?",
table.unpack(vals))
assert(ok, err)
end
--- Delete a content row by id.
function M.delete_content(id)
local ok, err = store:execute("DELETE FROM content WHERE id=?", id)
assert(ok, err)
end
-- ── Settings ───────────────────────────────────────────────────────────
--- Fetch a setting value by key. Returns the string value, or nil if missing.
function M.get_setting(key)
local row = one(store:fetchOne("SELECT value FROM settings WHERE key=?", key))
return row and row.value or nil
end
--- Insert or update a setting by key.
function M.set_setting(key, value)
local ok, err = store:execute([[
INSERT INTO settings(key, value) VALUES(?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value
]], key, value)
assert(ok, err)
end
-- ── Sessions ───────────────────────────────────────────────────────────
--- Persist a new session token with its expiry (unix timestamp).
function M.create_session(token, expires_at)
local ok, err = store:execute(
"INSERT INTO sessions(token, expires_at) VALUES(?, ?)", token, expires_at)
assert(ok, err)
end
--- Fetch a session by token, checking that it has not expired.
--- Returns the row (with .token and .expires_at), or nil.
function M.get_session(token)
return one(store:fetchOne(
"SELECT token, expires_at FROM sessions WHERE token=? AND expires_at > ?",
token, os.time()))
end
--- Remove a session by token (logout).
function M.delete_session(token)
store:execute("DELETE FROM sessions WHERE token=?", token)
end
--- Remove all expired sessions (housekeeping; cheap to call on each login).
function M.prune_sessions()
store:execute("DELETE FROM sessions WHERE expires_at <= ?", os.time())
end
return M

70
.lua/feed.lua Normal file
View File

@@ -0,0 +1,70 @@
-- feed.lua — Atom feed (RFC 4287) for published blog posts.
local db = require "db"
local md = require "md"
local config = require "config"
local M = {}
local XML_ESC = {
["&"] = "&amp;", ["<"] = "&lt;", [">"] = "&gt;",
['"'] = "&quot;", ["'"] = "&apos;",
}
local function xesc(s)
return (tostring(s or ""):gsub("[&<>\"']", XML_ESC))
end
M.FEED_LIMIT = 20
--- Build the Atom feed document. Returns the XML string.
function M.atom()
local base = config.base_url()
local title = config.get("site_title")
local author = config.get("site_author")
local posts = db.list_published_page("blog", M.FEED_LIMIT, 0)
-- Feed-level updated: newest updated_at, or "now" for an empty feed.
local feed_updated = os.date("!%Y-%m-%dT%H:%M:%SZ")
if posts[1] then
feed_updated = posts[1].updated_at
for _, p in ipairs(posts) do
if p.updated_at > feed_updated then feed_updated = p.updated_at end
end
end
local out = {
'<?xml version="1.0" encoding="utf-8"?>',
'<feed xmlns="http://www.w3.org/2005/Atom">',
"<title>" .. xesc(title) .. "</title>",
'<link href="' .. xesc(base) .. '/"/>',
'<link rel="self" type="application/atom+xml" href="' .. xesc(base) .. '/atom.xml"/>',
"<id>" .. xesc(base) .. "/</id>",
"<updated>" .. xesc(feed_updated) .. "</updated>",
}
if author ~= "" then
out[#out + 1] = "<author><name>" .. xesc(author) .. "</name></author>"
end
local tagline = config.get("site_tagline")
if tagline ~= "" then
out[#out + 1] = "<subtitle>" .. xesc(tagline) .. "</subtitle>"
end
for _, p in ipairs(posts) do
local url = base .. "/blog/" .. EscapePath(p.slug)
out[#out + 1] = "<entry>"
.. "<title>" .. xesc(p.title) .. "</title>"
.. '<link href="' .. xesc(url) .. '"/>'
.. "<id>" .. xesc(url) .. "</id>"
.. "<published>" .. xesc(p.created_at) .. "</published>"
.. "<updated>" .. xesc(p.updated_at) .. "</updated>"
.. (p.summary ~= ""
and ("<summary>" .. xesc(p.summary) .. "</summary>") or "")
.. '<content type="html">' .. xesc(md.render(p.body)) .. "</content>"
.. "</entry>"
end
out[#out + 1] = "</feed>"
return table.concat(out, "\n")
end
return M

454
.lua/md.lua Normal file
View File

@@ -0,0 +1,454 @@
-- md.lua — pure-Lua Markdown → HTML renderer.
--
-- Block level: ATX headings (with id anchors), fenced code blocks,
-- blockquotes (nested, recursive), nested ordered/unordered lists with
-- multi-paragraph items, pipe tables, horizontal rules, paragraphs,
-- footnote definitions ([^id]: text).
-- Inline: code spans, backslash escapes, autolinks (<https://…>),
-- images, links (with optional "title"), footnote refs, bold, italic,
-- bold+italic, strikethrough.
--
-- Link and image URLs are sanitised: only http(s), mailto, and
-- relative/fragment URLs survive; anything else (javascript:, data:,
-- vbscript:, …) is dropped and the text rendered plain.
--
-- Not supported (by design): indented code blocks (use fences),
-- setext headings, raw inline HTML (it is escaped).
--
-- No mutable global state — safe to call concurrently from handlers.
local M = {}
-- ── HTML escaping ─────────────────────────────────────────────────────
local ESC = { ["&"] = "&amp;", ["<"] = "&lt;", [">"] = "&gt;", ['"'] = "&quot;" }
local function esc(s)
return (s:gsub('[&<>"]', ESC))
end
local function slugify(s)
return (s:lower()
:gsub("&%a+;", "") -- drop HTML entities
:gsub("<[^>]*>", "") -- drop tags (inline markup in headings)
:gsub("[^%w%s-]", "")
:gsub("%s+", "-")
:gsub("%-+", "-")
:gsub("^%-+", ""):gsub("%-+$", ""))
end
-- ── URL sanitiser ─────────────────────────────────────────────────────
-- Returns the URL if it is safe to place in href/src, else nil.
-- Input has already been HTML-escaped (quotes are &quot;), so it cannot
-- break out of the attribute; this check is about dangerous schemes.
local function safe_url(u)
u = u:match("^%s*(.-)%s*$")
if u == "" then return nil end
local l = u:lower()
if l:match("^https?://") or l:match("^mailto:") then return u end
-- Fragment, absolute path, query, or explicit relative path.
if u:match("^[#/?]") or u:match("^%.%.?/") then return u end
-- Bare relative path is fine as long as there is no scheme.
if not l:match("^[%w+.-]+:") then return u end
return nil
end
-- ── Inline markup ─────────────────────────────────────────────────────
-- ctx carries footnote state (fn_defs/fn_order/fn_index); may have no
-- footnotes. Code spans and finished HTML are stashed as \1<n>\1
-- placeholders so later passes cannot touch them.
local function inline(s, ctx)
local holes = {}
local function stash(html)
holes[#holes + 1] = html
return "\1" .. #holes .. "\1"
end
-- 1. Backslash escapes: the escaped character is stashed as a literal.
s = s:gsub("\\([\\`%*_{}%[%]%(%)#%+%-%.!~|<>])", function(c)
return stash(esc(c))
end)
-- 2. Code spans (content is escaped, never processed further).
s = s:gsub("`([^`]+)`", function(code)
return stash("<code>" .. esc(code) .. "</code>")
end)
-- 3. Autolinks <https://example.com> (before escaping eats the <>).
s = s:gsub("<(https?://[^%s<>]+)>", function(url)
return stash('<a href="' .. esc(url) .. '">' .. esc(url) .. "</a>")
end)
-- 4. Escape HTML in the remaining text.
s = esc(s)
-- 5. Footnote references [^id] (before links, which share the bracket).
if ctx and ctx.fn_defs then
s = s:gsub("%[%^([^%]%s]+)%]", function(id)
if not ctx.fn_defs[id] then return "[^" .. id .. "]" end
local n = ctx.fn_index[id]
if not n then
ctx.fn_order[#ctx.fn_order + 1] = id
n = #ctx.fn_order
ctx.fn_index[id] = n
end
return stash('<sup class="footnote-ref" id="fnref-' .. n .. '">'
.. '<a href="#fn-' .. n .. '">[' .. n .. ']</a></sup>')
end)
end
-- 6. Images (must precede links). ![alt](url "title")
s = s:gsub("!%[([^%]]*)%]%(([^%)]*)%)", function(alt, spec)
local url, title = spec:match('^(.-)%s+&quot;(.-)&quot;%s*$')
url = url or spec
local safe = safe_url(url)
if not safe then return alt end
local t = title and (' title="' .. title .. '"') or ""
return stash('<img src="' .. safe .. '" alt="' .. alt .. '"' .. t .. ' loading="lazy">')
end)
-- 7. Links. [text](url "title")
s = s:gsub("%[([^%]]+)%]%(([^%)]*)%)", function(text, spec)
local url, title = spec:match('^(.-)%s+&quot;(.-)&quot;%s*$')
url = url or spec
local safe = safe_url(url)
if not safe then return text end
local t = title and (' title="' .. title .. '"') or ""
-- Rendered content sits inside an hx-boost region; links to binary
-- endpoints must opt out or htmx would swap the file into the page.
local boost = (safe:match("^/docs/") or safe == "/atom.xml")
and ' hx-boost="false"' or ""
-- Text may still contain emphasis markers; process below by NOT
-- stashing the whole anchor — only the attribute part.
return stash('<a href="' .. safe .. '"' .. t .. boost .. ">") .. text .. stash("</a>")
end)
-- 8. Bold + italic, bold, italic, strikethrough.
s = s:gsub("%*%*%*(.-)%*%*%*", "<strong><em>%1</em></strong>")
s = s:gsub("%*%*(.-)%*%*", "<strong>%1</strong>")
s = s:gsub("%*([^%*]+)%*", "<em>%1</em>")
-- Underscore emphasis only at word boundaries (leaves snake_case alone).
-- Padded + double pass because adjacent matches share the boundary char.
s = " " .. s .. " "
for _ = 1, 2 do
s = s:gsub("([%s%p])___(.-)___([%s%p])", "%1<strong><em>%2</em></strong>%3")
s = s:gsub("([%s%p])__(.-)__([%s%p])", "%1<strong>%2</strong>%3")
s = s:gsub("([%s%p])_([^_]+)_([%s%p])", "%1<em>%2</em>%3")
end
s = s:sub(2, -2)
s = s:gsub("~~(.-)~~", "<del>%1</del>")
-- 9. Restore stashed fragments.
s = s:gsub("\1(%d+)\1", function(idx)
return holes[tonumber(idx)]
end)
return s
end
-- ── Table renderer ────────────────────────────────────────────────────
local function render_table(rows, ctx)
local out = { "<table>" }
local in_body = false
for i, row in ipairs(rows) do
-- Separator row (---|---) closes thead, opens tbody.
if row:match("^[%s|%-:]+$") then
if not in_body then
out[#out + 1] = "<tbody>"
in_body = true
end
else
local raw = {}
for cell in (row .. "|"):gmatch("([^|]*)|") do
raw[#raw + 1] = cell:match("^%s*(.-)%s*$")
end
if raw[1] == "" then table.remove(raw, 1) end
if raw[#raw] == "" then raw[#raw] = nil end
local tag = in_body and "td" or "th"
local tr = "<tr>"
for _, cell in ipairs(raw) do
tr = tr .. "<" .. tag .. ">" .. inline(cell, ctx) .. "</" .. tag .. ">"
end
tr = tr .. "</tr>"
if not in_body and i == 1 then
out[#out + 1] = "<thead>" .. tr .. "</thead>"
else
out[#out + 1] = tr
end
end
end
if in_body then out[#out + 1] = "</tbody>" end
out[#out + 1] = "</table>"
return table.concat(out, "\n")
end
-- ── List parsing ──────────────────────────────────────────────────────
-- If line opens a list item, returns: prefix_len, rest, ordered, number.
local function item_prefix(line)
local pre, rest = line:match("^(%s*[%-%*%+]%s+)(.*)$")
if pre then return #pre, rest, false, nil end
local pre2, num, rest2 = line:match("^(%s*(%d+)[%.%)]%s+)(.*)$")
if pre2 then return #pre2, rest2, true, tonumber(num) end
return nil
end
local function indent_of(line)
return #(line:match("^ *"))
end
-- True when the line begins a non-paragraph block (used to stop lazy
-- paragraph continuation inside list items).
local function is_block_start(line)
return line:match("^%s*#") ~= nil
or line:match("^%s*```") ~= nil
or line:match("^%s*>") ~= nil
or item_prefix(line) ~= nil
or line:match("^%s*%-%-%-+%s*$") ~= nil
or line:match("^%s*%*%*%*+%s*$") ~= nil
end
local render_blocks -- forward declaration (mutual recursion with lists)
-- Parse one list starting at lines[i]. Returns html, next_index.
local function parse_list(lines, i, ctx)
local n = #lines
local items = {}
local ordered, start_num
local loose = false
while i <= n do
local line = lines[i]
if line:match("^%s*$") then
-- Blank line: continuation, next item (loose), or end of list.
local j = i + 1
while j <= n and lines[j]:match("^%s*$") do j = j + 1 end
if j > n or #items == 0 then break end
local nxt = lines[j]
local ind = indent_of(nxt)
local plen = item_prefix(nxt)
if ind >= items[#items].content_indent then
-- Indented continuation of the current item after a blank.
items[#items].lines[#items[#items].lines + 1] = ""
loose = true
i = i + 1
elseif plen and ind <= 3 then
loose = true
i = j -- next iteration consumes the item start
else
break
end
else
local plen, rest, ord, num = item_prefix(line)
local ind = indent_of(line)
-- Indented content belonging to the current item (including nested
-- list items) must be checked BEFORE the new-item case.
if plen and ind <= 3
and not (#items > 0 and ind >= items[#items].content_indent) then
if #items == 0 then
ordered = ord
start_num = num
elseif ord ~= ordered then
break -- a different list type starts; outer loop re-parses it
end
items[#items + 1] = { lines = { rest }, content_indent = plen }
i = i + 1
elseif #items > 0 and ind >= items[#items].content_indent then
-- Indented continuation: strip the item's content indent, recurse later.
items[#items].lines[#items[#items].lines + 1] =
line:sub(items[#items].content_indent + 1)
i = i + 1
elseif #items > 0 then
-- Lazy paragraph continuation (unindented plain text).
local ilines = items[#items].lines
local last = ilines[#ilines]
if last and not last:match("^%s*$") and not is_block_start(line) then
ilines[#ilines + 1] = line:match("^%s*(.*)$")
i = i + 1
else
break
end
else
break
end
end
end
local out = {}
for _, item in ipairs(items) do
local html = render_blocks(item.lines, ctx)
if not loose then
-- Tight list: unwrap the leading paragraph of each item.
html = html:gsub("^<p>(.-)</p>", "%1", 1)
end
out[#out + 1] = "<li>" .. html .. "</li>"
end
local tag = ordered and "ol" or "ul"
local attrs = ""
if ordered and start_num and start_num ~= 1 then
attrs = ' start="' .. start_num .. '"'
end
local html = "<" .. tag .. attrs .. ">\n"
.. table.concat(out, "\n") .. "\n</" .. tag .. ">"
return html, i
end
-- ── Block parser ──────────────────────────────────────────────────────
render_blocks = function(lines, ctx)
local out = {}
local i = 1
while i <= #lines do
local line = lines[i]
-- Fenced code block ```[lang]
if line:match("^```") then
local lang = line:match("^```%s*(%w[%w+-]*)") or ""
local code = {}
i = i + 1
while i <= #lines and not lines[i]:match("^```%s*$") do
code[#code + 1] = lines[i]
i = i + 1
end
i = i + 1 -- consume closing ```
local cls = lang ~= "" and (' class="language-' .. lang .. '"') or ""
out[#out + 1] = "<pre><code" .. cls .. ">"
.. esc(table.concat(code, "\n"))
.. "</code></pre>"
-- ATX headings # through ###### (with anchor ids)
elseif line:match("^#+%s") then
local hashes, text = line:match("^(#+)%s+(.-)%s*#*%s*$")
local level = math.min(#hashes, 6)
local html_text = inline(text, ctx)
local id = slugify(text)
if id == "" then id = "section" end
if ctx.ids[id] then
ctx.ids[id] = ctx.ids[id] + 1
id = id .. "-" .. ctx.ids[id]
else
ctx.ids[id] = 1
end
out[#out + 1] = "<h" .. level .. ' id="' .. id .. '">'
.. html_text
.. ' <a class="heading-anchor" href="#' .. id
.. '" aria-label="Link to this section">#</a>'
.. "</h" .. level .. ">"
i = i + 1
-- Blockquote > ...
elseif line:match("^>") then
local bq = {}
while i <= #lines and lines[i]:match("^>") do
bq[#bq + 1] = lines[i]:match("^>%s?(.*)")
i = i + 1
end
out[#out + 1] = "<blockquote>" .. render_blocks(bq, ctx) .. "</blockquote>"
-- Horizontal rule --- / *** / ___
elseif line:match("^%-%-%-+%s*$")
or line:match("^%*%*%*+%s*$")
or line:match("^___%s*$") then
out[#out + 1] = "<hr>"
i = i + 1
-- List (ordered or unordered, possibly nested)
elseif item_prefix(line) and indent_of(line) <= 3 then
local html
html, i = parse_list(lines, i, ctx)
out[#out + 1] = html
-- Pipe table (current line has | AND next line is a separator)
elseif line:match("|")
and i + 1 <= #lines
and lines[i + 1]:match("^[%s|%-:]+$")
and lines[i + 1]:match("%-") then
local rows = {}
while i <= #lines and lines[i]:match("|") do
rows[#rows + 1] = lines[i]
i = i + 1
end
out[#out + 1] = render_table(rows, ctx)
-- Blank line
elseif line:match("^%s*$") then
i = i + 1
-- Paragraph — collect until blank line or block-level element
else
local para = {}
while i <= #lines do
local l = lines[i]
if l:match("^%s*$") or is_block_start(l) then break end
para[#para + 1] = l
i = i + 1
end
if #para > 0 then
out[#out + 1] = "<p>" .. inline(table.concat(para, " "), ctx) .. "</p>"
else
i = i + 1 -- defensive: never loop without progress
end
end
end
return table.concat(out, "\n")
end
-- ── Footnotes ─────────────────────────────────────────────────────────
-- Extract footnote definitions ([^id]: text, plus indented continuation
-- lines) from the line list. Returns the remaining lines.
local function extract_footnotes(lines, ctx)
local rest = {}
local i = 1
while i <= #lines do
local id, text = lines[i]:match("^%[%^([^%]%s]+)%]:%s*(.*)$")
if id then
local parts = { text }
i = i + 1
while i <= #lines and lines[i]:match("^ %S") do
parts[#parts + 1] = lines[i]:match("^ (.*)$")
i = i + 1
end
ctx.fn_defs[id] = table.concat(parts, " ")
else
rest[#rest + 1] = lines[i]
i = i + 1
end
end
return rest
end
local function render_footnotes(ctx)
if #ctx.fn_order == 0 then return "" end
local items = {}
for n, id in ipairs(ctx.fn_order) do
items[#items + 1] = '<li id="fn-' .. n .. '">'
.. inline(ctx.fn_defs[id], ctx)
.. ' <a href="#fnref-' .. n .. '" class="footnote-back"'
.. ' aria-label="Back to reference">&#8617;</a></li>'
end
return '\n<section class="footnotes"><hr><ol>\n'
.. table.concat(items, "\n")
.. "\n</ol></section>"
end
-- ── Public API ────────────────────────────────────────────────────────
--- Render a Markdown string to an HTML string.
--- Safe to call from concurrent request handlers (no global state mutated).
function M.render(source)
if not source or source == "" then return "" end
source = source:gsub("\r\n", "\n"):gsub("\t", " ")
local lines = {}
for line in (source .. "\n"):gmatch("([^\n]*)\n") do
lines[#lines + 1] = line
end
local ctx = { ids = {}, fn_defs = {}, fn_order = {}, fn_index = {} }
lines = extract_footnotes(lines, ctx)
return render_blocks(lines, ctx) .. render_footnotes(ctx)
end
return M

52
.lua/media.lua Normal file
View File

@@ -0,0 +1,52 @@
-- media.lua — MIME metadata and content sniffing for uploaded files.
--
-- Uploads are stored as `docs` rows in the content table (slug, title,
-- mime_type, file_data) and served at /docs/:slug. Images uploaded
-- from the post editor go through the same store.
local M = {}
M.MIME_EXT = {
["application/pdf"] = ".pdf",
["application/epub+zip"] = ".epub",
["text/plain"] = ".txt",
["application/zip"] = ".zip",
["image/png"] = ".png",
["image/jpeg"] = ".jpg",
["image/gif"] = ".gif",
["image/webp"] = ".webp",
}
M.MIME_LABELS = {
["application/pdf"] = "PDF Document",
["application/epub+zip"] = "EPUB e-book",
["text/plain"] = "Plain text",
["application/zip"] = "ZIP archive",
["image/png"] = "PNG image",
["image/jpeg"] = "JPEG image",
["image/gif"] = "GIF image",
["image/webp"] = "WebP image",
["application/octet-stream"] = "Other binary",
}
--- Detect the MIME type of a binary blob from its magic bytes.
--- Returns a mime string, or nil when unrecognised.
function M.sniff(data)
if not data or #data < 12 then return nil end
local head = data:sub(1, 12)
if head:sub(1, 8) == "\137PNG\r\n\26\10" then return "image/png" end
if head:sub(1, 3) == "\255\216\255" then return "image/jpeg" end
if head:sub(1, 6) == "GIF87a" or head:sub(1, 6) == "GIF89a" then return "image/gif" end
if head:sub(1, 4) == "RIFF" and head:sub(9, 12) == "WEBP" then return "image/webp" end
if head:sub(1, 5) == "%PDF-" then return "application/pdf" end
if head:sub(1, 4) == "PK\3\4" then return "application/zip" end
return nil
end
--- True for MIME types the editor's image-upload widget accepts.
function M.is_image(mime)
return mime == "image/png" or mime == "image/jpeg"
or mime == "image/gif" or mime == "image/webp"
end
return M

266
.lua/render.lua Normal file
View File

@@ -0,0 +1,266 @@
-- render.lua — HTML layout and partial helpers.
--
-- render.layout(title, body_html) → full HTML page
-- render.partial(fragment_html) → raw fragment (for HTMX sub-requests)
-- render.respond(title, body_html) → dispatches between the two
--
-- Brand, nav items, and footer come from config (settings table), so
-- they are built per request rather than baked in at load time.
local config = require "config"
local M = {}
-- Tiny inline script applied before CSS to avoid flash-of-wrong-theme.
local THEME_INIT = '<script>(function(){'
.. "var t=localStorage.getItem('theme');"
.. "if(t==='dark'||t==='light'||t==='mac8')document.documentElement.dataset.theme=t;"
.. '})();</script>'
-- Inline SVG icon helper (Lucide-style stroked icons).
local function icon(paths, size)
size = size or 16
return '<svg xmlns="http://www.w3.org/2000/svg"'
.. ' width="' .. size .. '" height="' .. size .. '" viewBox="0 0 24 24"'
.. ' fill="none" stroke="currentColor" stroke-width="2"'
.. ' stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
.. paths .. '</svg>'
end
local ICON_SUN = icon(
'<circle cx="12" cy="12" r="5"/>'
.. '<line x1="12" y1="1" x2="12" y2="3"/>'
.. '<line x1="12" y1="21" x2="12" y2="23"/>'
.. '<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>'
.. '<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>'
.. '<line x1="1" y1="12" x2="3" y2="12"/>'
.. '<line x1="21" y1="12" x2="23" y2="12"/>'
.. '<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>'
.. '<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>')
local ICON_MOON = icon('<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>')
-- Auto/system theme: monitor.
local ICON_MONITOR = icon(
'<rect x="2" y="3" width="20" height="14" rx="2"/>'
.. '<line x1="8" y1="21" x2="16" y2="21"/>'
.. '<line x1="12" y1="17" x2="12" y2="21"/>')
-- Mac-8 retro theme: classic compact Mac.
local ICON_RETRO = icon(
'<rect x="5" y="2" width="14" height="20" rx="2"/>'
.. '<rect x="8" y="5" width="8" height="7"/>'
.. '<line x1="8" y1="16" x2="12" y2="16"/>')
-- Theme picker: half-filled contrast circle on the trigger.
local ICON_CONTRAST = icon(
'<circle cx="12" cy="12" r="10"/>'
.. '<path d="M12 2v20a10 10 0 0 0 0-20z" fill="currentColor" stroke="none"/>')
local ICON_PLAY = icon('<polygon points="6 3 20 12 6 21 6 3" fill="currentColor" stroke="none"/>', 14)
local ICON_PAUSE = icon(
'<rect x="5" y="3" width="5" height="18" fill="currentColor" stroke="none"/>'
.. '<rect x="14" y="3" width="5" height="18" fill="currentColor" stroke="none"/>', 14)
-- One entry per selectable theme, in menu order.
local THEME_CHOICES = {
{ key = "auto", label = "System", svg = ICON_MONITOR },
{ key = "light", label = "Light", svg = ICON_SUN },
{ key = "dark", label = "Dark", svg = ICON_MOON },
{ key = "mac8", label = "Mac\194\1608", svg = ICON_RETRO },
}
local function theme_menu_html()
local buttons = {}
for _, t in ipairs(THEME_CHOICES) do
buttons[#buttons + 1] = '<button type="button" class="theme-option"'
.. ' data-theme-choice="' .. t.key .. '"'
.. ' title="' .. t.label .. '" aria-label="' .. t.label .. ' theme">'
.. t.svg .. '</button>'
end
return '<details class="theme-menu" id="theme-menu">'
.. '<summary class="theme-toggle" aria-label="Choose theme" title="Theme">'
.. ICON_CONTRAST .. '</summary>'
.. '<div class="theme-options">' .. table.concat(buttons) .. '</div>'
.. '</details>'
end
-- Atom feed icon (nav).
local ICON_FEED = '<svg xmlns="http://www.w3.org/2000/svg"'
.. ' width="14" height="14" viewBox="0 0 24 24" fill="none"'
.. ' stroke="currentColor" stroke-width="2"'
.. ' stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
.. '<path d="M4 11a9 9 0 0 1 9 9"/>'
.. '<path d="M4 4a16 16 0 0 1 16 16"/>'
.. '<circle cx="5" cy="19" r="1"/>'
.. '</svg>'
local function nav_html()
-- No Home item: the site brand already links to /.
local items = {}
if config.get("links_page") ~= "" then
items[#items + 1] = '<li><a href="/links" class="nav-link">Links</a></li>'
end
if config.enabled("feature_wiki") then
items[#items + 1] = '<li><a href="/wiki" class="nav-link">Wiki</a></li>'
end
for _, l in ipairs(config.nav_links()) do
items[#items + 1] = '<li><a href="' .. EscapeHtml(l.url) .. '" class="nav-link">'
.. EscapeHtml(l.label) .. '</a></li>'
end
-- show:window:top restores normal navigation feel: without it the
-- boosted swap leaves the viewport wherever it was (or scrolls the
-- target into view) instead of starting the new page at the top.
return '<header class="site-header">'
.. '<div class="header-inner">'
.. '<a href="/" class="site-brand" hx-boost="true"'
.. ' hx-target="#content" hx-swap="outerHTML show:window:top">'
.. EscapeHtml(config.get("site_title")) .. '</a>'
.. '<nav class="site-nav"'
.. ' hx-boost="true"'
.. ' hx-target="#content"'
.. ' hx-swap="outerHTML show:window:top">'
.. '<ul class="nav-list">'
.. table.concat(items)
.. '</ul>'
.. '</nav>'
.. '<div class="nav-end">'
-- The site's only search input. Carries the active query so that
-- landing on /search?q=… (or a full page load mid-search) shows the
-- term being searched.
.. '<form class="nav-search" action="/search" method="get">'
.. '<input id="nav-q" name="q" type="search" class="nav-search-input"'
.. ' value="' .. EscapeHtml(GetParam("q") or "") .. '"'
.. ' placeholder="Search\226\128\166" aria-label="Site search"'
.. ' hx-get="/search"'
.. ' hx-trigger="input changed delay:400ms"'
.. ' hx-target="#content"'
.. ' hx-push-url="true">'
.. '</form>'
.. '<a class="nav-feed" href="/atom.xml" aria-label="Atom feed" title="Atom feed">'
.. ICON_FEED
.. '</a>'
.. theme_menu_html()
.. '</div>'
.. '</div>'
.. '</header>'
end
-- Floating radio player (bottom-left, collapsed by default). The
-- channel list is fetched lazily from /radio/channels on first expand.
local function radio_html()
if not config.enabled("radio_enabled") or config.get("radio_url") == "" then
return ""
end
return '<div id="radio-player" class="radio-player">'
.. '<div class="radio-controls">'
.. '<button type="button" id="radio-play" class="radio-btn" disabled'
.. ' aria-label="Play or pause radio" title="Play/pause">'
.. '<span class="radio-icon-play">' .. ICON_PLAY .. '</span>'
.. '<span class="radio-icon-pause">' .. ICON_PAUSE .. '</span>'
.. '</button>'
.. '<input type="range" id="radio-vol" min="0" max="100" step="5" value="80"'
.. ' aria-label="Radio volume" title="Volume">'
.. '<button type="button" id="radio-expand" class="radio-btn"'
.. ' aria-label="Show channels" aria-expanded="false" title="Channels"'
.. ' hx-get="/radio/channels" hx-target="#radio-channels"'
.. ' hx-trigger="click once">&#9656;</button>'
.. '</div>'
.. '<div id="radio-channels" class="radio-channels" hidden>'
.. '<span class="radio-loading">Loading\226\128\166</span>'
.. '</div>'
.. '<audio id="radio-audio" preload="none"></audio>'
.. '</div>'
end
local function footer_html()
local author = config.get("site_author")
local who = author ~= "" and author or config.get("site_title")
return '<footer class="site-footer">'
.. '<p>&#169; ' .. os.date("!%Y") .. ' ' .. EscapeHtml(who)
.. ' &#183; <a href="/atom.xml">Atom feed</a></p>'
.. '</footer>'
end
-- Opening tag of the #content element. Shared by layout() and the
-- boosted-fragment path in respond(): boosted swaps replace #content via
-- outerHTML, so the replacement must carry the same hx- attributes or
-- boosting would stop working after the first navigation.
local MAIN_OPEN = '<main id="content" class="main-content"'
.. ' hx-boost="true" hx-target="#content"'
.. ' hx-swap="outerHTML show:window:top" hx-history-elt>'
local function page_title(title)
return EscapeHtml(title) .. " \226\128\148 " .. EscapeHtml(config.get("site_title"))
end
--- Return a complete HTML document wrapping body_html in the site shell.
--- Always returns a full page; callers check GetHeader("HX-Request") and
--- call render.partial() instead when only a fragment is needed.
function M.layout(title, body_html)
local site_title = config.get("site_title")
local safe_title = page_title(title)
return "<!DOCTYPE html>\n"
.. '<html lang="en" data-theme="light">\n'
.. "<head>\n"
.. '<meta charset="UTF-8">\n'
.. '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
.. "<title>" .. safe_title .. "</title>\n"
.. '<link rel="alternate" type="application/atom+xml" title="'
.. EscapeHtml(site_title) .. '" href="/atom.xml">\n'
.. THEME_INIT .. "\n"
.. '<link rel="stylesheet" href="/static/style.css">\n'
.. '<script src="/static/theme.js"></script>\n'
.. '<script src="/static/htmx.min.js"></script>\n'
.. '<script src="/static/highlight.min.js" defer></script>\n'
.. (radio_html() ~= "" and '<script src="/static/radio.js" defer></script>\n' or "")
.. "</head>\n"
.. "<body>\n"
.. nav_html() .. "\n"
-- hx-boost on <main> makes every internal link inside page content
-- (post cards, pagination, back-links, links in markdown) swap only
-- #content, so the radio player <audio> outside it keeps playing
-- across navigation. hx-history-elt scopes htmx's history snapshot/
-- restore to #content — without it the back button restores the whole
-- <body>, recreating the player. Elements inside that need different
-- swap behaviour (tag pills, editor preview) carry explicit hx-*
-- attributes, which override this inheritance.
.. MAIN_OPEN .. "\n"
.. body_html .. "\n"
.. "</main>\n"
.. footer_html() .. "\n"
.. radio_html() .. "\n"
.. "</body>\n"
.. "</html>"
end
--- Return the fragment as-is for HTMX partial responses.
function M.partial(fragment_html)
return fragment_html
end
--- Smart dispatch: return the right response for the request type.
---
--- Three cases:
--- 1. Direct browser navigation → full layout (no HX-Request header)
--- 2. Boosted navigation → a <title> plus the replacement
--- #content element only (HX-Request + HX-Boosted). htmx swaps it
--- via outerHTML and picks the <title> out of the response; nav,
--- footer, and the radio player are left untouched.
--- 3. True HTMX fragment request → bare partial (HX-Request, no
--- HX-Boosted; e.g. nav search input, editor preview, tag pills)
---
--- All page-returning handlers should call this instead of checking
--- GetHeader("HX-Request") themselves.
function M.respond(title, body_html)
if GetHeader("HX-Request") then
if GetHeader("HX-Boosted") then
return "<title>" .. page_title(title) .. "</title>\n"
.. MAIN_OPEN .. "\n" .. body_html .. "\n</main>"
end
return M.partial(body_html)
end
return M.layout(title, body_html)
end
return M

788
.lua/routes_admin.lua Normal file
View File

@@ -0,0 +1,788 @@
-- routes_admin.lua — login/logout, dashboard, editor, settings, uploads.
local fm = require "fm"
local db = require "db"
local render = require "render"
local md = require "md"
local auth = require "auth"
local web = require "web"
local config = require "config"
local media = require "media"
local route = web.route
local FORBIDDEN_BODY =
'<div class="error-page"><p>Invalid request token. Please go back and try again.</p></div>'
local function forbidden()
SetStatus(403)
return render.respond("Forbidden", FORBIDDEN_BODY)
end
-- ── Login / logout ────────────────────────────────────────────────────
local login_rate_ok = web.make_limiter(5, 900) -- 5 attempts per IP per 15 min
local LOGIN_ERRORS = {
token = "Invalid request token. Please try again.",
rate = "Too many login attempts. Please wait before trying again.",
cred = "Incorrect password.",
}
route(fm.GET "/login", function(r)
if auth.has_session() then
return web.redirect("/edit")
end
local tok = web.csrf_token()
local err_msg = LOGIN_ERRORS[r.params.e or ""] or ""
local err_html = err_msg ~= ""
and '<div class="login-error"><p>' .. err_msg .. '</p></div>'
or ""
local body =
'<div class="login-wrap">'
.. '<h1>Admin Login</h1>'
.. err_html
.. '<form method="post" action="/login">'
.. '<input type="hidden" name="csrf_token" value="' .. tok .. '">'
.. '<div class="form-group">'
.. '<label for="lp">Password</label>'
.. '<input type="password" id="lp" name="password" autocomplete="current-password">'
.. '</div>'
.. '<button type="submit" class="btn btn-primary">SIGN_IN</button>'
.. '</form>'
.. '</div>'
return render.respond("Login", body)
end)
route(fm.POST "/login", function(r)
if not web.csrf_check(r) then
return web.redirect("/login?e=token")
end
local ip = web.client_ip()
if not login_rate_ok(ip) then
return web.redirect("/login?e=rate")
end
local token, err = auth.login(r.params.password or "")
if not token then
Log(kLogWarn, "login failed for " .. ip .. ": " .. tostring(err))
return web.redirect("/login?e=cred")
end
-- SetStatus MUST come before SetCookie/SetHeader: SetStatus resets the
-- response buffer, discarding any headers set before it.
SetStatus(302)
SetHeader("Location", "/edit")
SetCookie("session", token, {
MaxAge = 7 * 24 * 3600,
Path = "/",
HttpOnly = true,
SameSite = "Strict",
})
return " "
end)
route(fm.POST "/logout", function(r)
local tok = GetCookie("session")
if tok then auth.logout(tok) end
SetStatus(302)
SetHeader("Location", "/")
SetCookie("session", "", {MaxAge = 0, Path = "/", HttpOnly = true, SameSite = "Strict"})
return " "
end)
-- ── Editor form (shared by GET /edit/new and GET /edit/:id) ───────────
-- item = nil for new, or a DB row for editing.
-- kind = "blog" | "wiki" | "docs" (used when item is nil).
-- err = optional error string to display above the form.
local function editor_page(item, kind, err)
local is_new = item == nil
kind = (item and item.kind) or kind or "blog"
local action = is_new and "/edit/new" or ("/edit/" .. item.id)
local title_val = (item and item.title) or ""
local slug_val = (item and item.slug) or ""
local body_val = (item and item.body) or ""
local tags_val = (item and item.tags) or ""
local summary_val = (item and item.summary) or ""
local pub_checked = (item and item.published == 1) and " checked" or ""
local kind_label = kind:sub(1, 1):upper() .. kind:sub(2)
local page_title = is_new and ("New " .. kind_label)
or ("Edit: " .. title_val)
local err_html = err
and '<div class="editor-error"><p>' .. EscapeHtml(err) .. '</p></div>'
or ""
local preview_html = body_val ~= ""
and md.render(body_val)
or '<p class="text-muted">Preview will appear here\226\128\166</p>'
-- View-live link (edit mode only, shown when slug is known).
local view_link = (not is_new and slug_val ~= "")
and ('<a href="/' .. EscapeHtml(kind) .. '/' .. EscapeHtml(slug_val) .. '"'
.. ' class="btn" target="_blank" rel="noopener">View live</a>')
or ""
local tok = web.csrf_token()
-- ── Content section: varies by kind ─────────────────────────────────
-- blog/wiki: summary + tags fields + two-column markdown editor with
-- live preview + image upload widget.
-- docs: MIME type selector + file upload input; no tags, no preview.
local content_section_html, media_section_html
media_section_html = ""
if kind == "docs" then
local mime_val = (item and item.mime_type ~= "" and item.mime_type) or "application/pdf"
local mime_opts = ""
for _, m in ipairs({
"application/pdf", "application/epub+zip", "text/plain", "application/zip",
"image/png", "image/jpeg", "image/gif", "image/webp",
"application/octet-stream",
}) do
local sel = (m == mime_val) and " selected" or ""
mime_opts = mime_opts
.. '<option value="' .. m .. '"' .. sel .. '>'
.. (media.MIME_LABELS[m] or m) .. "</option>"
end
local file_note = (item and item.mime_type ~= "")
and ("Current file type: " .. EscapeHtml(item.mime_type) .. ". Upload a new file to replace it.")
or "No file stored yet."
content_section_html =
'<div class="form-group">'
.. '<label for="ed-mime">Document type</label>'
.. '<select id="ed-mime" name="mime_type" class="select-input">'
.. mime_opts .. '</select>'
.. '</div>'
.. '<div class="form-group">'
.. '<label for="ed-file">File</label>'
.. '<p class="field-note">' .. file_note .. '</p>'
.. '<input type="file" id="ed-file" name="file">'
.. '</div>'
else
content_section_html =
-- Summary (used by feed cards and the Atom feed)
'<div class="form-group">'
.. '<label for="ed-summary">Summary</label>'
.. '<input type="text" id="ed-summary" name="summary"'
.. ' value="' .. EscapeHtml(summary_val) .. '"'
.. ' placeholder="One-sentence summary shown on the feed and in the Atom feed"'
.. ' autocomplete="off">'
.. '</div>'
-- Tags field (full-width)
.. '<div class="form-group">'
.. '<label for="ed-tags">Tags</label>'
.. '<input type="text" id="ed-tags" name="tags"'
.. ' value="' .. EscapeHtml(tags_val) .. '"'
.. ' placeholder="comma, separated, tags" autocomplete="off">'
.. '</div>'
-- Two-column editor / preview
.. '<div class="editor-layout">'
.. '<div class="editor-pane">'
.. '<div class="pane-label">Markdown</div>'
-- hx-swap must be explicit: the inherited main-level swap is
-- outerHTML, which would destroy #preview-pane on first preview.
.. '<textarea id="ed-body" name="body" class="editor-textarea"'
.. ' hx-post="/edit/preview"'
.. ' hx-trigger="keyup changed delay:400ms"'
.. ' hx-target="#preview-pane"'
.. ' hx-swap="innerHTML"'
.. ' hx-include="[name=csrf_token]"'
.. ' placeholder="Write your content in Markdown\226\128\166">'
.. EscapeHtml(body_val)
.. '</textarea>'
.. '</div>'
.. '<div class="preview-col">'
.. '<div class="pane-label">Preview</div>'
.. '<div id="preview-pane" class="preview-pane post-body">'
.. preview_html
.. '</div>'
.. '</div>'
.. '</div>' -- /editor-layout
-- Image upload widget: its own form (forms cannot nest), posts
-- multipart to /edit/upload; the response offers an insert button.
media_section_html =
'<div class="editor-media">'
.. '<div class="pane-label">Insert image</div>'
.. '<form hx-post="/edit/upload"'
.. ' hx-encoding="multipart/form-data"'
.. ' hx-target="#upload-result"'
.. ' hx-swap="innerHTML"'
.. ' class="upload-form">'
.. '<input type="hidden" name="csrf_token" value="' .. tok .. '">'
.. '<input type="file" name="file" accept="image/png,image/jpeg,image/gif,image/webp">'
.. '<input type="text" name="name" placeholder="optional-slug" class="slug-input upload-name">'
.. '<button type="submit" class="btn">Upload</button>'
.. '</form>'
.. '<div id="upload-result"></div>'
.. '</div>'
end
-- The docs form uploads files; keep it a plain full-page submit
-- rather than relying on boosted multipart handling.
local enctype = (kind == "docs")
and ' enctype="multipart/form-data" hx-boost="false"' or ""
local body =
'<div class="editor-wrap">'
-- Header
.. '<header class="post-header">'
.. '<a href="/edit" class="back-link">&#8592; Dashboard</a>'
.. '<h1>' .. EscapeHtml(page_title) .. '</h1>'
.. '</header>'
.. err_html
-- Form
.. '<form method="post" action="' .. action .. '"' .. enctype .. '>'
.. '<input type="hidden" name="kind" value="' .. EscapeHtml(kind) .. '">'
.. '<input type="hidden" name="csrf_token" value="' .. tok .. '">'
-- Meta row: title / slug / published
.. '<div class="editor-meta-row">'
.. '<div class="form-group">'
.. '<label for="ed-title">Title</label>'
.. '<input type="text" id="ed-title" name="title"'
.. ' value="' .. EscapeHtml(title_val) .. '"'
.. ' placeholder="Post title" autocomplete="off">'
.. '</div>'
.. '<div class="form-group">'
.. '<label for="ed-slug">Slug</label>'
.. '<input type="text" id="ed-slug" name="slug"'
.. ' value="' .. EscapeHtml(slug_val) .. '"'
.. ' placeholder="url-friendly-slug" autocomplete="off" class="slug-input">'
.. '</div>'
.. '<div class="form-group-inline">'
.. '<label class="checkbox-label">'
.. '<input type="checkbox" name="published" value="1"' .. pub_checked .. '>'
.. ' Published'
.. '</label>'
.. '</div>'
.. '</div>' -- /editor-meta-row
.. content_section_html
-- Actions
.. '<div class="editor-actions">'
.. view_link
.. '<button type="submit" class="btn btn-primary">'
.. (is_new and "Create" or "Save changes")
.. '</button>'
.. '</div>'
.. '</form>'
.. media_section_html
-- Slug auto-derive + snippet insertion (runs once on DOMContentLoaded).
.. '<script>(function(){'
.. 'var t=document.getElementById("ed-title");'
.. 'var s=document.getElementById("ed-slug");'
.. 'if(t&&s){'
.. 'var manual=s.value.length>0;'
.. 't.addEventListener("input",function(){'
.. 'if(manual)return;'
.. 's.value=this.value.toLowerCase()'
.. '.replace(/[^a-z0-9]+/g,"-")'
.. '.replace(/^-+|-+$/g,"");'
.. '});'
.. 's.addEventListener("input",function(){manual=true;});'
.. '}'
.. 'window.insertSnippet=function(text){'
.. 'var b=document.getElementById("ed-body");'
.. 'if(!b)return;'
.. 'var p=b.selectionStart||0;'
.. 'b.value=b.value.slice(0,p)+text+b.value.slice(b.selectionEnd||p);'
.. 'b.selectionStart=b.selectionEnd=p+text.length;'
.. 'b.focus();'
.. 'b.dispatchEvent(new Event("keyup"));'
.. '};'
.. '})();</script>'
.. '</div>' -- /editor-wrap
return render.respond(page_title, body)
end
-- ── GET /edit — admin dashboard ───────────────────────────────────────
route(fm.GET "/edit", function(r)
local denied = auth.require_session()
if denied then return denied end
local tok = web.csrf_token()
local all = db.list_all()
local rows = {}
for _, item in ipairs(all) do
local date = web.fmt_date(item.created_at)
local pub_badge = item.published == 1
and '<span class="status-badge status-published">Published</span>'
or '<span class="status-badge status-draft">Draft</span>'
local kind_badge =
'<span class="kind-badge kind-' .. item.kind .. '">' .. item.kind .. '</span>'
rows[#rows + 1] =
'<tr>'
.. '<td>' .. kind_badge .. '</td>'
.. '<td><a href="/edit/' .. item.id .. '">' .. EscapeHtml(item.title) .. '</a></td>'
.. '<td class="col-mono">' .. EscapeHtml(item.slug) .. '</td>'
.. '<td>' .. pub_badge .. '</td>'
.. '<td class="col-mono">' .. date .. '</td>'
.. '<td class="col-actions">'
.. '<a href="/edit/' .. item.id .. '" class="action-link">Edit</a>'
-- No explicit hx-post: the form is boosted via the main-level
-- hx-boost, so the redirect response swaps #content correctly;
-- hx-confirm still guards the boosted submit.
.. '<form method="post" action="/edit/' .. item.id .. '/delete"'
.. ' hx-confirm="Delete this post?"'
.. ' class="action-delete-form">'
.. '<input type="hidden" name="csrf_token" value="' .. tok .. '">'
.. '<button type="submit" class="action-link action-link-danger">Delete</button>'
.. '</form>'
.. '</td>'
.. '</tr>'
end
local table_html = #rows == 0
and '<div class="no-results">No content yet. Use the buttons above to create your first post.</div>'
or '<div class="admin-table-wrap">'
.. '<table class="admin-table">'
.. '<thead><tr>'
.. '<th>Kind</th><th>Title</th><th>Slug</th>'
.. '<th>Status</th><th>Created</th><th>Actions</th>'
.. '</tr></thead>'
.. '<tbody>' .. table.concat(rows, "\n") .. '</tbody>'
.. '</table>'
.. '</div>'
local new_buttons = '<a href="/edit/new?kind=blog" class="btn btn-primary">+ Blog post</a>'
if config.enabled("feature_wiki") then
new_buttons = new_buttons
.. '<a href="/edit/new?kind=wiki" class="btn btn-primary">+ Wiki page</a>'
end
new_buttons = new_buttons
.. '<a href="/edit/new?kind=docs" class="btn btn-primary">+ File</a>'
.. '<a href="/edit/links" class="btn">Links page</a>'
.. '<a href="/edit/settings" class="btn">Settings</a>'
local body =
'<div class="section-header admin-section-header">'
.. '<h1>Dashboard</h1>'
.. '<form method="post" action="/logout" class="logout-form">'
.. '<input type="hidden" name="csrf_token" value="' .. tok .. '">'
.. '<button type="submit" class="btn btn-logout">SIGN_OUT</button>'
.. '</form>'
.. '</div>'
.. '<div class="admin-new-row">' .. new_buttons .. '</div>'
.. table_html
return render.respond("Dashboard", body)
end)
-- ── Settings page (must be registered before /edit/:id) ──────────────
local SETTINGS_FIELDS = {
{ key = "site_title", label = "Site title" },
{ key = "site_tagline", label = "Tagline (shown above the feed)" },
{ key = "site_author", label = "Author name (footer, Atom feed)" },
{ key = "base_url", label = "Base URL (absolute, no trailing slash — required for the Atom feed)" },
{ key = "posts_per_page", label = "Posts per page on the feed" },
}
local function text_field(key, label, placeholder)
return '<div class="form-group">'
.. '<label for="set-' .. key .. '">' .. EscapeHtml(label) .. '</label>'
.. '<input type="text" id="set-' .. key .. '" name="' .. key .. '"'
.. ' value="' .. EscapeHtml(config.get(key)) .. '"'
.. ' placeholder="' .. EscapeHtml(placeholder or config.DEFAULTS[key] or "") .. '"'
.. ' autocomplete="off">'
.. '</div>'
end
local function checkbox_field(key, label)
local checked = config.enabled(key) and " checked" or ""
return '<div class="form-group-inline">'
.. '<label class="checkbox-label">'
.. '<input type="checkbox" name="' .. key .. '" value="1"' .. checked .. '>'
.. ' ' .. EscapeHtml(label)
.. '</label>'
.. '</div>'
end
local function settings_page(msg)
local tok = web.csrf_token()
local site_fields = {}
for _, f in ipairs(SETTINGS_FIELDS) do
site_fields[#site_fields + 1] = text_field(f.key, f.label)
end
local nav_fields = {}
for n = 1, 4 do
nav_fields[#nav_fields + 1] =
'<div class="nav-link-row">'
.. text_field("nav_link" .. n .. "_label", "Link " .. n .. " label", "Label")
.. text_field("nav_link" .. n .. "_url", "Link " .. n .. " URL", "https://\226\128\166 or /path")
.. '</div>'
end
local msg_html = msg
and '<div class="settings-saved"><p>' .. EscapeHtml(msg) .. '</p></div>'
or ""
local body =
'<div class="settings-wrap">'
.. '<header class="post-header">'
.. '<a href="/edit" class="back-link">&#8592; Dashboard</a>'
.. '<h1>Settings</h1>'
.. '</header>'
.. msg_html
.. '<form method="post" action="/edit/settings">'
.. '<input type="hidden" name="csrf_token" value="' .. tok .. '">'
.. '<h2 class="settings-section-label">Site</h2>'
.. table.concat(site_fields)
.. checkbox_field("feature_wiki", "Enable the wiki section")
.. '<h2 class="settings-section-label">Navbar links</h2>'
.. '<p class="field-note">Up to four extra nav items. Both label and URL must be set for a link to appear.</p>'
.. table.concat(nav_fields)
.. '<h2 class="settings-section-label">Radio player</h2>'
.. checkbox_field("radio_enabled", "Show the floating radio player")
.. text_field("radio_url", "Playlist URL (m3u)", "https://radio.example.com/stations.m3u")
.. '<div class="editor-actions">'
.. '<button type="submit" class="btn btn-primary">Save settings</button>'
.. '</div>'
.. '</form>'
.. '</div>'
return render.respond("Settings", body)
end
route(fm.GET "/edit/settings", function(r)
local denied = auth.require_session()
if denied then return denied end
return settings_page(nil)
end)
route(fm.POST "/edit/settings", function(r)
local denied = auth.require_session()
if denied then return denied end
if not web.csrf_check(r) then return forbidden() end
for _, f in ipairs(SETTINGS_FIELDS) do
local v = web.trim(r.params[f.key] or "")
if f.key == "posts_per_page" and v ~= "" then
local n = tonumber(v)
v = (n and n >= 1 and n <= 100) and tostring(math.floor(n)) or ""
end
if f.key == "base_url" then
v = v:gsub("/+$", "")
end
config.set(f.key, v)
end
-- Nav links: label free-form; URL must be absolute http(s) or site-relative.
for n = 1, 4 do
config.set("nav_link" .. n .. "_label", web.trim(r.params["nav_link" .. n .. "_label"]))
local u = web.trim(r.params["nav_link" .. n .. "_url"])
if u ~= "" and not (u:match("^https?://") or u:match("^/")) then u = "" end
config.set("nav_link" .. n .. "_url", u)
end
local radio_url = web.trim(r.params.radio_url)
if radio_url ~= "" and not radio_url:match("^https?://") then radio_url = "" end
config.set("radio_url", radio_url)
config.set("feature_wiki", r.params.feature_wiki == "1" and "1" or "0")
config.set("radio_enabled", r.params.radio_enabled == "1" and "1" or "0")
return settings_page("Settings saved.")
end)
-- ── /edit/links — editor for the single free-form Links page ─────────
-- (Registered before /edit/:id so "links" is not parsed as an id.)
local function links_editor_page(msg)
local tok = web.csrf_token()
local body_val = config.get("links_page")
local preview_html = body_val ~= ""
and md.render(body_val)
or '<p class="text-muted">Preview will appear here\226\128\166</p>'
local msg_html = msg
and '<div class="settings-saved"><p>' .. EscapeHtml(msg) .. '</p></div>'
or ""
local body =
'<div class="editor-wrap">'
.. '<header class="post-header">'
.. '<a href="/edit" class="back-link">&#8592; Dashboard</a>'
.. '<h1>Links page</h1>'
.. '</header>'
.. '<p class="field-note">A single free-form Markdown page served at /links.'
.. ' Leave it empty to remove the page and its nav item.</p>'
.. msg_html
.. '<form method="post" action="/edit/links">'
.. '<input type="hidden" name="csrf_token" value="' .. tok .. '">'
.. '<div class="editor-layout">'
.. '<div class="editor-pane">'
.. '<div class="pane-label">Markdown</div>'
.. '<textarea id="ed-body" name="body" class="editor-textarea"'
.. ' hx-post="/edit/preview"'
.. ' hx-trigger="keyup changed delay:400ms"'
.. ' hx-target="#preview-pane"'
.. ' hx-swap="innerHTML"'
.. ' hx-include="[name=csrf_token]"'
.. ' placeholder="## Friends\10- [somebody](https://example.com)\226\128\166">'
.. EscapeHtml(body_val)
.. '</textarea>'
.. '</div>'
.. '<div class="preview-col">'
.. '<div class="pane-label">Preview</div>'
.. '<div id="preview-pane" class="preview-pane post-body">'
.. preview_html
.. '</div>'
.. '</div>'
.. '</div>'
.. '<div class="editor-actions">'
.. (body_val ~= ""
and '<a href="/links" class="btn" target="_blank" rel="noopener">View live</a>'
or "")
.. '<button type="submit" class="btn btn-primary">Save</button>'
.. '</div>'
.. '</form>'
.. '</div>'
return render.respond("Links page", body)
end
route(fm.GET "/edit/links", function(r)
local denied = auth.require_session()
if denied then return denied end
return links_editor_page(nil)
end)
route(fm.POST "/edit/links", function(r)
local denied = auth.require_session()
if denied then return denied end
if not web.csrf_check(r) then return forbidden() end
config.set("links_page", r.params.body or "")
return links_editor_page("Saved.")
end)
-- ── GET /edit/new?kind=:kind ──────────────────────────────────────────
route(fm.GET "/edit/new", function(r)
local denied = auth.require_session()
if denied then return denied end
local kind = r.params.kind
if kind ~= "blog" and kind ~= "wiki" and kind ~= "docs" then kind = "blog" end
return editor_page(nil, kind, nil)
end)
-- ── POST /edit/new ────────────────────────────────────────────────────
route(fm.POST "/edit/new", function(r)
local denied = auth.require_session()
if denied then return denied end
if not web.csrf_check(r) then return forbidden() end
local kind = r.params.kind or "blog"
local title = web.trim(r.params.title)
local slug = web.trim(r.params.slug)
local body = r.params.body or ""
local tags = web.trim(r.params.tags)
local summary = web.trim(r.params.summary)
local pub = (r.params.published == "1") and 1 or 0
if kind ~= "blog" and kind ~= "wiki" and kind ~= "docs" then kind = "blog" end
if slug == "" then slug = web.slugify(title) end
if title == "" then
return editor_page(nil, kind, "Title is required.")
end
if not web.valid_slug(slug) then
return editor_page(nil, kind,
"Slug must contain only lowercase letters, digits, and hyphens (no leading or trailing hyphens).")
end
local ok, err
if kind == "docs" then
local file_data = r.params.file or ""
local mime_type = r.params.mime_type or "application/pdf"
if file_data == "" then
return editor_page(nil, kind, "A file is required.")
end
ok, err = pcall(db.insert_doc, slug, title, mime_type, file_data, pub)
else
if body:match("^%s*$") then
return editor_page(nil, kind, "Body cannot be empty.")
end
ok, err = pcall(db.insert_content, kind, slug, title, body, summary, pub, tags)
end
if not ok then
local msg = tostring(err):find("UNIQUE")
and "A post with that slug already exists for this section."
or "Database error: " .. tostring(err)
return editor_page(nil, kind, msg)
end
return web.redirect("/" .. kind .. "/" .. slug)
end)
-- ── POST /edit/preview ────────────────────────────────────────────────
route(fm.POST "/edit/preview", function(r)
local denied = auth.require_session()
if denied then return denied end
if not web.csrf_check(r) then
return render.partial('<p class="text-muted">Invalid request token.</p>')
end
local body = r.params.body or ""
if body:match("^%s*$") then
return render.partial('<p class="text-muted">Preview will appear here\226\128\166</p>')
end
return render.partial(md.render(body))
end)
-- ── POST /edit/upload — image upload from the editor ─────────────────
route(fm.POST "/edit/upload", function(r)
local denied = auth.require_session()
if denied then return denied end
if not web.csrf_check(r) then
return render.partial('<p class="upload-error">Invalid request token.</p>')
end
local data = r.params.file or ""
if data == "" then
return render.partial('<p class="upload-error">No file selected.</p>')
end
local mime = media.sniff(data)
if not mime or not media.is_image(mime) then
return render.partial(
'<p class="upload-error">Unsupported file type. Use PNG, JPEG, GIF, or WebP.</p>')
end
local slug = web.slugify(web.trim(r.params.name))
if slug == "" then
slug = "img-" .. os.date("!%Y%m%d-%H%M%S")
end
-- Retry with a random suffix on slug collision.
local ok, err = pcall(db.insert_doc, slug, slug, mime, data, 1)
if not ok and tostring(err):find("UNIQUE") then
slug = slug .. "-" .. web.random_hex(3)
ok, err = pcall(db.insert_doc, slug, slug, mime, data, 1)
end
if not ok then
Log(kLogWarn, "upload failed: " .. tostring(err))
return render.partial('<p class="upload-error">Upload failed. Check the server log.</p>')
end
local snippet = "![](/docs/" .. slug .. ")"
return render.partial(
'<div class="upload-ok">'
.. '<code>' .. EscapeHtml(snippet) .. '</code>'
.. '<button type="button" class="btn"'
.. " onclick=\"insertSnippet(this.dataset.snippet)\""
.. ' data-snippet="' .. EscapeHtml(snippet) .. '">Insert into post</button>'
.. '</div>')
end)
-- ── GET /edit/:id ─────────────────────────────────────────────────────
route(fm.GET "/edit/:id", function(r)
local denied = auth.require_session()
if denied then return denied end
local id = tonumber(r.params.id)
if not id then return web.not_found() end
local item = db.get_by_id(id)
if not item then return web.not_found() end
return editor_page(item, item.kind, nil)
end)
-- ── POST /edit/:id ────────────────────────────────────────────────────
route(fm.POST "/edit/:id", function(r)
local denied = auth.require_session()
if denied then return denied end
if not web.csrf_check(r) then return forbidden() end
local id = tonumber(r.params.id)
if not id then return web.not_found() end
local item = db.get_by_id(id)
if not item then return web.not_found() end
local title = web.trim(r.params.title)
local slug = web.trim(r.params.slug)
local pub = (r.params.published == "1") and 1 or 0
if title == "" then
return editor_page(item, item.kind, "Title is required.")
end
if not web.valid_slug(slug) then
return editor_page(item, item.kind,
"Slug must contain only lowercase letters, digits, and hyphens.")
end
local ok, err
if item.kind == "docs" then
local file_data = r.params.file or ""
local mime_type = r.params.mime_type or ""
local fields = { title = title, slug = slug, published = pub }
-- Only update file if a new one was uploaded; keep existing blob otherwise.
if file_data ~= "" then fields.file_data = file_data end
if mime_type ~= "" then fields.mime_type = mime_type end
ok, err = pcall(db.update_content, id, fields)
else
local body = r.params.body or ""
local tags = web.trim(r.params.tags)
local summary = web.trim(r.params.summary)
if body:match("^%s*$") then
return editor_page(item, item.kind, "Body cannot be empty.")
end
ok, err = pcall(db.update_content, id,
{ title = title, slug = slug, body = body, tags = tags,
summary = summary, published = pub })
end
if not ok then
local msg = tostring(err):find("UNIQUE")
and "A post with that slug already exists for this section."
or "Database error: " .. tostring(err)
return editor_page(item, item.kind, msg)
end
return web.redirect("/" .. item.kind .. "/" .. slug)
end)
-- ── POST /edit/:id/delete ─────────────────────────────────────────────
route(fm.POST "/edit/:id/delete", function(r)
local denied = auth.require_session()
if denied then return denied end
if not web.csrf_check(r) then return forbidden() end
local id = tonumber(r.params.id)
if id then
local ok, err = pcall(db.delete_content, id)
if not ok then Log(kLogWarn, "delete failed: " .. tostring(err)) end
end
return web.redirect("/edit")
end)

306
.lua/routes_public.lua Normal file
View File

@@ -0,0 +1,306 @@
-- routes_public.lua — visitor-facing routes.
--
-- The front page (/) is the article feed: published blog posts, newest
-- first, paginated. /blog/:slug remains the permalink format. Wiki
-- routes exist only when the feature_wiki flag is on.
--
-- Registration order matters: */search before */:slug. The catch-all
-- 404 is registered by app.lua after all modules.
local fm = require "fm"
local db = require "db"
local render = require "render"
local md = require "md"
local auth = require "auth"
local web = require "web"
local ui = require "ui"
local config = require "config"
local media = require "media"
local feed = require "feed"
local route = web.route
-- ── Shared detail-page body ───────────────────────────────────────────
local function detail_body(post, kind, back_href, back_label)
local date = web.fmt_date(post.created_at)
local updated = web.fmt_date(post.updated_at)
local date_str = date
if updated ~= date then
date_str = date_str .. ' <span style="opacity:.6">(updated ' .. updated .. ')</span>'
end
if post.published == 0 then
date_str = date_str .. ' <span class="status-badge status-draft">Draft</span>'
end
local tags_list = web.split_tags(post.tags or "")
local tags_html = ""
if #tags_list > 0 then
-- Plain links on detail pages — no #post-list in the DOM to swap into.
tags_html = '<div class="post-meta-tags">'
.. ui.tag_pills(tags_list, kind, false) .. '</div>'
end
-- Edit affordance for a logged-in admin.
local edit_bar = ""
if auth.has_session() then
edit_bar = '<div class="admin-edit-bar">'
.. '<a href="/edit/' .. post.id .. '" class="admin-edit-link">Edit this page</a>'
.. '</div>'
end
return '<article class="post-detail">'
.. '<header class="post-header">'
.. '<a href="' .. back_href .. '" class="back-link">&#8592; ' .. back_label .. '</a>'
.. '<h1>' .. EscapeHtml(post.title) .. '</h1>'
.. '<div class="post-meta"><time datetime="' .. date .. '">' .. date_str .. '</time></div>'
.. tags_html
.. '</header>'
.. '<div class="post-body">' .. md.render(post.body) .. '</div>'
.. edit_bar
.. '</article>'
end
-- ── Front page: the article feed ──────────────────────────────────────
route(fm.GET "/", function(r)
local tag = r.params.tag
local tagline = config.get("site_tagline")
local header = tagline ~= ""
and ('<div class="section-header"><p>' .. EscapeHtml(tagline) .. '</p></div>')
or ""
local list_html, pagination_html
if tag then
-- Tag filter: unpaginated result list, like a search.
list_html = ui.post_list(db.search(nil, "blog", tag), "blog")
pagination_html = ""
else
local per = math.max(1, config.get_number("posts_per_page"))
local page = math.max(1, math.floor(tonumber(r.params.page) or 1))
local total = db.count_published("blog")
local pages = math.max(1, math.ceil(total / per))
if page > pages then page = pages end
local posts = db.list_published_page("blog", per, (page - 1) * per)
list_html = ui.post_list(posts, "blog")
pagination_html = ui.pagination("/", page, pages)
end
local body = header
.. ui.tag_cloud(db.list_tags("blog"), "blog", tag)
.. list_html
.. pagination_html
return render.respond(config.get("site_title"), body)
end)
-- Old section index → the feed now lives at /.
route(fm.GET "/blog", function(r)
local tag = r.params.tag
return web.redirect(tag and ("/?tag=" .. EscapePath(tag)) or "/")
end)
-- Tag-filter fragment for the feed's tag pills (HTMX swaps #post-list).
-- Must be registered before /blog/:slug.
route(fm.GET "/blog/search", function(r)
local tag = r.params.tag
if tag then
return render.partial(
ui.search_results(db.search(nil, "blog", tag), "blog", "tag: " .. tag))
end
return render.partial(ui.post_list(db.list_published("blog"), "blog"))
end)
route(fm.GET "/blog/:slug", function(r)
local post = db.get_content("blog", r.params.slug, auth.has_session())
if not post then return web.not_found() end
return render.respond(post.title, detail_body(post, "blog", "/", "Home"))
end)
-- ── /links — single free-form page (markdown stored in settings) ─────
route(fm.GET "/links", function(r)
local body_md = config.get("links_page")
if body_md == "" then return web.not_found() end
local edit_bar = ""
if auth.has_session() then
edit_bar = '<div class="admin-edit-bar">'
.. '<a href="/edit/links" class="admin-edit-link">Edit this page</a>'
.. '</div>'
end
local body = '<article class="post-detail">'
.. '<header class="post-header"><h1>Links</h1></header>'
.. '<div class="post-body">' .. md.render(body_md) .. '</div>'
.. edit_bar
.. '</article>'
return render.respond("Links", body)
end)
-- ── /radio/channels — lazy channel list for the radio player ─────────
-- Fetches the configured m3u playlist server-side (browser CSP allows
-- remote audio only, not remote fetches) and returns channel buttons.
local function parse_m3u(text)
local channels = {}
local pending_name
for line in (text .. "\n"):gmatch("([^\n]*)\n") do
line = line:gsub("\r$", ""):match("^%s*(.-)%s*$")
if line:match("^#EXTINF") then
pending_name = line:match("^#EXTINF:[^,]*,%s*(.+)$")
elseif line ~= "" and not line:match("^#") then
if line:match("^https?://") then
local name = pending_name
if not name or name == "" then
name = line:match("([^/]+)/*$") or line
end
channels[#channels + 1] = { name = name, url = line }
if #channels >= 32 then break end
end
pending_name = nil
end
end
return channels
end
route(fm.GET "/radio/channels", function(r)
local url = config.get("radio_url")
if not config.enabled("radio_enabled") or url == "" then
return web.not_found()
end
local ok, status, _, playlist = pcall(Fetch, url)
if not ok or type(status) ~= "number" or status ~= 200 or not playlist then
Log(kLogWarn, "radio playlist fetch failed: " .. tostring(ok and status or status))
return render.partial('<span class="radio-loading">Station unreachable.</span>')
end
local channels = parse_m3u(playlist)
if #channels == 0 then
return render.partial('<span class="radio-loading">No channels in playlist.</span>')
end
local out = {}
for _, ch in ipairs(channels) do
out[#out + 1] = '<button type="button" class="radio-ch"'
.. ' data-url="' .. EscapeHtml(ch.url) .. '">'
.. EscapeHtml(ch.name) .. '</button>'
end
return render.partial(table.concat(out, "\n"))
end)
-- ── Atom feed ─────────────────────────────────────────────────────────
route(fm.GET "/atom.xml", function(r)
return feed.atom(), {ContentType = "application/atom+xml; charset=utf-8"}
end)
-- ── Wiki (optional feature; parked unless feature_wiki is on) ─────────
local function wiki_enabled()
return config.enabled("feature_wiki")
end
route(fm.GET "/wiki", function(r)
if not wiki_enabled() then return web.not_found() end
local tag = r.params.tag
local pages = tag and db.search(nil, "wiki", tag) or db.list_published("wiki")
local body = '<div class="section-header">'
.. '<h1>Wiki</h1>'
.. '<p>Notes and reference pages.</p>'
.. '</div>'
.. ui.tag_cloud(db.list_tags("wiki"), "wiki", tag)
.. ui.post_list(pages, "wiki")
return render.respond("Wiki", body)
end)
-- Tag-filter fragment for the wiki's tag pills (HTMX swaps #post-list).
-- Must be registered before /wiki/:slug.
route(fm.GET "/wiki/search", function(r)
if not wiki_enabled() then return web.not_found() end
local tag = r.params.tag
if tag then
return render.partial(
ui.search_results(db.search(nil, "wiki", tag), "wiki", "tag: " .. tag))
end
return render.partial(ui.post_list(db.list_published("wiki"), "wiki"))
end)
route(fm.GET "/wiki/:slug", function(r)
if not wiki_enabled() then return web.not_found() end
local page = db.get_content("wiki", r.params.slug, auth.has_session())
if not page then return web.not_found() end
return render.respond(page.title, detail_body(page, "wiki", "/wiki", "Wiki"))
end)
-- ── Global search ─────────────────────────────────────────────────────
-- The nav search bar is the site's only search input: it hx-gets this
-- route into #content as you type (and submits here as a plain form).
route(fm.GET "/search", function(r)
local q = r.params.q or ""
-- With the wiki parked there is only one section to search.
local only_kind = wiki_enabled() and nil or "blog"
local results_html
if q == "" then
results_html = '<div id="search-results">'
.. '<div class="no-results">Type in the search bar above to search all content.</div>'
.. '</div>'
else
local results = db.search(q, only_kind, nil)
if #results == 0 then
results_html = '<div id="search-results">'
.. '<div class="no-results">No results for &#8220;' .. EscapeHtml(q) .. '&#8221;.</div>'
.. '</div>'
else
local by_kind = { blog = {}, wiki = {} }
for _, item in ipairs(results) do
local k = item.kind
if by_kind[k] then by_kind[k][#by_kind[k] + 1] = item end
end
local groups = {}
for _, kind in ipairs({ "blog", "wiki" }) do
if #by_kind[kind] > 0 then
local cards = {}
for _, post in ipairs(by_kind[kind]) do
cards[#cards + 1] = ui.post_card(post, kind)
end
groups[#groups + 1] = '<div class="search-group">'
.. '<div class="search-group-label">' .. kind .. '</div>'
.. table.concat(cards, "\n")
.. '</div>'
end
end
results_html = '<div id="search-results">' .. table.concat(groups, "\n") .. '</div>'
end
end
local body = '<div class="section-header">'
.. '<h1>Search</h1>'
.. (q ~= "" and ('<p>Results for &#8220;' .. EscapeHtml(q) .. '&#8221;</p>') or '')
.. '</div>'
.. results_html
return render.respond("Search", body)
end)
-- ── Docs: binary file server ──────────────────────────────────────────
-- Docs entries are not listed in the nav or search — they are linked
-- directly (this also serves images uploaded from the post editor).
route(fm.GET "/docs/:slug", function(r)
local doc = db.get_doc(r.params.slug, auth.has_session())
if not doc or not doc.file_data or doc.file_data == "" then
return web.not_found()
end
local mime = (doc.mime_type ~= "" and doc.mime_type) or "application/octet-stream"
local ext = media.MIME_EXT[mime] or ""
-- Content-Disposition is set directly; Content-Type is passed as the second
-- return value so Fullmoon uses it verbatim and skips detectType() sniffing.
SetHeader("Content-Disposition", 'inline; filename="' .. doc.slug .. ext .. '"')
return doc.file_data, {ContentType = mime}
end)
-- Static files (serves /static/* assets from the zip)
route(fm.GET "/static/*", "/static/*")

145
.lua/ui.lua Normal file
View File

@@ -0,0 +1,145 @@
-- ui.lua — shared HTML component builders.
--
-- Pure functions: data in, HTML string out. No DB access here; route
-- handlers query and pass rows in.
local web = require "web"
local M = {}
-- ── Tag pills ─────────────────────────────────────────────────────────
-- Render tag pills for a list of tag strings.
-- kind scopes the filter link to that section ("blog" filters on the feed at /).
-- htmx=true → include hx-get/hx-target/hx-swap for list pages (where #post-list exists)
-- htmx=false → plain href links only, for detail pages (no #post-list in DOM)
function M.tag_pills(tags_list, kind, htmx)
if #tags_list == 0 then return "" end
-- Blog tag filters live on the front-page feed; wiki keeps its section page.
local base = (kind == "blog") and "/" or ("/" .. kind)
local search_path = "/" .. kind .. "/search"
local sep = (base == "/") and "?" or "?"
local pills = {}
for _, tag in ipairs(tags_list) do
local pill = '<a class="tag-pill"'
.. ' href="' .. base .. sep .. 'tag=' .. EscapePath(tag) .. '"'
if htmx then
pill = pill
.. ' hx-get="' .. search_path .. '?tag=' .. EscapePath(tag) .. '"'
.. ' hx-target="#post-list"'
.. ' hx-swap="outerHTML"'
end
pill = pill .. '>' .. EscapeHtml(tag) .. '</a>'
pills[#pills+1] = pill
end
return table.concat(pills, " ")
end
-- Render the tag cloud for a list page (with active-tag state).
-- tags_list is the output of db.list_tags(kind).
function M.tag_cloud(tags_list, kind, active_tag)
if #tags_list == 0 then return "" end
local base = (kind == "blog") and "/" or ("/" .. kind)
local search_path = "/" .. kind .. "/search"
local pills = {}
for _, t in ipairs(tags_list) do
local is_active = t.tag == active_tag
local cls = is_active and "tag-pill tag-pill-active" or "tag-pill"
-- Active tag: clicking clears the filter.
local qs = "tag=" .. EscapePath(t.tag)
local href = is_active and base or (base .. "?" .. qs)
local hx_get = is_active and search_path or (search_path .. "?" .. qs)
pills[#pills+1] = '<a class="' .. cls .. '"'
.. ' href="' .. href .. '"'
.. ' hx-get="' .. hx_get .. '"'
.. ' hx-target="#post-list"'
.. ' hx-swap="outerHTML">'
.. EscapeHtml(t.tag) .. '</a>'
end
return '<div class="tag-cloud">' .. table.concat(pills, "\n") .. '</div>'
end
-- ── Post cards / lists ────────────────────────────────────────────────
--- Render a single post/wiki card for use in list and search results.
function M.post_card(post, kind)
local date = web.fmt_date(post.created_at)
local summary = (post.summary ~= "" and post.summary)
or (post.excerpt and post.excerpt ~= "" and post.excerpt)
or post.body:sub(1, 160) .. ""
local tags_list = web.split_tags(post.tags or "")
local tags_html = ""
if #tags_list > 0 then
tags_html = '<div class="post-card-tags">'
.. M.tag_pills(tags_list, kind, true) .. '</div>'
end
local draft_html = post.published == 0
and ' <span class="status-badge status-draft">Draft</span>' or ""
return '<article class="post-card">'
.. '<a href="/' .. kind .. '/' .. EscapePath(post.slug) .. '">'
.. '<h2>' .. EscapeHtml(post.title) .. '</h2>'
.. '<div class="post-meta"><time datetime="' .. date .. '">' .. date .. '</time>'
.. draft_html .. '</div>'
.. '<p class="post-excerpt">' .. EscapeHtml(summary) .. '</p>'
.. '</a>'
.. tags_html
.. '</article>'
end
--- Render the swappable list div containing post cards.
--- kind sets a CSS modifier class (.post-list-wiki gets a 2-col grid).
function M.post_list(posts, kind)
if #posts == 0 then
return '<div id="post-list" class="post-list post-list-' .. kind
.. '"><div class="no-results">No posts found.</div></div>'
end
local cards = {}
for _, post in ipairs(posts) do
cards[#cards + 1] = M.post_card(post, kind)
end
return '<div id="post-list" class="post-list post-list-' .. kind .. '">'
.. table.concat(cards, "\n") .. '</div>'
end
--- Render search results fragment (no layout wrapper — HTMX target).
--- results: rows from db.search(). label: human-readable query description.
function M.search_results(results, kind, label)
if #results == 0 then
return '<div id="post-list" class="post-list post-list-' .. (kind or "blog")
.. '"><div class="no-results">No results for &#8220;'
.. EscapeHtml(label) .. '&#8221;.</div></div>'
end
local cards = {}
for _, post in ipairs(results) do
cards[#cards + 1] = M.post_card(post, kind or post.kind)
end
return '<div id="post-list" class="post-list post-list-' .. (kind or "blog") .. '">'
.. table.concat(cards, "\n") .. '</div>'
end
-- ── Pagination ────────────────────────────────────────────────────────
--- Render older/newer pagination links for the feed.
--- base is the page path ("/"); page numbers are 1-based.
function M.pagination(base, page, total_pages)
if total_pages <= 1 then return "" end
local sep = base:find("?", 1, true) and "&" or "?"
local function link(p, label, rel)
local href = p == 1 and base or (base .. sep .. "page=" .. p)
return '<a class="page-link" rel="' .. rel .. '" href="' .. href .. '">'
.. label .. '</a>'
end
local newer = page > 1
and link(page - 1, "&#8592; Newer", "prev")
or '<span class="page-link page-link-disabled">&#8592; Newer</span>'
local older = page < total_pages
and link(page + 1, "Older &#8594;", "next")
or '<span class="page-link page-link-disabled">Older &#8594;</span>'
return '<nav class="pagination" aria-label="Pagination">'
.. newer
.. '<span class="page-current">Page ' .. page .. ' of ' .. total_pages .. '</span>'
.. older
.. '</nav>'
end
return M

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