Files
photog/trash_test.go
puttaalu a7f2afc3ad 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.
2026-10-18 21:52:11 +02:00

140 lines
3.7 KiB
Go

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)
}
}
}