-- 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 =
'
Invalid request token. Please go back and try again.
'
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 '
' .. err_msg .. '
'
or ""
local body =
'
'
.. '
Admin Login
'
.. err_html
.. ''
.. '
'
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 '
' .. EscapeHtml(err) .. '
'
or ""
local preview_html = body_val ~= ""
and md.render(body_val)
or '
Preview will appear here\226\128\166
'
-- View-live link (edit mode only, shown when slug is known).
local view_link = (not is_new and slug_val ~= "")
and ('View live')
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
.. '"
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 =
'
'
.. ''
.. ''
.. '
'
.. '
'
.. ''
.. '
' .. file_note .. '
'
.. ''
.. '
'
else
content_section_html =
-- Summary (used by feed cards and the Atom feed)
'
'
.. ''
.. ''
.. '
'
-- Tags field (full-width)
.. '
'
.. ''
.. ''
.. '
'
-- Two-column editor / preview
.. '
'
.. '
'
.. '
Markdown
'
-- hx-swap must be explicit: the inherited main-level swap is
-- outerHTML, which would destroy #preview-pane on first preview.
.. ''
.. '
'
.. '
'
.. '
Preview
'
.. '
'
.. preview_html
.. '
'
.. '
'
.. '
' -- /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 =
'
'
.. '
Insert image
'
.. ''
.. ''
.. '
'
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 =
'
'
.. ''
.. err_html
-- Form
.. ''
.. media_section_html
-- Slug auto-derive + snippet insertion (runs once on DOMContentLoaded).
.. ''
.. '
' -- /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 'Published'
or 'Draft'
local kind_badge =
'' .. item.kind .. ''
rows[#rows + 1] =
'
'
.. 'Edit'
-- 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.
.. ''
.. '
'
.. '
'
end
local table_html = #rows == 0
and '
No content yet. Use the buttons above to create your first post.
'
or '
'
.. '
'
.. '
'
.. '
Kind
Title
Slug
'
.. '
Status
Created
Actions
'
.. '
'
.. '' .. table.concat(rows, "\n") .. ''
.. '
'
.. '
'
local new_buttons = '+ Blog post'
if config.enabled("feature_wiki") then
new_buttons = new_buttons
.. '+ Wiki page'
end
new_buttons = new_buttons
.. '+ File'
.. 'Links page'
.. 'Settings'
local body =
'
'
.. '
Dashboard
'
.. ''
.. '
'
.. '
' .. new_buttons .. '
'
.. 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 '
'
.. ''
.. ''
.. '
'
end
local function checkbox_field(key, label)
local checked = config.enabled(key) and " checked" or ""
return '
'
.. ''
.. '
'
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] =
'
'
.. 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")
.. '
'
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 '
A single free-form Markdown page served at /links.'
.. ' Leave it empty to remove the page and its nav item.
'
.. msg_html
.. ''
.. '
'
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('
Invalid request token.
')
end
local body = r.params.body or ""
if body:match("^%s*$") then
return render.partial('
Preview will appear here\226\128\166
')
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('
Invalid request token.
')
end
local data = r.params.file or ""
if data == "" then
return render.partial('
No file selected.
')
end
local mime = media.sniff(data)
if not mime or not media.is_image(mime) then
return render.partial(
'
Unsupported file type. Use PNG, JPEG, GIF, or WebP.
')
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('
Upload failed. Check the server log.
')
end
local snippet = ""
return render.partial(
'
'
.. '' .. EscapeHtml(snippet) .. ''
.. ''
.. '
')
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)