// Copyright (c) 2026 Petr BalvĂ­n (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, }) }