diff --git a/CLAUDE.md b/CLAUDE.md index b0131dc..001f760 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ 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 /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET /health`. +Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET`/`POST /manage/delete/{slug}` (confirm, then delete), `GET /health`. In production nginx sits in front: serves `public/` directly, proxies `/upload` and `/manage` to localhost, htpasswd on both. Note `/health` is **not** proxied — nginx only forwards `location /upload` and `location /manage`, so anything outside the box can only reach those two. @@ -30,7 +30,8 @@ In production nginx sits in front: serves `public/` directly, proxies `/upload` - An edit only rewrites the `.toml` sidecar. Any key other than `caption` and `date` in a hand-written sidecar is dropped, same as on upload. - Slugs from the URL are matched against the photos actually in `content/`, never joined into a path. - `sameOrigin` compares the `Origin` header against `-base-url`, because behind nginx the `Host` header is the upstream address. A wrong `-base-url` therefore breaks saving in browsers that send no `Sec-Fetch-Site`. -- Changing a date moves the photo to another day page, but the old `public/day//` is not removed — the generator never prunes `public/`. +- Delete is permanent and takes two steps: `GET /manage/delete/{slug}` only shows a confirmation page, the `POST` does the work. `Generator.Delete` removes the image and sidecar from `content/` first, then the four renditions and `public/photo//`, all under the build mutex. There is no trash folder — recovery means restoring from a backup. +- Listing pages are put right by the rebuild that follows, which also prunes what a delete or a date change leaves behind: `renderDayPages` drops `public/day//` for days without photos, and `prunePages` drops pager pages past the last one. Renditions of photos removed from `content/` by hand (not via `/manage`) are still **not** pruned. ## Stack @@ -58,7 +59,7 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first - **Timeline is the site root by default** (`/`, `/page/2/`, …). Photos grouped by calendar day: one ISO date heading (`2026-06-14`) + count per day, then the square grid. `groupByDay` collapses the newest-first list into consecutive same-day runs; `chunkDayGroups` packs whole days into pages targeting `pageSize`, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this). - **Grid** (`/grid/`, `/grid/page/2/`, …) is the flat square grid, linked from the profile header. - Each timeline date heading links to a **single-day page** (`/day//`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug. -- Which view owns the site root is set by `-default-view timeline|grid` (NixOS `services.photogallery.defaultView`). The other view moves under `//`: with `-default-view grid` the grid is `/` and the timeline is `/timeline/`. `viewBase` and `pageURL` in `generator.go` derive every page path and pager link from that, and the templates take the cross-links as `GridURL` / `TimelineURL` — never hardcode `/grid/` or `/`. On each build the directory named after the default view is removed, since it can only be a leftover from before a switch. Root-level `/page/N/` beyond the new page count is *not* pruned. +- Which view owns the site root is set by `-default-view timeline|grid` (NixOS `services.photogallery.defaultView`). The other view moves under `//`: with `-default-view grid` the grid is `/` and the timeline is `/timeline/`. `viewBase` and `pageURL` in `generator.go` derive every page path and pager link from that, and the templates take the cross-links as `GridURL` / `TimelineURL` — never hardcode `/grid/` or `/`. On each build the directory named after the default view is removed, since it can only be a leftover from before a switch. - Single photo page with sidebar and newer/older navigation ### Link previews @@ -175,8 +176,7 @@ nix build # succeeds - ActivityPub federation (planned later — start with RSS/Atom) - Multi-image upload -- Delete via UI (edit is done, see Managing photos) -- Pruning `public/` when a photo is deleted or its date changes +- Pruning renditions and photo pages of photos deleted from `content/` by hand (delete via `/manage` cleans up after itself) - Any auth beyond nginx htpasswd - 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/README.md b/README.md index 7ca9102..92fb3ca 100644 --- a/README.md +++ b/README.md @@ -141,12 +141,16 @@ curl -u user:password \ Or open `https://photos.yourdomain.tld/upload` in a browser. -## Editing a photo +## Editing and deleting photos Open `https://photos.yourdomain.tld/manage` in a browser. It lists every photo; *Edit* opens a form for the caption and date, and saving rewrites the photo's `.toml` sidecar, which triggers a rebuild. +*Delete* asks for confirmation, then removes the image, its sidecar and +everything generated from them. This is permanent — there is no trash folder, +so keep a backup of the content directory. + `/manage` sits behind the same htpasswd as `/upload`, and nothing on the public site links to it. If you run your own nginx instead of the NixOS module, add a `location /manage` block alongside the `/upload` one — without diff --git a/generator.go b/generator.go index 56347fc..542388d 100644 --- a/generator.go +++ b/generator.go @@ -199,6 +199,48 @@ func (g *Generator) Build() error { return nil } +// Delete removes a photo for good: its image and sidecar in the content +// directory, then everything generated from them. Pages that list the photo +// are put right by the next Build. It returns os.ErrNotExist when no photo +// has that slug. +func (g *Generator) Delete(slug string) error { + g.mu.Lock() + defer g.mu.Unlock() + + photos, err := g.loadPhotos() + if err != nil { + return err + } + + found := false + for _, p := range photos { + if p.Slug != slug { + continue + } + found = true + // The source goes first: if it cannot be removed, the photo stays + // fully intact rather than half deleted. + if err := os.Remove(filepath.Join(g.contentDir, filepath.Base(p.File))); err != nil { + return err + } + for _, rel := range []string{p.File, p.Thumb, p.Medium, p.OG} { + err := os.Remove(filepath.Join(g.outputDir, filepath.FromSlash(rel))) + if err != nil && !os.IsNotExist(err) { + return err + } + } + } + if !found { + return os.ErrNotExist + } + + // slug is safe to join from here on: it matched a directory entry. + if err := os.Remove(filepath.Join(g.contentDir, slug+".toml")); err != nil && !os.IsNotExist(err) { + return err + } + return os.RemoveAll(filepath.Join(g.outputDir, "photo", slug)) +} + // loadPhotos scans the content directory and returns photos sorted newest first. func (g *Generator) loadPhotos() ([]Photo, error) { entries, err := os.ReadDir(g.contentDir) @@ -441,10 +483,10 @@ func generateOG(src, dst string, maxDim int) error { return os.WriteFile(dst, buf.Bytes(), 0644) } -// renderGridPages writes the paginated square-grid view under public/grid/: -// grid/index.html and grid/page/N/index.html, chunking photos into pageSize -// slices for progressive pagination. The grid is the secondary view; the -// timeline is the site root. +// renderGridPages writes the paginated square-grid view: index.html and +// page/N/index.html under the grid's base (the site root when the grid is the +// default view, /grid/ otherwise), chunking photos into pageSize slices for +// progressive pagination. func (g *Generator) renderGridPages(photos []Photo) error { total := len(photos) totalPages := (total + g.pageSize - 1) / g.pageSize @@ -499,7 +541,7 @@ func (g *Generator) renderGridPages(photos []Photo) error { return err } } - return nil + return g.prunePages(base, totalPages) } // renderPhotoPage writes public/photo//index.html. @@ -571,8 +613,9 @@ func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup { return pages } -// renderTimelinePages writes the day-grouped timeline as the site root: -// index.html and page/N/index.html. Photos are grouped by day, then packed +// renderTimelinePages writes the day-grouped timeline: index.html and +// page/N/index.html under the timeline's base (the site root when the +// timeline is the default view). Photos are grouped by day, then packed // into pages of roughly pageSize without splitting a day across pages. func (g *Generator) renderTimelinePages(photos []Photo) error { pages := chunkDayGroups(groupByDay(photos), g.pageSize) @@ -619,13 +662,51 @@ func (g *Generator) renderTimelinePages(photos []Photo) error { return err } } - return nil + return g.prunePages(base, totalPages) +} + +// prunePages removes the pager pages of a view past its last one. They are +// left behind when the photo count shrinks or the views swap places, and +// would keep showing photos that are gone. +func (g *Generator) prunePages(base string, totalPages int) error { + // Page 1 is the view's own root, never a leftover: start past it even + // if a caller reports no pages at all. + for n := max(totalPages, 1) + 1; ; n++ { + dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, n))) + if _, err := os.Stat(dir); err != nil { + return nil + } + if err := os.RemoveAll(dir); err != nil { + return err + } + } } // renderDayPages writes one page per calendar day under public/day//, -// holding just that day's photos. Timeline date headings link here. +// holding just that day's photos. Timeline date headings link here. Pages of +// days that no longer have photos, after a delete or a date change, are +// removed. func (g *Generator) renderDayPages(photos []Photo) error { - for _, grp := range groupByDay(photos) { + groups := groupByDay(photos) + + live := make(map[string]bool, len(groups)) + for _, grp := range groups { + live[grp.day] = true + } + dayDir := filepath.Join(g.outputDir, "day") + entries, err := os.ReadDir(dayDir) + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir() && !live[e.Name()] { + if err := os.RemoveAll(filepath.Join(dayDir, e.Name())); err != nil { + return err + } + } + } + + for _, grp := range groups { dir := filepath.Join(g.outputDir, "day", grp.day) if err := os.MkdirAll(dir, 0755); err != nil { return err diff --git a/generator_test.go b/generator_test.go index 1f84efa..41e30c6 100644 --- a/generator_test.go +++ b/generator_test.go @@ -123,3 +123,36 @@ func TestSwitchingDefaultViewDropsOldViewDir(t *testing.T) { t.Errorf("site root is still the timeline") } } + +func TestShrinkingSiteDropsLeftoverPages(t *testing.T) { + output := t.TempDir() + buildSite(t, output, viewTimeline) // three photos, page size two + + content := t.TempDir() + var img bytes.Buffer + if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(content, "one.jpg"), img.Bytes()) + writeFile(t, filepath.Join(content, "one.toml"), + []byte("caption = \"one\"\ndate = \"2026-06-14\"\n")) + + // The same output directory, now fed a single photo: one page per view, + // one day. + g := NewGenerator(content, output, "https://photos.example.com", + "Ada", "", "", "", "", "Crimson Pro", "Manjari", 2, viewTimeline) + if err := g.Build(); err != nil { + t.Fatal(err) + } + + for _, gone := range []string{"page/2", "grid/page/2", "day/2026-06-10"} { + if _, err := os.Stat(filepath.Join(output, filepath.FromSlash(gone))); !os.IsNotExist(err) { + t.Errorf("leftover %s survived", gone) + } + } + for _, kept := range []string{"index.html", "grid/index.html", "day/2026-06-14/index.html"} { + if _, err := os.Stat(filepath.Join(output, filepath.FromSlash(kept))); err != nil { + t.Errorf("%s is missing: %v", kept, err) + } + } +} diff --git a/manage.go b/manage.go index f9d052a..1b42bcb 100644 --- a/manage.go +++ b/manage.go @@ -13,14 +13,14 @@ import ( "github.com/BurntSushi/toml" ) -// The /manage pages edit photos that are already published. Like /upload they -// carry no auth of their own: in production nginx puts them behind htpasswd, -// and nothing on the public site links to them. +// The /manage pages edit and delete photos that are already published. Like +// /upload they carry no auth of their own: in production nginx puts them +// behind htpasswd, and nothing on the public site links to them. // maxEditBody caps the edit form. It carries a caption and a date, nothing else. const maxEditBody = 64 << 10 -// handleManagePage lists every photo with a link to its edit form. +// handleManagePage lists every photo with links to edit and delete it. func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) { photos, err := s.generator.loadPhotos() if err != nil { @@ -38,6 +38,7 @@ func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) { } } } + data["Deleted"] = r.URL.Query().Get("deleted") s.renderManage(w, "manage.html", data) } @@ -111,6 +112,51 @@ func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/manage?saved="+url.QueryEscape(photo.Slug), http.StatusSeeOther) } +// handleDeletePage asks for confirmation before a photo is deleted. Deleting +// is a separate page rather than a button in the list so that it always takes +// two deliberate steps. +func (s *Server) handleDeletePage(w http.ResponseWriter, r *http.Request) { + photo, ok := s.findPhoto(r.PathValue("slug")) + if !ok { + http.NotFound(w, r) + return + } + data := s.managePageData() + data["Photo"] = photo + s.renderManage(w, "delete.html", data) +} + +// handleDelete removes a photo for good: image, sidecar and everything +// generated from them. There is no undo. +func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { + if !s.sameOrigin(r) { + http.Error(w, "cross-site request refused", http.StatusForbidden) + return + } + + photo, ok := s.findPhoto(r.PathValue("slug")) + if !ok { + http.NotFound(w, r) + return + } + + if err := s.generator.Delete(photo.Slug); err != nil { + log.Printf("delete %s: %v", photo.Slug, err) + http.Error(w, "could not delete photo", http.StatusInternalServerError) + return + } + log.Printf("deleted %s (%q)", photo.Slug, photo.Caption) + + // Build() is mutex-guarded so calling it here and from the watcher is safe. + go func() { + if err := s.generator.Build(); err != nil { + log.Printf("rebuild after delete: %v", err) + } + }() + + http.Redirect(w, r, "/manage?deleted="+url.QueryEscape(photo.Caption), http.StatusSeeOther) +} + // findPhoto looks slug up among the photos in the content directory. Matching // against real entries, rather than building a path from the URL, is what // keeps a crafted slug from reaching outside the content directory. @@ -130,8 +176,8 @@ func (s *Server) findPhoto(slug string) (Photo, bool) { // sameOrigin reports whether a form 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 submit the edit form -// on a logged-in user's behalf. +// host, so without this check any other website could submit the edit or +// delete form on a logged-in user's behalf. // // The Origin header is compared against -base-url as well as the Host header: // behind nginx the Host that reaches this server is the proxy's upstream diff --git a/manage_test.go b/manage_test.go index b94777e..8241d0a 100644 --- a/manage_test.go +++ b/manage_test.go @@ -193,3 +193,133 @@ func TestEditAcceptsOwnOrigin(t *testing.T) { }) } } + +// twoPhotoServer returns a built site with "sunrise" plus "dusk", which has a +// day of its own, so deleting it must also take that day's page away. +func twoPhotoServer(t *testing.T) *Server { + t.Helper() + s := newTestServer(t) + + var img bytes.Buffer + if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(s.contentDir, "dusk.jpg"), img.Bytes()) + writeFile(t, filepath.Join(s.contentDir, "dusk.toml"), + []byte("caption = \"Dusk\"\ndate = \"2026-07-01\"\n")) + + if err := s.generator.Build(); err != nil { + t.Fatal(err) + } + return s +} + +// photoFiles lists every file and directory that belongs to one photo. +func photoFiles(s *Server, slug, day string) []string { + return []string{ + filepath.Join(s.contentDir, slug+".jpg"), + filepath.Join(s.contentDir, slug+".toml"), + filepath.Join(s.outputDir, "images", slug+".jpg"), + filepath.Join(s.outputDir, "thumbs", slug+".jpg"), + filepath.Join(s.outputDir, "medium", slug+".jpg"), + filepath.Join(s.outputDir, "og", slug+".jpg"), + filepath.Join(s.outputDir, "photo", slug), + filepath.Join(s.outputDir, "day", day), + } +} + +func postDelete(s *Server, slug string, headers map[string]string) *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", "/manage/delete/"+slug, nil) + for k, v := range headers { + r.Header.Set(k, v) + } + r.SetPathValue("slug", slug) + w := httptest.NewRecorder() + s.handleDelete(w, r) + return w +} + +func TestDeletePageAsksFirst(t *testing.T) { + s := twoPhotoServer(t) + r := httptest.NewRequest("GET", "/manage/delete/dusk", nil) + r.SetPathValue("slug", "dusk") + w := httptest.NewRecorder() + s.handleDeletePage(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), `action="/manage/delete/dusk"`) { + t.Errorf("confirmation page has no delete form") + } + // Looking at the confirmation page must not delete anything. + for _, f := range photoFiles(s, "dusk", "2026-07-01") { + if _, err := os.Stat(f); err != nil { + t.Errorf("%s is gone after a GET: %v", f, err) + } + } +} + +func TestDeleteRemovesPhotoEverywhere(t *testing.T) { + s := twoPhotoServer(t) + w := postDelete(s, "dusk", nil) + + if w.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303: %s", w.Code, w.Body) + } + if loc := w.Header().Get("Location"); loc != "/manage?deleted=Dusk" { + t.Errorf("Location = %q", loc) + } + // The handler rebuilds in the background; build here to have a + // finished site to look at. + if err := s.generator.Build(); err != nil { + t.Fatal(err) + } + + for _, f := range photoFiles(s, "dusk", "2026-07-01") { + if _, err := os.Stat(f); !os.IsNotExist(err) { + t.Errorf("%s survived the delete", f) + } + } + for _, f := range photoFiles(s, "sunrise", "2026-06-14") { + if _, err := os.Stat(f); err != nil { + t.Errorf("the other photo lost %s: %v", f, err) + } + } + for _, page := range []string{"index.html", "grid/index.html", "feed.xml"} { + data, err := os.ReadFile(filepath.Join(s.outputDir, page)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "dusk") { + t.Errorf("%s still mentions the deleted photo", page) + } + } +} + +func TestDeleteRejectsBadInput(t *testing.T) { + tests := []struct { + name string + slug string + headers map[string]string + want int + }{ + {"unknown photo", "nope", nil, http.StatusNotFound}, + {"path traversal", "../dusk", nil, http.StatusNotFound}, + {"cross-site fetch", "dusk", map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden}, + {"foreign origin", "dusk", map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := twoPhotoServer(t) + if w := postDelete(s, tt.slug, tt.headers); w.Code != tt.want { + t.Errorf("status = %d, want %d", w.Code, tt.want) + } + for _, slug := range []string{"dusk", "sunrise"} { + if _, err := os.Stat(filepath.Join(s.contentDir, slug+".jpg")); err != nil { + t.Errorf("%s was deleted: %v", slug, err) + } + } + }) + } +} diff --git a/server.go b/server.go index 5caacce..4a50585 100644 --- a/server.go +++ b/server.go @@ -38,6 +38,8 @@ func (s *Server) Listen() error { mux.HandleFunc("GET /manage", s.handleManagePage) mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage) mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit) + mux.HandleFunc("GET /manage/delete/{slug}", s.handleDeletePage) + mux.HandleFunc("POST /manage/delete/{slug}", s.handleDelete) mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }) diff --git a/templates/delete.html b/templates/delete.html new file mode 100644 index 0000000..5247e57 --- /dev/null +++ b/templates/delete.html @@ -0,0 +1,107 @@ + + + + + + + Delete — {{.Photo.Caption}} + + + + + +
+ ← Manage +

Delete photo

+ + +

{{.Photo.Caption}}

+

{{.Photo.Date.Format "2006-01-02"}} · {{.Photo.Slug}}

+ +

This removes the photo, its caption and every + page and image generated from it. It cannot be undone.

+ +
+ +
+ Cancel +
+ + diff --git a/templates/manage.html b/templates/manage.html index e399ba2..a619e83 100644 --- a/templates/manage.html +++ b/templates/manage.html @@ -79,6 +79,7 @@ .mactions a { color: var(--fg3); text-decoration: none; } .mactions a:hover { color: var(--fg); } .mactions .medit { color: var(--aqua); } + .mactions .mdelete { color: #fb4934; } .mempty { color: var(--fg4); font-size: 15px; } @@ -89,6 +90,7 @@

{{len .Photos}} {{if eq (len .Photos) 1}}photo{{else}}photos{{end}}

{{if .Saved}}

✓ saved “{{.Saved}}”

{{end}} + {{if .Deleted}}

✓ deleted “{{.Deleted}}”

{{end}} {{if .Photos}}