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.
140 lines
3.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|