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
+139
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func listTrash(t *testing.T, s *Server) []trashItem {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleListTrash(rec, httptest.NewRequest(http.MethodGet, "/api/trash", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list trash: code=%d", rec.Code)
|
||||
}
|
||||
var items []trashItem
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("decode trash: %v", err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func TestTrashListAndRestore(t *testing.T) {
|
||||
s, content, _ := newTestServer(t)
|
||||
|
||||
// Delete foo, then it should show up in the trash.
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleDelete(rec, form(url.Values{"slug": {"foo"}}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete: %d", rec.Code)
|
||||
}
|
||||
|
||||
items := listTrash(t, s)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("trash has %d items, want 1", len(items))
|
||||
}
|
||||
it := items[0]
|
||||
if it.Slug != "foo" || !it.HasMeta || it.Image == "" {
|
||||
t.Errorf("trash item wrong: %+v", it)
|
||||
}
|
||||
|
||||
// Restore it.
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleRestore(rec, form(url.Values{"id": {it.ID}}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("restore: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
for _, p := range []string{"foo.png", "foo.toml"} {
|
||||
if _, err := os.Stat(filepath.Join(content, p)); err != nil {
|
||||
t.Errorf("restored %s missing: %v", p, err)
|
||||
}
|
||||
}
|
||||
if items := listTrash(t, s); len(items) != 0 {
|
||||
t.Errorf("trash not empty after restore: %+v", items)
|
||||
}
|
||||
// Caption survived the round trip.
|
||||
if readMeta(t, content, "foo").Caption != "Foo" {
|
||||
t.Error("restored sidecar lost its caption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRefusesLiveCollision(t *testing.T) {
|
||||
s, content, _ := newTestServer(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleDelete(rec, form(url.Values{"slug": {"foo"}}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete: %d", rec.Code)
|
||||
}
|
||||
id := listTrash(t, s)[0].ID
|
||||
|
||||
// A new live post now occupies the slug.
|
||||
writePNG(t, filepath.Join(content, "foo.png"))
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleRestore(rec, form(url.Values{"id": {id}}))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("restore over live post: code=%d, want 409", rec.Code)
|
||||
}
|
||||
// The trashed copy is left intact for a manual decision.
|
||||
if len(listTrash(t, s)) != 1 {
|
||||
t.Error("trashed item should remain after refused restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyTrash(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
|
||||
// Trash both posts.
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleDelete(rec, form(url.Values{"slugs": {"foo,bar"}}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete: %d", rec.Code)
|
||||
}
|
||||
items := listTrash(t, s)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("trash has %d, want 2", len(items))
|
||||
}
|
||||
|
||||
// Empty one by id.
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleEmptyTrash(rec, form(url.Values{"id": {items[0].ID}}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("empty one: %d", rec.Code)
|
||||
}
|
||||
if len(listTrash(t, s)) != 1 {
|
||||
t.Error("expected 1 item left after deleting one")
|
||||
}
|
||||
|
||||
// Empty all.
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleEmptyTrash(rec, form(url.Values{"all": {"1"}}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("empty all: %d", rec.Code)
|
||||
}
|
||||
if len(listTrash(t, s)) != 0 {
|
||||
t.Error("trash not empty after all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTrashIDRejectsJunk(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"foo.20260102-150405": true,
|
||||
"foo.bar.20260102-150405": false, // slug "foo.bar" has a dot → rejected
|
||||
"../x.20260102-150405": false, // traversal
|
||||
"foo.2026": false, // malformed stamp
|
||||
"foo": false, // no stamp
|
||||
"": false,
|
||||
}
|
||||
for id, want := range cases {
|
||||
if _, _, ok := splitTrashID(id); ok != want {
|
||||
t.Errorf("splitTrashID(%q) = %v, want %v", id, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user