Initial commit, blog system works
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user