Add a /manage page to edit, delete and restore posts

A new auth-gated web UI at /manage lists every post for inline editing of
caption, date and tags, with per-row and bulk delete and bulk tag add/remove.
It is backed by a small JSON API (all behind the same htpasswd Basic auth as
/upload, and unlinked from the public site):

  GET  /api/photos        list posts (newest first)
  POST /api/edit          rewrite one sidecar (caption, date, tags)
  POST /api/tags          bulk add/remove tags across posts
  POST /api/delete        move posts to content/.trash/ (reversible)
  GET  /api/trash         list trashed items
  POST /api/restore       move a trashed item back (409 on live-slug clash)
  POST /api/trash/empty   hard delete trashed items (id… or all=1)

Design notes:
- Editing only rewrites the .toml; the slug (and URL) never changes.
- Delete is reversible: image + sidecar move to content/.trash/ with a
  timestamp suffix, excluded from the site (loadPhotos skips dirs). The only
  hard delete is emptying the trash.
- Build now prunes: it drops the derived HTML trees (photo/ day/ album/ page/
  grid/page/) and regenerates them, and pruneRenditions removes orphaned
  images/thumbs/medium/og. Without this a deleted post would linger at its old
  URL and on stale day/album pages; this also cleans up after a hand-removed
  sidecar.
- Sidecars are written atomically (temp + rename) so a concurrent build never
  observes a half-written file.
- Slugs and trash ids are validated as bare filenames, closing path traversal
  and dotfile access.
- Every state-changing POST passes a sameOrigin (CSRF) check, since Basic-auth
  credentials are replayed by the browser on every request to the host. It
  honours Sec-Fetch-Site, falls back to Origin vs base-url/Host, and allows
  header-less non-browser clients. Ported from the independent /manage on
  plex/trunk-ng.

module.nix proxies /manage and /api/ to the app (nginx otherwise serves them
as missing static files). The apps are unchanged; the API is there for them to
adopt edit/delete later.
This commit is contained in:
puttaalu committed 2026-10-18 21:52:11 +02:00
1 parent 434d2437bf
commit a7f2afc3ad
9 files changed
+1453 -7

No files matched your search

+206
View File
@@ -0,0 +1,206 @@
package main
import (
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// trashStamp is the timestamp moveToTrash appends: 20060102-150405.
var trashStamp = regexp.MustCompile(`^\d{8}-\d{6}$`)
// trashItem is one recoverable post in content/.trash/, grouping the image and
// its sidecar that were trashed together (same slug, same timestamp).
type trashItem struct {
ID string `json:"id"` // "<slug>.<stamp>", the restore/empty handle
Slug string `json:"slug"` // original slug it would restore to
DeletedAt string `json:"deletedAt"`
Image string `json:"image"` // trash filename of the image, "" if only a sidecar
HasMeta bool `json:"hasMeta"` // whether a sidecar is present
}
func (s *Server) trashDir() string { return filepath.Join(s.contentDir, ".trash") }
// parseTrashName splits a trash filename "<slug>.<stamp><ext>" into its parts.
// slug is a bare sanitised name (no dots) and stamp is a fixed timestamp, so
// the extension and the stamp peel off unambiguously from the right.
func parseTrashName(name string) (slug, stamp, ext string, ok bool) {
ext = filepath.Ext(name)
rest := strings.TrimSuffix(name, ext) // "<slug>.<stamp>"
i := strings.LastIndex(rest, ".")
if i < 0 {
return "", "", "", false
}
slug, stamp = rest[:i], rest[i+1:]
if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") {
return "", "", "", false
}
if !trashStamp.MatchString(stamp) {
return "", "", "", false
}
return slug, stamp, ext, true
}
func isImageExt(ext string) bool {
for _, e := range contentExts {
if ext == e {
return true
}
}
return false
}
// handleListTrash returns trashed items, newest first.
func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(s.trashDir())
if err != nil {
// No trash dir yet → empty list, not an error.
writeJSON(w, []trashItem{})
return
}
byID := make(map[string]*trashItem)
for _, e := range entries {
if e.IsDir() {
continue
}
slug, stamp, ext, ok := parseTrashName(e.Name())
if !ok {
continue
}
id := slug + "." + stamp
it := byID[id]
if it == nil {
when := stamp
if t, err := time.ParseInLocation("20060102-150405", stamp, time.Local); err == nil {
when = t.Format("2006-01-02 15:04")
}
it = &trashItem{ID: id, Slug: slug, DeletedAt: when}
byID[id] = it
}
if ext == ".toml" {
it.HasMeta = true
} else if isImageExt(ext) {
it.Image = e.Name()
}
}
items := make([]trashItem, 0, len(byID))
for _, it := range byID {
items = append(items, *it)
}
// Newest first: the stamp sorts lexically in time order, and it is the tail
// of the ID.
sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID })
writeJSON(w, items)
}
// splitTrashID validates a "<slug>.<stamp>" handle and returns its parts.
func splitTrashID(id string) (slug, stamp string, ok bool) {
i := strings.LastIndex(id, ".")
if i < 0 {
return "", "", false
}
slug, stamp = id[:i], id[i+1:]
if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") {
return "", "", false
}
if !trashStamp.MatchString(stamp) {
return "", "", false
}
return slug, stamp, true
}
// trashFiles returns the files in .trash belonging to one id (image + sidecar).
func (s *Server) trashFiles(slug, stamp string) []string {
var out []string
prefix := slug + "." + stamp
for _, ext := range append(append([]string{}, contentExts...), ".toml") {
p := filepath.Join(s.trashDir(), prefix+ext)
if _, err := os.Stat(p); err == nil {
out = append(out, p)
}
}
return out
}
// handleRestore moves a trashed post back into content/, stripping the
// timestamp. It refuses to overwrite a live post with the same slug.
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
if s.rejectCrossSite(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
slug, stamp, ok := splitTrashID(r.FormValue("id"))
if !ok {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
if _, live := s.resolveSlug(slug); live {
http.Error(w, "a post with that slug already exists", http.StatusConflict)
return
}
files := s.trashFiles(slug, stamp)
if len(files) == 0 {
http.Error(w, "not in trash", http.StatusNotFound)
return
}
for _, src := range files {
name := filepath.Base(src)
trashPrefix := slug + "." + stamp // "<slug>.<stamp>"
// "<slug>.<stamp>.png" → "<slug>.png"; "<slug>.<stamp>.toml" → "<slug>.toml"
dstName := slug + strings.TrimPrefix(name, trashPrefix)
if err := os.Rename(src, filepath.Join(s.contentDir, dstName)); err != nil {
log.Printf("restore %s: %v", name, err)
http.Error(w, "could not restore", http.StatusInternalServerError)
return
}
}
s.rebuild()
w.WriteHeader(http.StatusOK)
}
// handleEmptyTrash permanently deletes trashed items — specific ids (repeated
// `id` fields), or everything with `all=1`. This is the only hard delete.
func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) {
if s.rejectCrossSite(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if r.FormValue("all") == "1" {
if err := os.RemoveAll(s.trashDir()); err != nil {
log.Printf("empty trash: %v", err)
http.Error(w, "could not empty trash", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"emptied": "all"})
return
}
removed := 0
for _, id := range r.Form["id"] {
slug, stamp, ok := splitTrashID(id)
if !ok {
continue
}
for _, p := range s.trashFiles(slug, stamp) {
if err := os.Remove(p); err == nil {
removed++
}
}
}
writeJSON(w, map[string]int{"removed": removed})
}