Files
btmx/.lua/routes_admin.lua

789 lines
29 KiB
Lua

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