Initial commit, blog system works
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Build products
|
||||
blog.com
|
||||
blog.db
|
||||
blog.db-wal
|
||||
blog.db-shm
|
||||
.deps/
|
||||
|
||||
# Fetched at maketime (see Makefile)
|
||||
.lua/fm.lua
|
||||
static/htmx.min.js
|
||||
static/highlight.min.js
|
||||
static/plexserif400.ttf
|
||||
static/plexserif500.ttf
|
||||
static/plexmono400.ttf
|
||||
static/plexmono500.ttf
|
||||
30
.init.lua
Normal file
30
.init.lua
Normal file
@@ -0,0 +1,30 @@
|
||||
-- special script called by main redbean process at startup
|
||||
HidePath('/usr/share/zoneinfo/')
|
||||
HidePath('/usr/share/ssl/')
|
||||
|
||||
-- fm from https://github.com/pkulchenko/fullmoon
|
||||
local fm = require "fm"
|
||||
local app = require "app"
|
||||
|
||||
-----------------------------------------------------------------
|
||||
-- Basic Browser Launch Logic
|
||||
-----------------------------------------------------------------
|
||||
local function launch_browser(url)
|
||||
if os.getenv("OS") == "Windows_NT" then
|
||||
os.execute("start " .. url)
|
||||
else
|
||||
-- Try xdg-open for Linux, then open for macOS
|
||||
if not os.execute("xdg-open " .. url .. " 2>/dev/null") then
|
||||
os.execute("open " .. url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Launch browser only on initial startup (skip for daemons/tests:
|
||||
-- set BLOG_NO_BROWSER=1 to suppress).
|
||||
if not _G.launched and not os.getenv("BLOG_NO_BROWSER") then
|
||||
launch_browser("http://localhost:8080")
|
||||
_G.launched = true
|
||||
end
|
||||
|
||||
fm.run()
|
||||
20
.lua/app.lua
Normal file
20
.lua/app.lua
Normal 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
101
.lua/auth.lua
Normal 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
77
.lua/config.lua
Normal 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
314
.lua/db.lua
Normal 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
70
.lua/feed.lua
Normal 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 = {
|
||||
["&"] = "&", ["<"] = "<", [">"] = ">",
|
||||
['"'] = """, ["'"] = "'",
|
||||
}
|
||||
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
454
.lua/md.lua
Normal 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 = { ["&"] = "&", ["<"] = "<", [">"] = ">", ['"'] = """ }
|
||||
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 "), 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). 
|
||||
s = s:gsub("!%[([^%]]*)%]%(([^%)]*)%)", function(alt, spec)
|
||||
local url, title = spec:match('^(.-)%s+"(.-)"%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+"(.-)"%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">↩</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
52
.lua/media.lua
Normal 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
266
.lua/render.lua
Normal 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">▸</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>© ' .. os.date("!%Y") .. ' ' .. EscapeHtml(who)
|
||||
.. ' · <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
788
.lua/routes_admin.lua
Normal 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">← 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">← 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">← 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 = ""
|
||||
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
306
.lua/routes_public.lua
Normal 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">← ' .. 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 “' .. EscapeHtml(q) .. '”.</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 “' .. EscapeHtml(q) .. '”</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
145
.lua/ui.lua
Normal 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 “'
|
||||
.. EscapeHtml(label) .. '”.</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, "← Newer", "prev")
|
||||
or '<span class="page-link page-link-disabled">← Newer</span>'
|
||||
local older = page < total_pages
|
||||
and link(page + 1, "Older →", "next")
|
||||
or '<span class="page-link page-link-disabled">Older →</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
205
.lua/web.lua
Normal file
@@ -0,0 +1,205 @@
|
||||
-- web.lua — request plumbing shared by all route modules.
|
||||
--
|
||||
-- Owns: the route() wrapper (security headers + pcall guard), CSRF
|
||||
-- helpers, redirects, client IP + rate limiting, and small string
|
||||
-- helpers (dates, slugs, tags).
|
||||
|
||||
local fm = require "fm"
|
||||
local render = require "render"
|
||||
|
||||
local M = {}
|
||||
|
||||
-- ── Error bodies ──────────────────────────────────────────────────────
|
||||
|
||||
M.BODY_404 = [[
|
||||
<div class="error-page">
|
||||
<div class="error-code">404</div>
|
||||
<h1>Page Not Found</h1>
|
||||
<p>The page you’re looking for doesn’t exist.</p>
|
||||
<a href="/">← Go home</a>
|
||||
</div>
|
||||
]]
|
||||
|
||||
M.BODY_500 = [[
|
||||
<div class="error-page">
|
||||
<div class="error-code">500</div>
|
||||
<h1>Internal Error</h1>
|
||||
<p>Something went wrong on our end. Please try again later.</p>
|
||||
<a href="/">← Go home</a>
|
||||
</div>
|
||||
]]
|
||||
|
||||
--- Set 404 status and return the rendered not-found page.
|
||||
function M.not_found()
|
||||
SetStatus(404)
|
||||
return render.respond("Not Found", M.BODY_404)
|
||||
end
|
||||
|
||||
-- ── Security headers / route wrapper ──────────────────────────────────
|
||||
|
||||
local SEC_HEADERS = {
|
||||
["Content-Security-Policy"] =
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline';"
|
||||
.. " style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
|
||||
.. " connect-src 'self'; font-src 'self'; frame-ancestors 'none';"
|
||||
-- Radio streams live on other origins; audio is the only remote media.
|
||||
.. " media-src 'self' https: http:",
|
||||
["X-Frame-Options"] = "DENY",
|
||||
["X-Content-Type-Options"] = "nosniff",
|
||||
["Referrer-Policy"] = "strict-origin-when-cross-origin",
|
||||
}
|
||||
|
||||
-- Route wrapper: applies security headers and guards each handler with pcall.
|
||||
-- Passes string handlers (static asset patterns) straight through unchanged.
|
||||
--
|
||||
-- Security headers are applied AFTER the handler returns, not before.
|
||||
-- Reason: SetStatus() resets the entire response buffer (see Redbean docs).
|
||||
-- Any SetHeader/SetCookie call made before a SetStatus call is silently
|
||||
-- discarded. By applying SEC_HEADERS after pcall(handler), we ensure they
|
||||
-- are always set after the last SetStatus call in the handler.
|
||||
function M.route(pattern, handler)
|
||||
if type(handler) == "string" then
|
||||
fm.setRoute(pattern, handler)
|
||||
return
|
||||
end
|
||||
fm.setRoute(pattern, function(r)
|
||||
local ok, result, extra = pcall(handler, r)
|
||||
for k, v in pairs(SEC_HEADERS) do SetHeader(k, v) end
|
||||
if not ok then
|
||||
Log(kLogWarn, "route error: " .. tostring(result))
|
||||
SetStatus(500)
|
||||
-- Re-apply after SetStatus(500) which also resets the buffer.
|
||||
for k, v in pairs(SEC_HEADERS) do SetHeader(k, v) end
|
||||
return render.respond("Error", M.BODY_500)
|
||||
end
|
||||
-- Forward the optional second return value so Fullmoon uses it as the
|
||||
-- response headers table, suppressing detectType() auto-detection.
|
||||
-- Binary routes (e.g. /docs/:slug) use this to pass {ContentType = mime}.
|
||||
return result, extra
|
||||
end)
|
||||
end
|
||||
|
||||
-- ── Randomness ────────────────────────────────────────────────────────
|
||||
|
||||
local function to_hex(s)
|
||||
return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end))
|
||||
end
|
||||
|
||||
--- Cryptographically random hex string of 2*nbytes chars.
|
||||
function M.random_hex(nbytes)
|
||||
local ok, bytes = pcall(GetRandomBytes, nbytes)
|
||||
if ok and bytes and #bytes == nbytes then return to_hex(bytes) end
|
||||
local ok2, bytes2 = pcall(unix.getrandom, nbytes)
|
||||
if ok2 and bytes2 and #bytes2 == nbytes then return to_hex(bytes2) end
|
||||
local f = io.open("/dev/urandom", "rb")
|
||||
if f then
|
||||
local b = f:read(nbytes); f:close()
|
||||
if b and #b == nbytes then return to_hex(b) end
|
||||
end
|
||||
error("no source of randomness available")
|
||||
end
|
||||
|
||||
-- ── CSRF ──────────────────────────────────────────────────────────────
|
||||
|
||||
--- Return (and mint if absent) the CSRF token for this browser.
|
||||
--- Sets a Set-Cookie header when a new token is generated.
|
||||
function M.csrf_token()
|
||||
local tok = GetCookie("csrf")
|
||||
if not tok or tok == "" then
|
||||
tok = M.random_hex(16)
|
||||
SetHeader("Set-Cookie",
|
||||
"csrf=" .. tok .. "; Path=/; HttpOnly; SameSite=Strict")
|
||||
end
|
||||
return tok
|
||||
end
|
||||
|
||||
--- True only when the submitted csrf_token matches the cookie.
|
||||
function M.csrf_check(r)
|
||||
local tok = GetCookie("csrf")
|
||||
local sub = (r.params and r.params.csrf_token) or ""
|
||||
return tok ~= nil and tok ~= "" and sub == tok
|
||||
end
|
||||
|
||||
-- ── Redirects ─────────────────────────────────────────────────────────
|
||||
|
||||
-- Issue a redirect that actually reaches the browser.
|
||||
--
|
||||
-- Redbean's SetHeader calls accumulate in the C layer but are only flushed
|
||||
-- when Write() is called. Fullmoon only calls Write() when the handler
|
||||
-- returns a non-empty string. Returning " " (one space) forces the flush.
|
||||
-- SetStatus must come first: it resets the response buffer.
|
||||
function M.redirect(url)
|
||||
SetStatus(302)
|
||||
SetHeader("Location", url)
|
||||
return " "
|
||||
end
|
||||
|
||||
-- ── Client IP + rate limiting ─────────────────────────────────────────
|
||||
|
||||
function M.client_ip()
|
||||
-- GetRemoteAddr already consults X-Forwarded-For when the socket IP is a
|
||||
-- trusted proxy, so no manual XFF parsing needed.
|
||||
local ip = GetRemoteAddr()
|
||||
if ip then return FormatIp(ip) end
|
||||
local fwd = GetHeader("X-Forwarded-For")
|
||||
if fwd and fwd ~= "" then
|
||||
local a = fwd:match("^%s*([^,%s]+)")
|
||||
if a then return a end
|
||||
end
|
||||
return "unknown"
|
||||
end
|
||||
|
||||
--- Build an in-memory sliding-window rate limiter.
|
||||
--- Note: per-process state — redbean forks per request, so this is a
|
||||
--- best-effort limit, acceptable for login throttling.
|
||||
function M.make_limiter(max_count, window_secs)
|
||||
local log = {} -- [ip] = { unix_timestamp, ... }
|
||||
return function(ip)
|
||||
local now = os.time()
|
||||
local fresh = {}
|
||||
for _, t in ipairs(log[ip] or {}) do
|
||||
if now - t < window_secs then fresh[#fresh + 1] = t end
|
||||
end
|
||||
if #fresh >= max_count then
|
||||
log[ip] = fresh
|
||||
return false
|
||||
end
|
||||
fresh[#fresh + 1] = now
|
||||
log[ip] = fresh
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- ── String helpers ────────────────────────────────────────────────────
|
||||
|
||||
--- Extract YYYY-MM-DD from an ISO timestamp.
|
||||
function M.fmt_date(iso)
|
||||
return iso and iso:sub(1, 10) or ""
|
||||
end
|
||||
|
||||
--- Trim surrounding whitespace.
|
||||
function M.trim(s)
|
||||
return (s or ""):match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
function M.slugify(s)
|
||||
return (s:lower():gsub("[^a-z0-9]+", "-"):gsub("^%-+", ""):gsub("%-+$", ""))
|
||||
end
|
||||
|
||||
function M.valid_slug(s)
|
||||
return s ~= ""
|
||||
and (s:match("^[a-z0-9]$") or s:match("^[a-z0-9][a-z0-9%-]*[a-z0-9]$")) ~= nil
|
||||
end
|
||||
|
||||
--- Split a comma-separated tag string into a trimmed list.
|
||||
function M.split_tags(s)
|
||||
if not s or s == "" then return {} end
|
||||
local list = {}
|
||||
for tag in s:gmatch("[^,]+") do
|
||||
tag = tag:match("^%s*(.-)%s*$")
|
||||
if tag ~= "" then list[#list+1] = tag end
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
return M
|
||||
12
.luarc.json
Normal file
12
.luarc.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"runtime.version": "Lua 5.4",
|
||||
"workspace.ignoreDir": [".jj", ".deps"],
|
||||
"diagnostics.globals": [
|
||||
"HidePath", "Log", "kLogWarn", "kLogInfo", "kLogDebug",
|
||||
"SetStatus", "SetHeader", "SetCookie", "GetCookie", "GetHeader",
|
||||
"GetRemoteAddr", "FormatIp", "ParseIp", "GetParam",
|
||||
"EscapeHtml", "EscapePath", "EncodeHex", "GetRandomBytes",
|
||||
"ServeRedirect", "Write", "Fetch",
|
||||
"unix", "argon2"
|
||||
]
|
||||
}
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
69
Makefile
Normal file
69
Makefile
Normal file
@@ -0,0 +1,69 @@
|
||||
# --- Web Sources ---
|
||||
REDBEAN_URL = https://redbean.dev/redbean-3.0.0.com
|
||||
FM_URL = https://raw.githubusercontent.com/pkulchenko/fullmoon/master/fullmoon.lua
|
||||
HTMX_URL = https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js
|
||||
HLJS_URL = https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/highlight.min.js
|
||||
PLEX_SERIF = https://github.com/IBM/plex/raw/master/IBM-Plex-Serif/fonts/complete/ttf
|
||||
PLEX_MONO = https://github.com/IBM/plex/raw/master/IBM-Plex-Mono/fonts/complete/ttf
|
||||
|
||||
# --- Targets ---
|
||||
TARGET = blog.com
|
||||
UPSTREAM_BIN = .deps/redbean.com
|
||||
LUA_DIR = .lua
|
||||
ASSET_DIR = static
|
||||
|
||||
LOCAL_LUA = .init.lua $(wildcard $(LUA_DIR)/*.lua)
|
||||
LOCAL_ASSET = $(wildcard $(ASSET_DIR)/*)
|
||||
|
||||
FILES = .init.lua .lua static
|
||||
FONTS = $(ASSET_DIR)/plex-serif-400.ttf \
|
||||
$(ASSET_DIR)/plex-serif-500.ttf \
|
||||
$(ASSET_DIR)/plex-mono-400.ttf \
|
||||
$(ASSET_DIR)/plex-mono-500.ttf
|
||||
|
||||
|
||||
.PHONY: all clean fetch build setup
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(LUA_DIR) $(ASSET_DIR) .deps:
|
||||
@mkdir -p $@
|
||||
|
||||
fetch: .deps/redbean.com $(LUA_DIR)/fm.lua $(ASSET_DIR)/htmx.min.js $(ASSET_DIR)/highlight.min.js fetch-fonts
|
||||
|
||||
.deps/redbean.com: | .deps
|
||||
curl -L $(REDBEAN_URL) -o $@
|
||||
chmod +x $@
|
||||
|
||||
$(LUA_DIR)/fm.lua: | $(LUA_DIR)
|
||||
curl -L $(FM_URL) -o $@
|
||||
|
||||
$(ASSET_DIR)/htmx.min.js: | $(ASSET_DIR)
|
||||
curl -L $(HTMX_URL) -o $@
|
||||
|
||||
$(ASSET_DIR)/highlight.min.js: | $(ASSET_DIR)
|
||||
curl -L $(HLJS_URL) -o $@
|
||||
|
||||
fetch-fonts: | $(ASSET_DIR)
|
||||
@echo "Fetching fonts..."
|
||||
curl -L $(PLEX_SERIF)/IBMPlexSerif-Regular.ttf -o $(ASSET_DIR)/plexserif400.ttf
|
||||
curl -L $(PLEX_SERIF)/IBMPlexSerif-Medium.ttf -o $(ASSET_DIR)/plexserif500.ttf
|
||||
curl -L $(PLEX_MONO)/IBMPlexMono-Regular.ttf -o $(ASSET_DIR)/plexmono400.ttf
|
||||
curl -L $(PLEX_MONO)/IBMPlexMono-Medium.ttf -o $(ASSET_DIR)/plexmono500.ttf
|
||||
|
||||
$(TARGET): fetch $(LOCAL_LUA) $(LOCAL_ASSET)
|
||||
cp $(UPSTREAM_BIN) $(TARGET)
|
||||
zip -r $(TARGET) .init.lua $(LUA_DIR)/ $(ASSET_DIR)/
|
||||
@echo "Build complete: $(TARGET)"
|
||||
|
||||
setup: $(TARGET)
|
||||
./$(TARGET) -i setup_admin.lua
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
rm -f .lua/fm.lua
|
||||
rm -f static/htmx.min.js
|
||||
rm -f static/highlight.min.js
|
||||
rm -f $(FONTS)
|
||||
rm -f blog.db
|
||||
rm -rf .deps
|
||||
155
README.md
Normal file
155
README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# btmx
|
||||
|
||||
A self-contained hypermedia-driven blogging platform in a single portable binary. Written in Lua for the [RedBean](https://redbean.dev/) webserver, routed with [Fullmoon](https://github.com/pkulchenko/fullmoon), rendered as hypermedia with [HTMX](https://htmx.org/), and stored in SQLite. The build produces one executable (`blog.com`) that runs on Linux, BSD, macOS, and Windows; content lives in `blog.db` next to it.
|
||||
|
||||
## Features
|
||||
|
||||
- **Single binary:** no dependencies, no installation, no configuration files. Just run `./blog.com` and it just works™.
|
||||
- **Markdown support:** posts are Markdown, rendered into HTML. [highlight.js](https://highlightjs.org/) is included for code blocks.
|
||||
- **Radio Station:** web radio included as an optional feature for a given .m3u playlist URL.
|
||||
- **RSS/Atom:** Atom feed is generated automatically at `/atom.xml`.
|
||||
- **Themes:** attempts to respect user preferences for light or dark mode. Also includes a retro theme.
|
||||
- **Admin dashboard:** `/edit` does almost everything needed to manage the blog.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Requirements
|
||||
|
||||
All requirements are pulled during maketime with `curl` using GNU `make` (if Justine has not updated her TLS certs, you may need to grab redbean by hand.)
|
||||
Because it's awesome, the fatbin should work robustly on most OSes; daemons may need to use the `ape` loader, rather than running the binary directly.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
git clone https://git.asperger.pro/hpcdisrespecter/btmx.git
|
||||
cd btmx
|
||||
make # fetch redbean/fullmoon/htmx/highlight.js/fonts and build blog.com
|
||||
make setup # set the admin password (creates blog.db)
|
||||
```
|
||||
|
||||
There is nothing to edit before building: all site identity (title, author, base URL, …) is configured after first login at `/edit/settings`.
|
||||
|
||||
## Running
|
||||
|
||||
### Configuration
|
||||
|
||||
Resetting the password and setting up the database:
|
||||
|
||||
```bash
|
||||
./blog.com -i setup_admin.lua
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
./blog.com
|
||||
```
|
||||
|
||||
Runs on `localhost:8080` and attempts to open your default browser to the blog (set `BLOG_NO_BROWSER=1` to suppress). Note that the Lua application is zipped **inside** `blog.com`; after editing anything under `.lua/` or `static/`, run `make` again.
|
||||
|
||||
### HTTPS
|
||||
|
||||
```
|
||||
./blog.com -p 443 \
|
||||
-K privkey.pem \
|
||||
-C fullchain.pem \
|
||||
-J
|
||||
```
|
||||
|
||||
Runs on `localhost:443` with TLS and mandatory HTTPS using the provided certificates.
|
||||
|
||||
### Daemon
|
||||
|
||||
#### Systemd Service
|
||||
|
||||
First, produce a daemon user and copy the application files to its home directory:
|
||||
|
||||
```bash
|
||||
sudo useradd -r -s /usr/sbin/nologin -d /var/lib/blog blog
|
||||
cp blog.com /var/lib/blog/blog.com
|
||||
cp blog.db /var/lib/blog/blog.db
|
||||
sudo chown -R blog:blog /var/lib/blog
|
||||
```
|
||||
|
||||
Then, deploy your TLS certificates to `/var/lib/blog/privkey.pem` and `/var/lib/blog/fullchain.pem` with similar permissions. Consider using a hook in your certificate management tool to automate this process.
|
||||
|
||||
Finally, make sure your system has a working [ape](https://justine.lol/ape.html) loader.
|
||||
```
|
||||
which ape
|
||||
```
|
||||
|
||||
Create `/etc/systemd/system/blog.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Hypermedia Blog (Redbean)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=blog
|
||||
WorkingDirectory=/var/lib/blog
|
||||
Environment=BLOG_NO_BROWSER=1
|
||||
ExecStart=ape \
|
||||
/var/lib/blog/blog.com -p 443 -p 80 \
|
||||
-K /path/to/privkey.pem \
|
||||
-C /path/to/fullchain.pem \
|
||||
-J
|
||||
Restart=on-failure
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Then enable and start the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable blog.service
|
||||
sudo systemctl start blog.service
|
||||
```
|
||||
|
||||
#### OpenRC Service
|
||||
|
||||
As above, you produce a daemon user, a copy of the application files, and deploy your TLS certificates.
|
||||
|
||||
Then, create `/etc/init.d/blog`:
|
||||
|
||||
```bash
|
||||
#!/sbin/openrc-run
|
||||
command="/usr/bin/ape"
|
||||
command_args="/var/lib/blog/blog.com -p 443 -p 80 \
|
||||
-K /path/to/privkey.pem \
|
||||
-C /path/to/fullchain.pem \
|
||||
-J"
|
||||
command_user="blog:blog"
|
||||
export BLOG_NO_BROWSER=1
|
||||
depend() {
|
||||
need net
|
||||
}
|
||||
```
|
||||
Make the script executable and add it to the default runlevel:
|
||||
|
||||
```bash
|
||||
sudo chmod +x /etc/init.d/blog
|
||||
sudo rc-update add blog default
|
||||
```
|
||||
|
||||
Finally, start the service:
|
||||
|
||||
```bash
|
||||
sudo rc-service blog start
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **Database**: `blog.db` is created automatically at first request in the working directory. Back it up and you have backed up the whole site (posts, uploads, settings, password hash).
|
||||
- **Admin access**: Visit `/login`, enter your password. A session cookie (HttpOnly, SameSite=Strict, 7-day TTL) is set on success. Sessions are stored in `blog.db` and survive server restarts. `/edit` is the admin dashboard; `/edit/settings` configures the site.
|
||||
- **Atom feed**: set the **Base URL** in Settings to your public origin (e.g. `https://example.com`) so feed IDs and links are absolute.
|
||||
- **Rate limiting**: Login is limited to 5 attempts per IP per 15 minutes (in-memory; resets on restart).
|
||||
- **CSRF tokens**: Every mutating form includes a `csrf_token` hidden field validated against an HttpOnly SameSite=Strict cookie.
|
||||
- **Password changes**: Re-run `./blog.com -i setup_admin.lua` at any time. The new hash overwrites the old one immediately.
|
||||
85
setup_admin.lua
Normal file
85
setup_admin.lua
Normal file
@@ -0,0 +1,85 @@
|
||||
-- setup_admin.lua — one-shot script to set the admin password.
|
||||
--
|
||||
-- Run with: ./blog.com -i setup_admin.lua
|
||||
-- (Redbean executes the script in the same Lua environment as the app,
|
||||
-- so require "db" and the argon2 module are available.)
|
||||
--
|
||||
-- Uses argon2id with OWASP Password Storage Cheat Sheet parameters:
|
||||
-- m_cost = 65536 (64 MiB memory)
|
||||
-- t_cost = 3 (3 passes)
|
||||
-- parallelism = 4
|
||||
-- hash_len = 32
|
||||
|
||||
local db = require "db"
|
||||
db.init()
|
||||
|
||||
-- Toggle terminal echo so the password is not shown while typing.
|
||||
-- Returns true when echo state was actually changed (i.e. stdin is a
|
||||
-- tty and stty exists); on Windows or piped stdin it fails silently
|
||||
-- and input is read as-is.
|
||||
local function set_echo(enabled)
|
||||
local ok = os.execute("stty " .. (enabled and "echo" or "-echo") .. " 2>/dev/null")
|
||||
return ok == true or ok == 0
|
||||
end
|
||||
|
||||
local function read_password(prompt)
|
||||
io.write(prompt)
|
||||
io.flush()
|
||||
local hidden = set_echo(false)
|
||||
local pw = io.read("*l")
|
||||
if hidden then
|
||||
set_echo(true)
|
||||
io.write("\n") -- the user's Enter was not echoed
|
||||
io.flush()
|
||||
end
|
||||
return pw
|
||||
end
|
||||
|
||||
local password = read_password("Enter new admin password: ")
|
||||
|
||||
if not password or password:match("^%s*$") then
|
||||
io.stderr:write("Error: password cannot be empty.\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
local confirm = read_password("Confirm admin password: ")
|
||||
|
||||
if password ~= confirm then
|
||||
io.stderr:write("Error: passwords do not match.\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
-- Generate a 16-byte random salt.
|
||||
local salt
|
||||
local ok_r, bytes = pcall(unix.getrandom, 16)
|
||||
if ok_r and bytes and #bytes == 16 then
|
||||
salt = bytes
|
||||
else
|
||||
local f = io.open("/dev/urandom", "rb")
|
||||
if f then
|
||||
salt = f:read(16)
|
||||
f:close()
|
||||
end
|
||||
end
|
||||
|
||||
if not salt or #salt < 16 then
|
||||
io.stderr:write("Error: could not generate a random salt.\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
-- Hash with argon2id using OWASP-recommended parameters.
|
||||
local ok_h, hash = pcall(argon2.hash_encoded, password, salt, {
|
||||
variant = argon2.variants.argon2_id,
|
||||
m_cost = 65536, -- 64 MiB
|
||||
t_cost = 3,
|
||||
parallelism = 4,
|
||||
hash_len = 32,
|
||||
})
|
||||
|
||||
if not ok_h or not hash then
|
||||
io.stderr:write("Error: argon2 hashing failed: " .. tostring(hash) .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
db.set_setting("admin_password_hash", hash)
|
||||
io.write("Admin password set successfully.\n")
|
||||
66
static/radio.js
Normal file
66
static/radio.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// radio.js — floating radio player (bottom-left).
|
||||
//
|
||||
// Markup comes from render.lua (only when the radio feature is enabled);
|
||||
// the channel list is lazily fetched by HTMX from /radio/channels on the
|
||||
// first expand. Volume and last-played channel persist in localStorage.
|
||||
|
||||
(function () {
|
||||
function init() {
|
||||
var player = document.getElementById('radio-player');
|
||||
if (!player) return;
|
||||
var audio = document.getElementById('radio-audio');
|
||||
var play = document.getElementById('radio-play');
|
||||
var vol = document.getElementById('radio-vol');
|
||||
var expand = document.getElementById('radio-expand');
|
||||
var channels = document.getElementById('radio-channels');
|
||||
|
||||
function markActive() {
|
||||
channels.querySelectorAll('.radio-ch').forEach(function (b) {
|
||||
b.classList.toggle('radio-ch-active', b.dataset.url === audio.src);
|
||||
});
|
||||
}
|
||||
|
||||
// Restore persisted state.
|
||||
var savedVol = parseFloat(localStorage.getItem('radio_vol'));
|
||||
if (!isNaN(savedVol)) vol.value = Math.round(savedVol * 100);
|
||||
audio.volume = vol.value / 100;
|
||||
var savedSrc = localStorage.getItem('radio_src');
|
||||
if (savedSrc) {
|
||||
audio.src = savedSrc;
|
||||
play.disabled = false;
|
||||
}
|
||||
|
||||
play.addEventListener('click', function () {
|
||||
if (audio.paused) audio.play(); else audio.pause();
|
||||
});
|
||||
audio.addEventListener('play', function () { player.classList.add('radio-playing'); });
|
||||
audio.addEventListener('pause', function () { player.classList.remove('radio-playing'); });
|
||||
|
||||
vol.addEventListener('input', function () {
|
||||
audio.volume = vol.value / 100;
|
||||
localStorage.setItem('radio_vol', String(audio.volume));
|
||||
});
|
||||
|
||||
expand.addEventListener('click', function () {
|
||||
var opening = channels.hidden;
|
||||
channels.hidden = !opening;
|
||||
expand.setAttribute('aria-expanded', opening ? 'true' : 'false');
|
||||
expand.classList.toggle('radio-open', opening);
|
||||
});
|
||||
|
||||
channels.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.radio-ch');
|
||||
if (!btn) return;
|
||||
audio.src = btn.dataset.url;
|
||||
audio.play();
|
||||
play.disabled = false;
|
||||
localStorage.setItem('radio_src', audio.src);
|
||||
markActive();
|
||||
});
|
||||
|
||||
// Highlight the saved channel once HTMX loads the list.
|
||||
channels.addEventListener('htmx:afterSwap', markActive);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
1661
static/style.css
Normal file
1661
static/style.css
Normal file
File diff suppressed because it is too large
Load Diff
63
static/theme.js
Normal file
63
static/theme.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// theme.js — theme picker menu + syntax highlighting hooks.
|
||||
//
|
||||
// The critical "read from localStorage" snippet is inlined in the <head>
|
||||
// via render.lua to avoid flash-of-wrong-theme. This file wires the
|
||||
// header theme menu (auto/light/dark/mac8) and highlight.js.
|
||||
|
||||
(function () {
|
||||
var THEMES = ['auto', 'light', 'dark', 'mac8'];
|
||||
|
||||
function currentTheme() {
|
||||
var t = localStorage.getItem('theme');
|
||||
return THEMES.indexOf(t) > 0 ? t : 'auto';
|
||||
}
|
||||
|
||||
function markActive() {
|
||||
var cur = currentTheme();
|
||||
document.querySelectorAll('.theme-option').forEach(function (b) {
|
||||
b.classList.toggle('theme-active', b.dataset.themeChoice === cur);
|
||||
});
|
||||
}
|
||||
|
||||
function applyTheme(t) {
|
||||
if (t === 'auto') {
|
||||
delete document.documentElement.dataset.theme;
|
||||
localStorage.removeItem('theme');
|
||||
} else {
|
||||
document.documentElement.dataset.theme = t;
|
||||
localStorage.setItem('theme', t);
|
||||
}
|
||||
markActive();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
markActive();
|
||||
var menu = document.getElementById('theme-menu');
|
||||
if (!menu) return;
|
||||
menu.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.theme-option');
|
||||
if (!btn) return;
|
||||
applyTheme(btn.dataset.themeChoice);
|
||||
menu.open = false;
|
||||
});
|
||||
// Close the menu when clicking anywhere else.
|
||||
document.addEventListener('click', function (e) {
|
||||
if (menu.open && !menu.contains(e.target)) menu.open = false;
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Syntax highlighting: run highlight.js over fenced code blocks on page
|
||||
// load and again after every HTMX swap (boosted nav, editor preview).
|
||||
// highlight.js marks processed elements, so re-running is safe.
|
||||
function highlightCode(root) {
|
||||
if (!window.hljs) return;
|
||||
(root || document).querySelectorAll('pre code').forEach(function (el) {
|
||||
if (el.dataset.highlighted !== 'yes') window.hljs.highlightElement(el);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () { highlightCode(); });
|
||||
document.addEventListener('htmx:afterSwap', function (e) {
|
||||
highlightCode(e.target);
|
||||
});
|
||||
Reference in New Issue
Block a user