Files
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

643 lines
22 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/markdown"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// registerPostRoutes mounts the post, preview, import and upload
// endpoints. Literal paths are registered before {slug} patterns.
func (a *Admin) registerPostRoutes(mux *http.ServeMux) {
// The exact path, not a subtree: an unknown URL under /admin/ must be
// a 404 rather than a dashboard.
mux.HandleFunc("GET /admin/{$}", a.requireLogin(a.handleDashboard))
mux.HandleFunc("GET /admin/posts/exists", a.requireLogin(a.handleExists))
mux.HandleFunc("GET /admin/posts/new", a.requireLogin(a.handleNewForm))
mux.HandleFunc("GET /admin/posts/import", a.requireLogin(a.handleImportForm))
mux.HandleFunc("POST /admin/posts/import", a.requireLogin(a.handleImport))
mux.HandleFunc("POST /admin/posts/bulk", a.requireLogin(a.handleBulk))
mux.HandleFunc("POST /admin/posts", a.requireLogin(a.handleCreate))
mux.HandleFunc("POST /admin/preview", a.requireLogin(a.handlePreview))
mux.HandleFunc("POST /admin/uploads", a.requireLogin(a.handleUpload))
mux.HandleFunc("GET /admin/posts/{slug}/download", a.requireLogin(a.handleDownload))
mux.HandleFunc("GET /admin/posts/{slug}/history", a.requireLogin(a.handleHistory))
mux.HandleFunc("GET /admin/posts/{slug}/history/{name}", a.requireLogin(a.handleHistoryDownload))
mux.HandleFunc("GET /admin/posts/{slug}/history/{name}/diff", a.requireLogin(a.handleHistoryDiff))
mux.HandleFunc("POST /admin/posts/{slug}/history/{name}/restore", a.requireLogin(a.handleHistoryRestore))
mux.HandleFunc("GET /admin/posts/{slug}/edit", a.requireLogin(a.handleEditForm))
mux.HandleFunc("POST /admin/posts/{slug}/delete", a.requireLogin(a.handleDelete))
mux.HandleFunc("POST /admin/posts/{slug}/undelete", a.requireLogin(a.handleUndelete))
mux.HandleFunc("POST /admin/posts/{slug}/duplicate", a.requireLogin(a.handleDuplicate))
mux.HandleFunc("GET /admin/posts/{slug}/preview-link", a.requireLogin(a.handlePreviewLink))
mux.HandleFunc("POST /admin/posts/{slug}", a.requireLogin(a.handleUpdate))
mux.HandleFunc("GET /admin/icon.svg", a.handleIcon)
}
// requireLogin redirects unauthenticated requests to the login form.
func (a *Admin) requireLogin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
username := sess.Get("user")
record := a.deps.Users.Find(username)
if username == "" || record == nil {
if username != "" {
sess.Clear()
}
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// The session is bound to the password it was issued under: a
// change (the owner's or an admin reset) retires every cookie
// still in the wild, which is what "change the password" has to
// mean for a compromised account.
if sess.Get("pv") != sessionFingerprint(record.PasswordHash) {
sess.Clear()
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Everything logged from here on names the account it happened
// for.
ctx := web.WithLogger(r.Context(), web.Logger(r.Context()).With("user", username))
next(w, r.WithContext(ctx))
}
}
// maxBackupImportBytes bounds the settings import request. A backup
// archive legitimately exceeds max_upload_bytes (it carries every post
// and image), so it gets the same budget backup.Restore enforces on the
// decompressed side.
const maxBackupImportBytes = 512 << 20
// requestLimit is the byte bound on one admin POST body.
func (a *Admin) requestLimit(r *http.Request) int64 {
if r.URL.Path == "/admin/settings/import" {
return maxBackupImportBytes + 1<<20
}
return int64(a.deps.Config.Admin.MaxUploadBytes) + 1<<20
}
// requireCSRF validates the form token and writes the error response
// itself when invalid.
func (a *Admin) requireCSRF(w http.ResponseWriter, r *http.Request) bool {
// The body is bounded before parsing: ParseMultipartForm's argument
// is only the in-memory threshold, and net/http drains the rest of a
// multipart body to temp files on disk whatever the threshold says.
// Wrapping the body also lifts ParseForm's internal 10 MiB urlencoded
// cap, so this limit is the one that applies.
r.Body = http.MaxBytesReader(w, r.Body, a.requestLimit(r))
var parseErr error
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") {
parseErr = r.ParseMultipartForm(32 << 20)
} else {
// ParseMultipartForm would call ParseForm internally, swallow its
// error and leave the body consumed, so the content type decides
// which parser runs.
parseErr = r.ParseForm()
}
if parseErr != nil {
if _, ok := errors.AsType[*http.MaxBytesError](parseErr); ok {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return false
}
if !errors.Is(parseErr, http.ErrNotMultipart) {
http.Error(w, "bad form", http.StatusBadRequest)
return false
}
// A body that claims a multipart type but is not parseable as one
// falls through; the token check rejects.
}
if !ValidateCSRF(r, session.FromContext(r.Context())) {
http.Error(w, a.tr(r, "Invalid CSRF token"), http.StatusForbidden)
return false
}
return true
}
func (a *Admin) currentUser(r *http.Request) string {
return session.FromContext(r.Context()).Get("user")
}
func (a *Admin) handleDashboard(w http.ResponseWriter, r *http.Request) {
notice := ""
if bulkAction := r.URL.Query().Get("bulk"); bulkAction == "delete" ||
bulkAction == "draft" || bulkAction == "publish" {
if n, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && n > 0 {
id := map[string]string{
"delete": "posts.deleted", "draft": "posts.drafted", "publish": "posts.published",
}[bulkAction]
notice = i18n.Admin.N(a.langFor(r), id, n)
}
}
a.renderPage(w, r, "list.html", a.dashboardData(r, notice), http.StatusOK)
}
// dashboardData builds the dashboard page: one card per publication, the
// language versions merged into a group that the card can switch between,
// and the counters, the recent list and the tag cloud over the same set.
func (a *Admin) dashboardData(r *http.Request, notice string) *PageData {
posts := a.allPostsSorted()
lang := a.langFor(r)
groups := groupPosts(posts)
display := make([]*post.Post, 0, len(groups))
for _, group := range groups {
display = append(display, pickDisplay(group, lang))
}
cards := make([]postCard, 0, len(groups))
stats := dashboardStats{}
var recent []recentPost
type pending struct {
post *post.Post
due time.Time
}
var upcoming []pending
for i, group := range groups {
p := display[i]
card := newPostCard(p)
if len(group) > 1 {
variants := make([]postVariant, 0, len(group))
var slugs []string
seenSlug := map[string]bool{}
for _, member := range group {
variants = append(variants, newPostVariant(member))
if !seenSlug[member.Slug()] {
seenSlug[member.Slug()] = true
slugs = append(slugs, member.Slug())
}
}
slices.SortFunc(variants, func(x, y postVariant) int {
return strings.Compare(x.Lang, y.Lang)
})
card.Variants = variants
card.VariantsJS = variantsJSON(variants)
// One selection acts on the whole publication: the bulk
// form carries every variant slug, split by commas.
card.GroupSlugs = strings.Join(slugs, ",")
}
if card.GroupSlugs == "" {
card.GroupSlugs = p.Slug()
}
cards = append(cards, card)
switch p.Status() {
case post.StatusDraft:
stats.Drafts++
case post.StatusScheduled:
stats.Scheduled++
if due, ok := p.DueAt(); ok {
upcoming = append(upcoming, pending{p, due})
}
default:
stats.Published++
if len(recent) < 3 {
recent = append(recent, recentPost{
Slug: p.Slug(),
Title: p.Title(),
DateString: p.DateString(),
})
}
}
}
stats.Total = len(cards)
stats.Recent = recent
slices.SortStableFunc(upcoming, func(x, y pending) int {
return x.due.Compare(y.due)
})
for _, entry := range upcoming {
if len(stats.Upcoming) >= 5 {
break
}
when := entry.due.Format("2006-01-02")
if h, m := entry.due.Hour(), entry.due.Minute(); h != 0 || m != 0 {
when = entry.due.Format("2006-01-02 15:04")
}
stats.Upcoming = append(stats.Upcoming, scheduledPost{
Slug: entry.post.Slug(),
Title: entry.post.Title(),
When: when,
})
}
var tagCounts []tagCount
for _, entry := range payloads.BuildTagCounts(display) {
tagCounts = append(tagCounts, tagCount{Name: entry.Name, Count: entry.Count})
}
data := a.pageData(r)
data.Posts = cards
data.Stats = stats
data.TagCounts = tagCounts
data.Q = strings.TrimSpace(r.URL.Query().Get("q"))
data.Notice = notice
data.Crumbs = []Crumb{{Label: "Posts", IsLast: true, UI: true}}
return data
}
func (a *Admin) allPostsSorted() []*post.Post {
posts := a.deps.Store.All()
sorted := slices.Clone(posts)
slices.SortStableFunc(sorted, func(x, y *post.Post) int {
return strings.Compare(y.DateString(), x.DateString())
})
return sorted
}
// handleExists answers the slug-availability check of the editor's slug
// field.
func (a *Admin) handleExists(w http.ResponseWriter, r *http.Request) {
slug := r.URL.Query().Get("slug")
if slug == "" {
writeAdminJSON(w, http.StatusOK, map[string]any{"available": true, "slug": ""})
return
}
existing := a.deps.Store.Find(slug, "")
if existing == nil || (r.URL.Query().Get("exclude") != "" &&
existing.Slug() == r.URL.Query().Get("exclude")) {
writeAdminJSON(w, http.StatusOK, map[string]any{"available": true, "slug": slug})
return
}
writeAdminJSON(w, http.StatusOK, map[string]any{
"available": false, "slug": slug, "title": existing.Title(),
})
}
// editorData fills the shared editor context for new and edit forms.
func (a *Admin) editorData(r *http.Request, mode string, p *post.Post, errorMsg string) *PageData {
data := a.pageData(r)
// The shared validation messages are catalogue keys; an unknown
// message falls back to itself, so nothing breaks untranslated.
data.Error = i18n.Admin.T(data.Lang, errorMsg)
data.Restored = r.URL.Query().Get("restored") != ""
data.Duplicated = r.URL.Query().Get("duplicated") != ""
data.AuthorPlaceholder = i18n.Admin.T(data.Lang, "Author name")
if record := data.CurrentUserRecord; record != nil && record.Name != "" {
data.AuthorPlaceholder = record.Name
}
data.IsNew = mode == "new"
data.IsEdit = mode == "edit"
view := newEditorPost(p)
// The author falls back to the current user record, and the fediverse
// handle to the user record and then to the site.
if view.Author == "" {
if record := data.CurrentUserRecord; record != nil {
view.Author = record.Name
} else {
view.Author = data.CurrentUser
}
}
if view.FediverseCreator == "" {
view.FediverseCreator = a.deps.Config.Site.FediverseCreator
if record := data.CurrentUserRecord; record != nil && record.FediverseCreator != "" {
view.FediverseCreator = record.FediverseCreator
}
}
// The author's ORCID rides on the account: a new post carries it
// unless its own frontmatter names another identifier.
if view.ORCID == "" {
if record := data.CurrentUserRecord; record != nil {
view.ORCID = record.Orcid
}
}
data.Post = view
// The page head's API link carries a preview token, so it opens a
// draft as well as a published post. The token is signed for a week,
// far longer than an editor tab stays open.
data.PreviewToken = preview.Token(view.Slug, a.deps.PreviewKey, time.Now())
// The default excerpt placeholder is interface copy; a derived
// excerpt (the post's own first paragraph) is content and passes
// through untranslated.
if view.ExcerptPlaceholder == "Short summary for listings and previews" {
view.ExcerptPlaceholder = i18n.Admin.T(data.Lang, view.ExcerptPlaceholder)
}
if data.IsNew {
options := tplOptions(a.deps.Templates.All())
data.PostTemplates = options
data.TemplatesJSON = templatesJSON(options)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: "New post", IsLast: true, UI: true},
}
} else {
label := view.Title
if strings.TrimSpace(label) == "" {
label = i18n.Admin.T(data.Lang, "Untitled")
}
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: label, IsLast: true},
}
}
return data
}
func (a *Admin) handleNewForm(w http.ResponseWriter, r *http.Request) {
p := post.New(frontmatter.NewMeta(), "")
p.Metadata.Set("lang", a.deps.Config.Site.Language)
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, ""), http.StatusOK)
}
func (a *Admin) handleEditForm(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
http.NotFound(w, r)
return
}
a.renderPage(w, r, "form.html", a.editorData(r, "edit", existing, ""), http.StatusOK)
}
// formMap flattens the request form into a plain string map.
func formMap(r *http.Request) map[string]string {
out := map[string]string{}
for key, values := range r.PostForm {
if len(values) > 0 {
out[key] = values[0]
}
}
return out
}
func (a *Admin) handleCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
p, err := payloads.PostFromParams(formMap(r), nil)
if err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, err.Error()), http.StatusUnprocessableEntity)
return
}
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, err.Error()), http.StatusUnprocessableEntity)
return
}
saved, err := payloads.SavePost(a.deps.Store, p, nil)
if err != nil {
a.renderPage(w, r, "form.html",
a.editorData(r, "new", p, i18n.Admin.Tf(a.langFor(r), "The post could not be saved: %s", err.Error())), http.StatusInternalServerError)
return
}
a.fire("post.created", saved)
a.record(r, "post.created", saved.Slug(), nil)
http.Redirect(w, r, "/admin/?saved=created", http.StatusSeeOther)
}
func (a *Admin) handleUpdate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
http.NotFound(w, r)
return
}
p, err := payloads.PostFromParams(formMap(r), existing)
if err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "edit", p, err.Error()), http.StatusUnprocessableEntity)
return
}
if err := payloads.CreationError(p, a.deps.Store, existing); err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "edit", p, err.Error()), http.StatusUnprocessableEntity)
return
}
// SavePost moves the file when the slug changed, the same way the API
// does, so a rename behaves alike from either entry point.
saved, err := payloads.SavePost(a.deps.Store, p, existing)
if err != nil {
a.renderPage(w, r, "form.html",
a.editorData(r, "edit", p, i18n.Admin.Tf(a.langFor(r), "The post could not be saved: %s", err.Error())), http.StatusInternalServerError)
return
}
a.fire("post.updated", saved)
a.record(r, "post.updated", saved.Slug(), nil)
http.Redirect(w, r, "/admin/?saved=updated", http.StatusSeeOther)
}
func (a *Admin) handleDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
deleted, undoable, err := a.deps.Store.Delete(slug, "")
if err != nil {
a.renderPage(w, r, "list.html",
a.dashboardData(r, i18n.Admin.Tf(a.langFor(r), "The post could not be deleted: %s", err.Error())), http.StatusInternalServerError)
return
}
if deleted == nil {
http.Redirect(w, r, "/admin/?saved=not_found", http.StatusSeeOther)
return
}
// The payload names the post under "post" like every other post
// event, so a subscriber sees one shape whichever entry point fired.
a.fireRaw("post.deleted", map[string]any{
"post": map[string]any{"slug": deleted.Slug(), "title": deleted.Title()},
})
a.record(r, "post.deleted", deleted.Slug(), nil)
target := "/admin/?saved=deleted"
if undoable {
// Only offer Undo when a tombstone exists to undo.
target += "&undo=" + url.QueryEscape(slug)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (a *Admin) handleUndelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
if restored := a.deps.Store.Undelete(slug); restored != nil {
a.fire("post.created", restored)
a.record(r, "post.undeleted", slug, nil)
http.Redirect(w, r, "/admin/?saved=undone", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/?saved=undelete_failed", http.StatusSeeOther)
}
func (a *Admin) handleDuplicate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
source := a.deps.Store.Find(slug, "")
if source == nil {
http.NotFound(w, r)
return
}
newSlug := a.nextAvailableSlug(slug + "-copy")
meta := frontmatter.NewMeta()
for _, key := range source.Metadata.Keys() {
if key == "slug" || key == "date" {
continue
}
value, _ := source.Metadata.Get(key)
meta.Set(key, value)
}
meta.Set("slug", newSlug)
meta.Set("draft", true)
clone := post.New(meta, source.Body)
if err := payloads.CreationError(clone, a.deps.Store, nil); err != nil {
slog.Warn("admin: duplicate rejected", "slug", slug, "error", err)
http.Redirect(w, r, "/admin/?saved=duplicate_failed", http.StatusSeeOther)
return
}
if _, err := a.deps.Store.Save(clone); err != nil {
slog.Warn("admin: duplicate failed", "slug", slug, "error", err)
http.Redirect(w, r, "/admin/?saved=duplicate_failed", http.StatusSeeOther)
return
}
a.fire("post.created", clone)
http.Redirect(w, r, "/admin/posts/"+newSlug+"/edit?saved=duplicated", http.StatusSeeOther)
}
func (a *Admin) nextAvailableSlug(base string) string {
candidate := base
for n := 2; a.deps.Store.Find(candidate, "") != nil; n++ {
candidate = fmt.Sprintf("%s-%d", base, n)
}
return candidate
}
func (a *Admin) handleBulk(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
action := r.PostFormValue("action")
var slugs []string
for slug := range strings.SplitSeq(r.PostFormValue("slugs"), ",") {
if slug != "" {
slugs = append(slugs, slug)
}
}
if len(slugs) == 0 || (action != "delete" && action != "draft" && action != "publish") {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
affected := 0
for _, slug := range slugs {
switch action {
case "delete":
deleted, _, err := a.deps.Store.Delete(slug, "")
if err == nil && deleted != nil {
a.fireRaw("post.deleted", map[string]any{
"post": map[string]any{"slug": slug, "title": deleted.Title()},
})
affected++
}
case "draft":
if cached := a.deps.Store.Find(slug, ""); cached != nil && !cached.Draft() {
// Cached posts are shared with other requests: clone first.
p := cached.Clone()
p.Metadata.Set("draft", true)
if _, err := a.deps.Store.Save(p); err == nil {
a.fire("post.updated", p)
affected++
}
}
case "publish":
if cached := a.deps.Store.Find(slug, ""); cached != nil && cached.Draft() {
p := cached.Clone()
p.Metadata.Delete("draft")
if _, err := a.deps.Store.Save(p); err == nil {
a.fireRaw("post.published", map[string]any{"post": payloads.BuildSummary(p)})
affected++
}
}
}
}
if affected > 0 {
a.record(r, "post.bulk_"+action, "", map[string]any{"slugs": slugs, "affected": affected})
}
http.Redirect(w, r, fmt.Sprintf("/admin/?bulk=%s&n=%d", action, affected), http.StatusSeeOther)
}
func (a *Admin) handlePreview(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
htmlOut, err := markdown.Render(r.PostFormValue("body"))
if err != nil {
http.Error(w, "render failed", http.StatusInternalServerError)
return
}
// The preview body carries no frontmatter, so the reference list it
// knows about comes from the saved post under the same slug: the
// editor then sees the bibliography the published page will show,
// not the raw [[refs]] marker. A new post has no saved refs, and its
// marker paragraph stays as written.
slug := r.PostFormValue("slug")
lang := r.PostFormValue("lang")
if slug != "" {
if p := a.deps.Store.Find(slug, lang); p != nil {
if refs := p.RefsLinked(); len(refs) > 0 {
htmlOut = biblio.LinkCitations(htmlOut, refs)
htmlOut = biblio.Place(htmlOut, refs)
}
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, htmlOut)
}
// --- import, download, history ---------------------------------------------
// fire and fireRaw notify the optional webhook sink.
func (a *Admin) fire(event string, p *post.Post) {
a.fireRaw(event, map[string]any{"post": payloads.BuildSummary(p)})
}
func (a *Admin) fireRaw(event string, payload map[string]any) {
if a.deps.OnEvent != nil {
a.deps.OnEvent(event, payload)
}
}
// handlePreviewLink returns a shareable preview URL for a draft or
// scheduled post. The link is signed with the session key, so without
// one no link can be honoured and none is offered.
func (a *Admin) handlePreviewLink(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if a.deps.Store.Find(slug, "") == nil {
http.NotFound(w, r)
return
}
token := preview.Token(slug, a.deps.PreviewKey, time.Now())
if token == "" {
writeAdminJSONError(w, http.StatusConflict, "no_session_key",
"Preview links need a session key: set [admin].session_key or make the state directory writable.")
return
}
base := strings.TrimRight(a.deps.Config.Site.BaseURL, "/")
writeAdminJSON(w, http.StatusOK, map[string]any{
"url": fmt.Sprintf("%s/api/volumen/posts/%s?preview_token=%s", base, slug, token),
"token": token,
})
}