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:
1 parent
434d2437bf
commit
a7f2afc3ad
9 files changed
+1453
-7
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user