-- 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 = '
' -- Header .. '
' .. '← Dashboard' .. '

' .. EscapeHtml(page_title) .. '

' .. '
' .. err_html -- Form .. '
' .. '' .. '' -- Meta row: title / slug / published .. '
' .. '
' .. '' .. '' .. '
' .. '
' .. '' .. '' .. '
' .. '
' .. '' .. '
' .. '
' -- /editor-meta-row .. content_section_html -- Actions .. '
' .. view_link .. '' .. '
' .. '
' .. 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] = '' .. '' .. kind_badge .. '' .. '' .. EscapeHtml(item.title) .. '' .. '' .. EscapeHtml(item.slug) .. '' .. '' .. pub_badge .. '' .. '' .. date .. '' .. '' .. '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 '
' .. '' .. '' .. '' .. '' .. '' .. '' .. table.concat(rows, "\n") .. '' .. '
KindTitleSlugStatusCreatedActions
' .. '
' 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] = '' end local msg_html = msg and '

' .. EscapeHtml(msg) .. '

' or "" local body = '
' .. '
' .. '← Dashboard' .. '

Settings

' .. '
' .. msg_html .. '
' .. '' .. '' .. table.concat(site_fields) .. checkbox_field("feature_wiki", "Enable the wiki section") .. '' .. '

Up to four extra nav items. Both label and URL must be set for a link to appear.

' .. table.concat(nav_fields) .. '' .. checkbox_field("radio_enabled", "Show the floating radio player") .. text_field("radio_url", "Playlist URL (m3u)", "https://radio.example.com/stations.m3u") .. '
' .. '' .. '
' .. '
' .. '
' 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 '

Preview will appear here\226\128\166

' local msg_html = msg and '

' .. EscapeHtml(msg) .. '

' or "" local body = '
' .. '
' .. '← Dashboard' .. '

Links page

' .. '
' .. '

A single free-form Markdown page served at /links.' .. ' Leave it empty to remove the page and its nav item.

' .. msg_html .. '
' .. '' .. '
' .. '
' .. '
Markdown
' .. '' .. '
' .. '
' .. '
Preview
' .. '
' .. preview_html .. '
' .. '
' .. '
' .. '
' .. (body_val ~= "" and 'View live' or "") .. '' .. '
' .. '
' .. '
' 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 = "![](/docs/" .. slug .. ")" 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)