Initial commit
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
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
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,642 @@
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user