diff --git a/CLAUDE.md b/CLAUDE.md index 8a1c76e..dad8823 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,10 +19,18 @@ The binary does three things at once: - Serves a multipart upload API at `POST /upload` - In dev mode (`--serve`), also serves `public/` as static files -Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`. -Both `/upload` routes are gated by HTTP Basic auth when `-htpasswd` is set (see Auth); `/health` is always open. +Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), +`GET /manage` (edit/delete UI), the manage API under `/api/` (see Manage), +`GET /health`. Every route except `/health` is gated by HTTP Basic auth when +`-htpasswd` is set (see Auth). -In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, terminates TLS. Auth is **no longer nginx's job** — the app checks credentials itself (`auth.go`), so the nginx vhost just proxies. Note `/health` is **not** proxied — nginx only forwards `location /upload`, so anything outside the box can only reach `/upload`. +In production nginx sits in front: serves `public/` directly, proxies the +dynamic endpoints (`/upload`, `/manage`, `/api/`) to localhost, terminates TLS. +Auth is **no longer nginx's job** — the app checks credentials itself +(`auth.go`), so the nginx vhost just proxies. `/health` is **not** proxied, so +only `/upload`, `/manage` and `/api/` are reachable from outside the box; +anything new the app serves needs a matching `location` in `module.nix` or +nginx will 404 it as a missing static file. ## Auth @@ -40,6 +48,44 @@ comparison (against a dummy hash) so timing can't enumerate usernames, and never touch it. Both apps (Android, Qt desktop) already send Basic auth, so no client change was needed. Deps: `golang.org/x/crypto/bcrypt`, `golang.org/x/time/rate`. +## Manage (edit / delete) + +`GET /manage` is an auth-gated web page (`templates/manage.html`) for editing +and deleting posts; it is **not linked from the public site** (reach it by URL). +Its API, all behind the same Basic auth and handled in `manage.go`: + +- `GET /api/photos` — every post as JSON (`slug, caption, date, tags, thumb, url`), newest first. +- `POST /api/edit` — rewrite one post's sidecar (`slug, caption, date, tags`). The slug (and URL) never changes; editing only touches the `.toml`. +- `POST /api/tags` — bulk add/remove tags across posts (`slugs` CSV + `add`/`remove` CSV), leaving each post's caption and date intact. +- `POST /api/delete` — move posts (`slug` repeated, or `slugs` CSV) to `content/.trash/` (timestamp-suffixed, reversible), **not** a hard delete. +- `GET /api/trash` — list trashed items (`id` = `.`, `slug`, `deletedAt`, `image`, `hasMeta`), newest first. +- `POST /api/restore` — move a trashed item (`id`) back to `content/`, stripping the timestamp. Refuses with `409` if a live post already holds that slug. +- `POST /api/trash/empty` — **hard delete** trashed items: specific `id`s (repeated), or everything with `all=1`. The only non-reversible delete in the system. + +The manage page has a Trash panel (header toggle) for restore / delete-forever / +empty. `trash.go` handles these; filenames parse as `.` (slug +is dot-free and the stamp is a fixed `20060102-150405`, so parts peel off +unambiguously), and `id`s are validated the same way to block traversal. + +Each non-trash mutation triggers the same async rebuild as upload. Sidecars are written +atomically (temp + rename) so a concurrent build never sees a half-written +file. Slugs are validated (`resolveSlug`: bare filename only, must name an +existing content image) which closes path traversal and dotfile access. + +Every state-changing POST (`edit`, `delete`, `tags`, `restore`, `trash/empty`) +first passes `sameOrigin` (`server.go`): because Basic-auth credentials are +replayed by the browser on every request to the host, a cross-site page could +otherwise drive a logged-in user into a delete (CSRF). The check honours +`Sec-Fetch-Site`, falls back to comparing `Origin` against `-base-url`/`Host`, +and allows header-less non-browser clients (curl). Cross-site → `403`. (Ported +from the independent `/manage` on `plex/trunk-ng`.) + +**Delete relies on `Build`'s prune step** (`generator.go`): a rebuild drops the +derived HTML trees (`photo/ day/ album/ page/ grid/page/`) and regenerates them, +and `pruneRenditions` removes `images|thumbs|medium|og` files with no backing +photo. Without this a deleted post would linger at its old URL and in stale +day/album pages. This also cleans up after a sidecar removed by hand. + ## Stack - **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`, `golang.org/x/crypto` (bcrypt), `golang.org/x/time` (rate) @@ -181,6 +227,9 @@ nix build # succeeds | `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` | | `server.go` | Upload API + optional static file serving | | `auth.go` | htpasswd (bcrypt) HTTP Basic auth + failed-attempt rate limit for `/upload` | +| `manage.go` | Edit/delete API (`/api/photos`, `/api/edit`, `/api/tags`, `/api/delete`) + trash move + atomic sidecar writes | +| `trash.go` | Trash API: list (`/api/trash`), `/api/restore`, `/api/trash/empty` (hard delete) | +| `templates/manage.html` | Auth-gated edit/delete web UI at `/manage`, with a Trash panel | | `watcher.go` | fsnotify watcher with 500ms debounce | | `assets.go` | `//go:embed` for templates and static | | `templates/timeline.html` | Site root: photos grouped by day, paginated | @@ -201,8 +250,7 @@ nix build # succeeds ## What's not done yet - ActivityPub federation (planned later — start with RSS/Atom) -- Multi-image upload -- Delete/edit via UI -- Any auth beyond the app's htpasswd Basic auth on `/upload` (no sessions, no per-user scopes) +- Edit/delete/trash in the Android and Qt apps (web `/manage` only so far; the `/api/` endpoints are there for the apps to adopt) +- Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes) - The Android app has never been run on real hardware - Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part diff --git a/generator.go b/generator.go index a739a0b..04c97d9 100644 --- a/generator.go +++ b/generator.go @@ -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. diff --git a/manage.go b/manage.go new file mode 100644 index 0000000..b5f3963 --- /dev/null +++ b/manage.go @@ -0,0 +1,318 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/BurntSushi/toml" +) + +// contentExts are the image extensions a post may use; the sidecar is always +// .toml alongside. +var contentExts = []string{".jpg", ".jpeg", ".png"} + +// photoJSON is one row of GET /api/photos, consumed by the manage page. +type photoJSON struct { + Slug string `json:"slug"` + Caption string `json:"caption"` + Date string `json:"date"` // YYYY-MM-DD + Tags []string `json:"tags"` + Thumb string `json:"thumb"` + URL string `json:"url"` +} + +// handleManagePage serves the (auth-gated) edit/delete UI. +func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) { + data, err := assets.ReadFile("templates/manage.html") + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) +} + +// handleListPhotos returns every post as JSON, newest first, for the manage UI. +func (s *Server) handleListPhotos(w http.ResponseWriter, r *http.Request) { + photos, err := s.generator.loadPhotos() + if err != nil { + http.Error(w, "could not list photos", http.StatusInternalServerError) + return + } + out := make([]photoJSON, 0, len(photos)) + for _, p := range photos { + tags := make([]string, 0, len(p.Tags)) + for _, t := range p.Tags { + tags = append(tags, t.Name) + } + out = append(out, photoJSON{ + Slug: p.Slug, + Caption: p.Caption, + Date: p.Date.Format("2006-01-02"), + Tags: tags, + Thumb: "/" + p.Thumb, + URL: "/photo/" + p.Slug + "/", + }) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(out) +} + +// handleEdit rewrites one post's sidecar (caption, date, tags). The slug — and +// therefore the URL — never changes. +func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) { + if s.rejectCrossSite(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + slug := r.FormValue("slug") + if _, ok := s.resolveSlug(slug); !ok { + http.Error(w, "unknown post", http.StatusNotFound) + return + } + + date := r.FormValue("date") + if date == "" { + date = time.Now().Format("2006-01-02") + } + if _, err := time.Parse("2006-01-02", date); err != nil { + http.Error(w, "date must be YYYY-MM-DD", http.StatusBadRequest) + return + } + + caption := r.FormValue("caption") + if caption == "" { + caption = slug + } + + var tags []string + for _, t := range parseTags(r.FormValue("tags")) { + tags = append(tags, t.Name) + } + + if err := s.writeSidecar(slug, PhotoMeta{Caption: caption, Date: date, Tags: tags}); err != nil { + log.Printf("edit %s: %v", slug, err) + http.Error(w, "could not save", http.StatusInternalServerError) + return + } + s.rebuild() + w.WriteHeader(http.StatusOK) +} + +// handleDelete moves one or more posts (image + sidecar) to content/.trash/. +// Slugs come as repeated `slug` fields or a comma-separated `slugs` field. +func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { + if s.rejectCrossSite(w, r) { + return + } + slugs, err := s.formSlugs(r) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + deleted := 0 + for _, slug := range slugs { + if _, ok := s.resolveSlug(slug); !ok { + continue + } + if err := s.moveToTrash(slug); err != nil { + log.Printf("delete %s: %v", slug, err) + continue + } + deleted++ + } + if deleted > 0 { + s.rebuild() + } + writeJSON(w, map[string]int{"deleted": deleted}) +} + +// handleBulkTags adds and/or removes tags across the given posts, leaving each +// post's caption and date untouched. `add` and `remove` are comma-separated. +func (s *Server) handleBulkTags(w http.ResponseWriter, r *http.Request) { + if s.rejectCrossSite(w, r) { + return + } + slugs, err := s.formSlugs(r) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + add := parseTags(r.FormValue("add")) + remove := make(map[string]bool) + for _, t := range parseTags(r.FormValue("remove")) { + remove[t.Name] = true + } + if len(add) == 0 && len(remove) == 0 { + http.Error(w, "nothing to add or remove", http.StatusBadRequest) + return + } + + updated := 0 + for _, slug := range slugs { + if _, ok := s.resolveSlug(slug); !ok { + continue + } + meta := s.readSidecar(slug) + + // Start from the current tags, append the additions, drop the removals, + // then let parseTags normalise and dedupe the result. + names := append([]string{}, meta.Tags...) + for _, t := range add { + names = append(names, t.Name) + } + var kept []string + for _, t := range parseTags(strings.Join(names, ",")) { + if !remove[t.Name] { + kept = append(kept, t.Name) + } + } + meta.Tags = kept + + if err := s.writeSidecar(slug, meta); err != nil { + log.Printf("bulk tags %s: %v", slug, err) + continue + } + updated++ + } + if updated > 0 { + s.rebuild() + } + writeJSON(w, map[string]int{"updated": updated}) +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +// resolveSlug reports whether slug names an existing post and returns the image +// path. It rejects anything that is not a bare, safe filename, closing path +// traversal and access to dotfiles such as the .trash directory. +func (s *Server) resolveSlug(slug string) (string, bool) { + if slug == "" || slug != filepath.Base(slug) || strings.HasPrefix(slug, ".") { + return "", false + } + for _, ext := range contentExts { + p := filepath.Join(s.contentDir, slug+ext) + if fi, err := os.Stat(p); err == nil && !fi.IsDir() { + return p, true + } + } + return "", false +} + +// formSlugs collects slugs from repeated `slug` fields and/or a comma-separated +// `slugs` field, de-duplicated. +func (s *Server) formSlugs(r *http.Request) ([]string, error) { + if err := r.ParseForm(); err != nil { + return nil, err + } + seen := make(map[string]bool) + var out []string + add := func(v string) { + v = strings.TrimSpace(v) + if v != "" && !seen[v] { + seen[v] = true + out = append(out, v) + } + } + for _, v := range r.Form["slug"] { + add(v) + } + for _, v := range strings.Split(r.FormValue("slugs"), ",") { + add(v) + } + return out, nil +} + +// readSidecar loads a post's existing metadata, falling back to the same +// defaults loadPhotos uses when the sidecar is missing or unreadable. +func (s *Server) readSidecar(slug string) PhotoMeta { + meta := PhotoMeta{Caption: slug, Date: time.Now().Format("2006-01-02")} + path := filepath.Join(s.contentDir, slug+".toml") + if _, err := os.Stat(path); err == nil { + if _, err := toml.DecodeFile(path, &meta); err != nil { + log.Printf("read sidecar %s: %v", path, err) + } + } + return meta +} + +// writeSidecar writes .toml atomically (temp file + rename) so a +// concurrent build can never observe a half-written sidecar. +func (s *Server) writeSidecar(slug string, meta PhotoMeta) error { + path := filepath.Join(s.contentDir, slug+".toml") + tmp, err := os.CreateTemp(s.contentDir, ".tmp-sidecar-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op once the rename below succeeds + + if err := toml.NewEncoder(tmp).Encode(meta); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// moveToTrash relocates a post's image and sidecar into content/.trash/, so a +// delete is reversible. A timestamp keeps re-deleted slugs from colliding. +func (s *Server) moveToTrash(slug string) error { + trash := filepath.Join(s.contentDir, ".trash") + if err := os.MkdirAll(trash, 0755); err != nil { + return err + } + stamp := time.Now().Format("20060102-150405") + + // Move every file the post owns: its image (whichever extension) and its + // sidecar. Missing pieces are not an error. + var names []string + for _, ext := range contentExts { + names = append(names, slug+ext) + } + names = append(names, slug+".toml") + + for _, name := range names { + src := filepath.Join(s.contentDir, name) + if _, err := os.Stat(src); err != nil { + continue + } + ext := filepath.Ext(name) + base := strings.TrimSuffix(name, ext) + dst := filepath.Join(trash, base+"."+stamp+ext) + if err := os.Rename(src, dst); err != nil { + return err + } + } + return nil +} + +// rebuild kicks off an asynchronous site rebuild, matching the upload handler. +// Build is mutex-guarded, so overlapping calls are safe. Tests may install a +// rebuildHook to make this deterministic. +func (s *Server) rebuild() { + if s.rebuildHook != nil { + s.rebuildHook() + return + } + go func() { + if err := s.generator.Build(); err != nil { + log.Printf("rebuild after manage op: %v", err) + } + }() +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} diff --git a/manage_test.go b/manage_test.go new file mode 100644 index 0000000..bfa1b16 --- /dev/null +++ b/manage_test.go @@ -0,0 +1,285 @@ +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) + } +} diff --git a/module.nix b/module.nix index 0df9354..dd5f855 100644 --- a/module.nix +++ b/module.nix @@ -194,11 +194,22 @@ in { }; # Auth is handled by the app (see services.photogallery.htpasswdFile), - # so nginx just proxies the upload endpoint. + # so nginx just proxies the dynamic endpoints: the upload form/API, + # the manage (edit/delete) page and its JSON API. Everything else is + # the static site served from root above. locations."/upload" = { proxyPass = "http://127.0.0.1:${toString cfg.port}"; extraConfig = "client_max_body_size 20M;"; }; + + locations."/manage" = { + proxyPass = "http://127.0.0.1:${toString cfg.port}"; + }; + + locations."/api/" = { + proxyPass = "http://127.0.0.1:${toString cfg.port}"; + extraConfig = "client_max_body_size 20M;"; + }; }; }; }) diff --git a/server.go b/server.go index 79ed9e7..d2f1eb1 100644 --- a/server.go +++ b/server.go @@ -5,6 +5,7 @@ import ( "io" "log" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -22,6 +23,11 @@ type Server struct { generator *Generator auth *Authenticator // nil disables auth (dev only) authLimit *rate.Limiter + + // rebuildHook overrides how a mutation triggers a rebuild. nil means the + // default asynchronous Build; tests set it to run synchronously (or not at + // all) so no background goroutine outlives the test. + rebuildHook func() } func NewServer(port, contentDir, outputDir string, serve bool, g *Generator, auth *Authenticator) *Server { @@ -47,10 +53,57 @@ func (s *Server) guard(h http.HandlerFunc) http.HandlerFunc { return s.auth.basicAuth(s.authLimit, h) } +// sameOrigin reports whether a form/API post came from the gallery's own pages. +// Basic-auth credentials are replayed by the browser on every request to the +// host, so without this check any other website could drive a logged-in user's +// browser into submitting an edit, delete or retag on their behalf (CSRF). +// +// Sec-Fetch-Site is preferred; where it is absent the Origin header is compared +// against -base-url and the request Host (behind nginx the Host reaching this +// server is the proxy upstream, not the public name). A request with neither +// header is a non-browser client such as curl, which cannot be driven +// cross-site, so it is allowed. +func (s *Server) sameOrigin(r *http.Request) bool { + if site := r.Header.Get("Sec-Fetch-Site"); site != "" { + return site == "same-origin" || site == "none" + } + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + if origin == s.generator.baseURL { + return true + } + u, err := url.Parse(origin) + return err == nil && u.Host == r.Host +} + +// rejectCrossSite writes a 403 and returns true when r is a cross-site request. +// State-changing handlers call it first: `if s.rejectCrossSite(w, r) { return }`. +func (s *Server) rejectCrossSite(w http.ResponseWriter, r *http.Request) bool { + if s.sameOrigin(r) { + return false + } + http.Error(w, "cross-site request refused", http.StatusForbidden) + return true +} + func (s *Server) Listen() error { mux := http.NewServeMux() mux.HandleFunc("GET /upload", s.guard(s.handleUploadPage)) mux.HandleFunc("POST /upload", s.guard(s.handleUpload)) + + // Edit/delete UI and its API. All auth-gated like /upload; destructive + // endpoints (delete) move posts to content/.trash/ rather than erasing. + mux.HandleFunc("GET /manage", s.guard(s.handleManagePage)) + mux.HandleFunc("GET /api/photos", s.guard(s.handleListPhotos)) + mux.HandleFunc("POST /api/edit", s.guard(s.handleEdit)) + mux.HandleFunc("POST /api/delete", s.guard(s.handleDelete)) + mux.HandleFunc("POST /api/tags", s.guard(s.handleBulkTags)) + mux.HandleFunc("GET /api/trash", s.guard(s.handleListTrash)) + mux.HandleFunc("POST /api/restore", s.guard(s.handleRestore)) + mux.HandleFunc("POST /api/trash/empty", s.guard(s.handleEmptyTrash)) + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }) diff --git a/templates/manage.html b/templates/manage.html new file mode 100644 index 0000000..976f746 --- /dev/null +++ b/templates/manage.html @@ -0,0 +1,339 @@ + + + + + + Manage — Photo Gallery + + + + + + + + +
+
+

Manage posts

+ + Upload   Trash   ← Gallery +
+
Loading…
+ +
+
+ Trash + + +
+
+ +
+ +
+ 0 selected + + + + + +
+ +
+ +
+ +
+ + + + diff --git a/trash.go b/trash.go new file mode 100644 index 0000000..db9e6c0 --- /dev/null +++ b/trash.go @@ -0,0 +1,206 @@ +package main + +import ( + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// trashStamp is the timestamp moveToTrash appends: 20060102-150405. +var trashStamp = regexp.MustCompile(`^\d{8}-\d{6}$`) + +// trashItem is one recoverable post in content/.trash/, grouping the image and +// its sidecar that were trashed together (same slug, same timestamp). +type trashItem struct { + ID string `json:"id"` // ".", the restore/empty handle + Slug string `json:"slug"` // original slug it would restore to + DeletedAt string `json:"deletedAt"` + Image string `json:"image"` // trash filename of the image, "" if only a sidecar + HasMeta bool `json:"hasMeta"` // whether a sidecar is present +} + +func (s *Server) trashDir() string { return filepath.Join(s.contentDir, ".trash") } + +// parseTrashName splits a trash filename "." into its parts. +// slug is a bare sanitised name (no dots) and stamp is a fixed timestamp, so +// the extension and the stamp peel off unambiguously from the right. +func parseTrashName(name string) (slug, stamp, ext string, ok bool) { + ext = filepath.Ext(name) + rest := strings.TrimSuffix(name, ext) // "." + i := strings.LastIndex(rest, ".") + if i < 0 { + return "", "", "", false + } + slug, stamp = rest[:i], rest[i+1:] + if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") { + return "", "", "", false + } + if !trashStamp.MatchString(stamp) { + return "", "", "", false + } + return slug, stamp, ext, true +} + +func isImageExt(ext string) bool { + for _, e := range contentExts { + if ext == e { + return true + } + } + return false +} + +// handleListTrash returns trashed items, newest first. +func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) { + entries, err := os.ReadDir(s.trashDir()) + if err != nil { + // No trash dir yet → empty list, not an error. + writeJSON(w, []trashItem{}) + return + } + + byID := make(map[string]*trashItem) + for _, e := range entries { + if e.IsDir() { + continue + } + slug, stamp, ext, ok := parseTrashName(e.Name()) + if !ok { + continue + } + id := slug + "." + stamp + it := byID[id] + if it == nil { + when := stamp + if t, err := time.ParseInLocation("20060102-150405", stamp, time.Local); err == nil { + when = t.Format("2006-01-02 15:04") + } + it = &trashItem{ID: id, Slug: slug, DeletedAt: when} + byID[id] = it + } + if ext == ".toml" { + it.HasMeta = true + } else if isImageExt(ext) { + it.Image = e.Name() + } + } + + items := make([]trashItem, 0, len(byID)) + for _, it := range byID { + items = append(items, *it) + } + // Newest first: the stamp sorts lexically in time order, and it is the tail + // of the ID. + sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID }) + writeJSON(w, items) +} + +// splitTrashID validates a "." handle and returns its parts. +func splitTrashID(id string) (slug, stamp string, ok bool) { + i := strings.LastIndex(id, ".") + if i < 0 { + return "", "", false + } + slug, stamp = id[:i], id[i+1:] + if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") { + return "", "", false + } + if !trashStamp.MatchString(stamp) { + return "", "", false + } + return slug, stamp, true +} + +// trashFiles returns the files in .trash belonging to one id (image + sidecar). +func (s *Server) trashFiles(slug, stamp string) []string { + var out []string + prefix := slug + "." + stamp + for _, ext := range append(append([]string{}, contentExts...), ".toml") { + p := filepath.Join(s.trashDir(), prefix+ext) + if _, err := os.Stat(p); err == nil { + out = append(out, p) + } + } + return out +} + +// handleRestore moves a trashed post back into content/, stripping the +// timestamp. It refuses to overwrite a live post with the same slug. +func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { + if s.rejectCrossSite(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + slug, stamp, ok := splitTrashID(r.FormValue("id")) + if !ok { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + if _, live := s.resolveSlug(slug); live { + http.Error(w, "a post with that slug already exists", http.StatusConflict) + return + } + + files := s.trashFiles(slug, stamp) + if len(files) == 0 { + http.Error(w, "not in trash", http.StatusNotFound) + return + } + for _, src := range files { + name := filepath.Base(src) + trashPrefix := slug + "." + stamp // "." + // "..png" → ".png"; "..toml" → ".toml" + dstName := slug + strings.TrimPrefix(name, trashPrefix) + if err := os.Rename(src, filepath.Join(s.contentDir, dstName)); err != nil { + log.Printf("restore %s: %v", name, err) + http.Error(w, "could not restore", http.StatusInternalServerError) + return + } + } + s.rebuild() + w.WriteHeader(http.StatusOK) +} + +// handleEmptyTrash permanently deletes trashed items — specific ids (repeated +// `id` fields), or everything with `all=1`. This is the only hard delete. +func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) { + if s.rejectCrossSite(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + if r.FormValue("all") == "1" { + if err := os.RemoveAll(s.trashDir()); err != nil { + log.Printf("empty trash: %v", err) + http.Error(w, "could not empty trash", http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"emptied": "all"}) + return + } + + removed := 0 + for _, id := range r.Form["id"] { + slug, stamp, ok := splitTrashID(id) + if !ok { + continue + } + for _, p := range s.trashFiles(slug, stamp) { + if err := os.Remove(p); err == nil { + removed++ + } + } + } + writeJSON(w, map[string]int{"removed": removed}) +} diff --git a/trash_test.go b/trash_test.go new file mode 100644 index 0000000..51a7888 --- /dev/null +++ b/trash_test.go @@ -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) + } + } +}