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

+47
View File
@@ -123,12 +123,30 @@ func (g *Generator) Build() error {
return err
}
// Drop fully-derived HTML trees before regenerating them, so a deleted
// post, a retag or a changed date leaves no orphaned page behind. These are
// cheap to rewrite (no up-to-date skipping applies to HTML), unlike the
// image renditions, which are pruned by slug just below.
for _, d := range []string{"photo", "day", "album", "page"} {
if err := os.RemoveAll(filepath.Join(g.outputDir, d)); err != nil {
return err
}
}
if err := os.RemoveAll(filepath.Join(g.outputDir, "grid", "page")); err != nil {
return err
}
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "album", "albums", "static"} {
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err
}
}
// Remove rendition files whose source photo is gone (e.g. after a delete),
// keeping the expensive resize outputs of surviving photos in place so
// up-to-date skipping still works.
g.pruneRenditions(photos)
for i := range photos {
if err := g.processImage(&photos[i]); err != nil {
log.Printf("processImage %s: %v", photos[i].Slug, err)
@@ -231,6 +249,35 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
return photos, nil
}
// pruneRenditions deletes files in the image output directories that no longer
// have a backing photo in content/. Called on every build, it is what makes a
// delete (and the stale sidecar left by any removed source) actually disappear
// from public/ rather than linger as an orphan reachable by direct URL.
func (g *Generator) pruneRenditions(photos []Photo) {
valid := make(map[string]bool, len(photos))
for _, p := range photos {
valid[p.Slug] = true
}
for _, d := range []string{"images", "thumbs", "medium", "og"} {
full := filepath.Join(g.outputDir, d)
entries, err := os.ReadDir(full)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
slug := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
if !valid[slug] {
if err := os.Remove(filepath.Join(full, e.Name())); err != nil {
log.Printf("prune %s/%s: %v", d, e.Name(), err)
}
}
}
}
}
// processImage copies the original to public/images/ and writes a square
// thumbnail to public/thumbs/ plus an aspect-preserved medium image to
// public/medium/. Outputs are skipped when already newer than the source.