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.
286 lines
8.4 KiB
Go
286 lines
8.4 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"image"
|
|
"image/png"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
)
|
|
|
|
// newTestServer builds a Server over fresh temp content/output dirs with a
|
|
// couple of posts, runs one build, and returns it.
|
|
func newTestServer(t *testing.T) (*Server, string, string) {
|
|
t.Helper()
|
|
content := t.TempDir()
|
|
output := t.TempDir()
|
|
|
|
writePNG(t, filepath.Join(content, "foo.png"))
|
|
if err := os.WriteFile(filepath.Join(content, "foo.toml"),
|
|
[]byte("caption = \"Foo\"\ndate = \"2026-01-02\"\ntags = [\"berlin\", \"street\"]\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writePNG(t, filepath.Join(content, "bar.png")) // no sidecar
|
|
|
|
g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60)
|
|
if err := g.Build(); err != nil {
|
|
t.Fatalf("initial build: %v", err)
|
|
}
|
|
s := NewServer("0", content, output, false, g, nil)
|
|
// Make rebuilds a no-op; tests call Build explicitly when they need to
|
|
// assert on generated output, so no background goroutine races teardown.
|
|
s.rebuildHook = func() {}
|
|
return s, content, output
|
|
}
|
|
|
|
func writePNG(t *testing.T, path string) {
|
|
t.Helper()
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func form(v url.Values) *http.Request {
|
|
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(v.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
return r
|
|
}
|
|
|
|
func readMeta(t *testing.T, content, slug string) PhotoMeta {
|
|
t.Helper()
|
|
var m PhotoMeta
|
|
if _, err := toml.DecodeFile(filepath.Join(content, slug+".toml"), &m); err != nil {
|
|
t.Fatalf("decode %s: %v", slug, err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func TestResolveSlug(t *testing.T) {
|
|
s, _, _ := newTestServer(t)
|
|
cases := map[string]bool{
|
|
"foo": true,
|
|
"bar": true,
|
|
"nope": false,
|
|
"../server": false,
|
|
"foo/../foo": false,
|
|
".trash": false,
|
|
"": false,
|
|
"sub/foo": false,
|
|
}
|
|
for slug, want := range cases {
|
|
if _, ok := s.resolveSlug(slug); ok != want {
|
|
t.Errorf("resolveSlug(%q) = %v, want %v", slug, ok, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleEdit(t *testing.T) {
|
|
s, content, _ := newTestServer(t)
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.handleEdit(rec, form(url.Values{
|
|
"slug": {"foo"},
|
|
"caption": {"New caption"},
|
|
"date": {"2020-05-06"},
|
|
"tags": {"Paris, , night"},
|
|
}))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("edit: code=%d body=%s", rec.Code, rec.Body)
|
|
}
|
|
m := readMeta(t, content, "foo")
|
|
if m.Caption != "New caption" || m.Date != "2020-05-06" {
|
|
t.Errorf("caption/date not saved: %+v", m)
|
|
}
|
|
if strings.Join(m.Tags, ",") != "paris,night" { // lowercased, empty dropped
|
|
t.Errorf("tags = %v, want [paris night]", m.Tags)
|
|
}
|
|
|
|
// Bad date rejected.
|
|
rec = httptest.NewRecorder()
|
|
s.handleEdit(rec, form(url.Values{"slug": {"foo"}, "date": {"06-05-2020"}}))
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("bad date: code=%d, want 400", rec.Code)
|
|
}
|
|
|
|
// Unknown slug rejected (and cannot escape content dir).
|
|
rec = httptest.NewRecorder()
|
|
s.handleEdit(rec, form(url.Values{"slug": {"../evil"}, "date": {"2020-01-01"}}))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("unknown slug: code=%d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleBulkTags(t *testing.T) {
|
|
s, content, _ := newTestServer(t)
|
|
|
|
// Add to bar (no sidecar yet) and foo (already has tags).
|
|
rec := httptest.NewRecorder()
|
|
s.handleBulkTags(rec, form(url.Values{"slugs": {"foo,bar"}, "add": {"aqua, berlin"}}))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("bulk add: code=%d body=%s", rec.Code, rec.Body)
|
|
}
|
|
foo := readMeta(t, content, "foo")
|
|
// berlin already present → not duplicated; aqua appended.
|
|
if got := strings.Join(foo.Tags, ","); got != "berlin,street,aqua" {
|
|
t.Errorf("foo tags = %q, want berlin,street,aqua", got)
|
|
}
|
|
if got := strings.Join(readMeta(t, content, "bar").Tags, ","); got != "aqua,berlin" {
|
|
t.Errorf("bar tags = %q, want aqua,berlin", got)
|
|
}
|
|
|
|
// Remove across both.
|
|
rec = httptest.NewRecorder()
|
|
s.handleBulkTags(rec, form(url.Values{"slugs": {"foo,bar"}, "remove": {"berlin"}}))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("bulk remove: code=%d", rec.Code)
|
|
}
|
|
if got := strings.Join(readMeta(t, content, "foo").Tags, ","); got != "street,aqua" {
|
|
t.Errorf("foo tags after remove = %q, want street,aqua", got)
|
|
}
|
|
|
|
// Empty add+remove is a 400.
|
|
rec = httptest.NewRecorder()
|
|
s.handleBulkTags(rec, form(url.Values{"slugs": {"foo"}}))
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("empty bulk: code=%d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleDeleteMovesToTrashAndPrunes(t *testing.T) {
|
|
s, content, output := newTestServer(t)
|
|
|
|
// Pre-condition: foo's rendition and page exist.
|
|
for _, p := range []string{"thumbs/foo.jpg", "images/foo.png", "photo/foo/index.html"} {
|
|
if _, err := os.Stat(filepath.Join(output, p)); err != nil {
|
|
t.Fatalf("expected %s before delete: %v", p, err)
|
|
}
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.handleDelete(rec, form(url.Values{"slug": {"foo"}}))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("delete: code=%d", rec.Code)
|
|
}
|
|
var res map[string]int
|
|
json.Unmarshal(rec.Body.Bytes(), &res)
|
|
if res["deleted"] != 1 {
|
|
t.Errorf("deleted = %d, want 1", res["deleted"])
|
|
}
|
|
|
|
// Source files moved out of content/ ...
|
|
if _, err := os.Stat(filepath.Join(content, "foo.png")); !os.IsNotExist(err) {
|
|
t.Error("foo.png should be gone from content")
|
|
}
|
|
// ... and into .trash/ (timestamp-suffixed).
|
|
trash, _ := os.ReadDir(filepath.Join(content, ".trash"))
|
|
var png, tml bool
|
|
for _, e := range trash {
|
|
if strings.HasPrefix(e.Name(), "foo.") && strings.HasSuffix(e.Name(), ".png") {
|
|
png = true
|
|
}
|
|
if strings.HasPrefix(e.Name(), "foo.") && strings.HasSuffix(e.Name(), ".toml") {
|
|
tml = true
|
|
}
|
|
}
|
|
if !png || !tml {
|
|
t.Errorf("trash missing files: png=%v toml=%v entries=%v", png, tml, trash)
|
|
}
|
|
|
|
// The handler rebuilds asynchronously; run a deterministic build, then
|
|
// confirm the prune removed foo's output and kept bar's.
|
|
if err := s.generator.Build(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range []string{"thumbs/foo.jpg", "images/foo.png", "photo/foo"} {
|
|
if _, err := os.Stat(filepath.Join(output, p)); !os.IsNotExist(err) {
|
|
t.Errorf("%s should have been pruned", p)
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(output, "thumbs/bar.jpg")); err != nil {
|
|
t.Errorf("bar's thumb should survive: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCSRFGuard(t *testing.T) {
|
|
s, _, _ := newTestServer(t) // generator.baseURL == "http://x"
|
|
|
|
// A cross-site Origin is refused before any mutation runs.
|
|
r := form(url.Values{"slug": {"foo"}})
|
|
r.Header.Set("Origin", "http://evil.example")
|
|
rec := httptest.NewRecorder()
|
|
s.handleDelete(rec, r)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("cross-site Origin: code=%d, want 403", rec.Code)
|
|
}
|
|
|
|
// Sec-Fetch-Site: cross-site is likewise refused.
|
|
r = form(url.Values{"slug": {"foo"}})
|
|
r.Header.Set("Sec-Fetch-Site", "cross-site")
|
|
rec = httptest.NewRecorder()
|
|
s.handleDelete(rec, r)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("Sec-Fetch cross-site: code=%d, want 403", rec.Code)
|
|
}
|
|
|
|
// Same-origin is allowed through (no slugs → deletes nothing, 200).
|
|
r = form(url.Values{})
|
|
r.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
rec = httptest.NewRecorder()
|
|
s.handleDelete(rec, r)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("same-origin: code=%d, want 200", rec.Code)
|
|
}
|
|
|
|
// A matching Origin is allowed.
|
|
r = form(url.Values{})
|
|
r.Header.Set("Origin", "http://x")
|
|
rec = httptest.NewRecorder()
|
|
s.handleDelete(rec, r)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("matching Origin: code=%d, want 200", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleListPhotos(t *testing.T) {
|
|
s, _, _ := newTestServer(t)
|
|
rec := httptest.NewRecorder()
|
|
s.handleListPhotos(rec, httptest.NewRequest(http.MethodGet, "/api/photos", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("list: code=%d", rec.Code)
|
|
}
|
|
var photos []photoJSON
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &photos); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(photos) != 2 {
|
|
t.Fatalf("got %d photos, want 2", len(photos))
|
|
}
|
|
var foo *photoJSON
|
|
for i := range photos {
|
|
if photos[i].Slug == "foo" {
|
|
foo = &photos[i]
|
|
}
|
|
}
|
|
if foo == nil {
|
|
t.Fatal("foo not in list")
|
|
}
|
|
if foo.Thumb != "/thumbs/foo.jpg" || foo.URL != "/photo/foo/" {
|
|
t.Errorf("foo paths wrong: %+v", foo)
|
|
}
|
|
if strings.Join(foo.Tags, ",") != "berlin,street" {
|
|
t.Errorf("foo tags = %v", foo.Tags)
|
|
}
|
|
}
|