From 3f719359ded2e00bbd8f12343c70d72f22bec8a9 Mon Sep 17 00:00:00 2001 From: Santhosh Raju Date: Wed, 30 Sep 2026 08:17:17 +0200 Subject: [PATCH] feat(manage): add /manage pages to edit and delete photos, behind -manage /manage lists every photo; /manage/edit/{slug} rewrites the photo's .toml sidecar (caption, date), /manage/delete/{slug} asks for confirmation and then removes the image, its sidecar and everything generated from them. Builds now drop day pages of days without photos and pager pages past the last one, so a delete or a date change leaves nothing behind. The pages are off by default: the routes are only registered with -manage, so an install that does not opt in has no such code path and answers 404. NixOS gets services.photogallery.manage.enable, which also adds the nginx location only when on. Like /upload, the pages carry no auth of their own and are not linked from the public site. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 21 ++- README.md | 23 ++- generator.go | 101 ++++++++++-- generator_test.go | 33 ++++ main.go | 3 +- manage.go | 224 ++++++++++++++++++++++++++ manage_test.go | 355 ++++++++++++++++++++++++++++++++++++++++++ module.nix | 14 +- server.go | 21 ++- templates/delete.html | 107 +++++++++++++ templates/edit.html | 110 +++++++++++++ templates/manage.html | 117 ++++++++++++++ 12 files changed, 1108 insertions(+), 21 deletions(-) create mode 100644 manage.go create mode 100644 manage_test.go create mode 100644 templates/delete.html create mode 100644 templates/edit.html create mode 100644 templates/manage.html diff --git a/CLAUDE.md b/CLAUDE.md index 4ffb2e4..c23a045 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,9 +19,19 @@ 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`. +Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`, and with `-manage`: `GET /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET`/`POST /manage/delete/{slug}` (confirm, then delete). -In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, htpasswd on the upload endpoint. 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 `/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. + +### Managing photos + +`manage.go` holds the `/manage` handlers, kept apart from the upload code in `server.go`. They are only registered when the binary runs with `-manage` (NixOS `services.photogallery.manage.enable`, which also adds the nginx location); without it the paths 404 and there is no edit or delete code path at all. The pages are rendered on request from the sidecars (not generated into `public/`), and nothing on the public site links to them. Constraints worth knowing before touching it: + +- 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`. +- 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 @@ -49,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 @@ -144,12 +154,15 @@ nix build # succeeds | `main.go` | Flags, wires generator + watcher + server | | `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums | | `server.go` | Upload API + optional static file serving | +| `manage.go` | `/manage` list and edit handlers | | `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 | | `templates/grid.html` | Flat square grid at `/grid/`, paginated | | `templates/day.html` | Single-day page at `/day//` | | `templates/photo.html` | Single photo page, neighbour links and prefetch | +| `templates/manage.html` | `/manage`: list of photos with edit links | +| `templates/edit.html` | `/manage/edit/{slug}`: caption and date form | | `static/photo-nav.js` | Arrow-key and swipe navigation | | `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) | | `flake.nix` | `buildGoModule` package + devShell + nixosModules | @@ -163,7 +176,7 @@ nix build # succeeds - ActivityPub federation (planned later — start with RSS/Atom) - Multi-image upload -- Delete/edit via UI +- 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 35f85e5..53473c7 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,9 @@ services.photogallery = { serifFamily = "EB Garamond"; mlFamily = "Manjari"; + # Optional: edit and delete published photos at /manage (off by default) + manage.enable = true; + # Optional: built-in nginx vhost nginx = { enable = true; @@ -103,9 +106,10 @@ nix-shell -p apacheHttpd --run \ | `mlFamily` | `Manjari` | CSS font-family for the alt-script bio | | `pageSize` | `60` | Photos per page on the timeline and grid views | | `defaultView` | `timeline` | View at the site root, `timeline` or `grid`; the other is served under `/timeline/` or `/grid/` | +| `manage.enable` | `false` | Serve the `/manage` pages (edit, delete) and, with `nginx.enable`, protect them with the same htpasswd | | `nginx.enable` | `false` | Configure an nginx vhost | | `nginx.domain` | — | Domain for the vhost | -| `nginx.htpasswdFile` | — | htpasswd file for `/upload` | +| `nginx.htpasswdFile` | — | htpasswd file for `/upload` and `/manage` | | `nginx.enableACME` | `true` | Enable Let's Encrypt | --- @@ -140,3 +144,20 @@ curl -u user:password \ ``` Or open `https://photos.yourdomain.tld/upload` in a browser. + +## Editing and deleting photos + +The pages are off by default: start the binary with `-manage` (NixOS: +`services.photogallery.manage.enable = true`). Then 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 +it the path is not proxied and returns 404. diff --git a/generator.go b/generator.go index b101846..9945b0c 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 00ea7ff..f483f07 100644 --- a/generator_test.go +++ b/generator_test.go @@ -130,3 +130,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/main.go b/main.go index afc0ff9..af873c5 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ func main() { serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text") mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line") pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)") + manage := flag.Bool("manage", false, "serve the /manage pages for editing and deleting published photos (off by default; put them behind auth in nginx)") defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other one is published under //") flag.Parse() @@ -46,6 +47,6 @@ func main() { defer w.Close() go w.Watch() - s := NewServer(*port, *contentDir, *outputDir, *serve, g) + s := NewServer(*port, *contentDir, *outputDir, *serve, *manage, g) log.Fatal(s.Listen()) } diff --git a/manage.go b/manage.go new file mode 100644 index 0000000..1b42bcb --- /dev/null +++ b/manage.go @@ -0,0 +1,224 @@ +package main + +import ( + "bytes" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/BurntSushi/toml" +) + +// 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 links to edit and delete it. +func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) { + photos, err := s.generator.loadPhotos() + if err != nil { + log.Printf("manage: %v", err) + http.Error(w, "could not read photos", http.StatusInternalServerError) + return + } + + data := s.managePageData() + data["Photos"] = photos + if saved := r.URL.Query().Get("saved"); saved != "" { + for _, p := range photos { + if p.Slug == saved { + data["Saved"] = p.Caption + } + } + } + data["Deleted"] = r.URL.Query().Get("deleted") + s.renderManage(w, "manage.html", data) +} + +// handleEditPage shows the edit form for one photo, prefilled from its sidecar. +func (s *Server) handleEditPage(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, "edit.html", data) +} + +// handleEdit accepts the edit form with fields: +// +// caption — human-readable caption (empty falls back to the slug, as on upload) +// date — YYYY-MM-DD +// +// It rewrites the photo's .toml sidecar; the image itself is never touched. +func (s *Server) handleEdit(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 + } + + r.Body = http.MaxBytesReader(w, r.Body, maxEditBody) + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + caption := strings.TrimSpace(r.PostFormValue("caption")) + if caption == "" { + caption = photo.Slug + } + dateStr := r.PostFormValue("date") + if _, err := time.Parse("2006-01-02", dateStr); err != nil { + http.Error(w, "date must be YYYY-MM-DD", http.StatusBadRequest) + return + } + + // Encode first and write in one go, so a rebuild racing this request + // never reads a half-written sidecar. + var buf bytes.Buffer + if err := toml.NewEncoder(&buf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil { + http.Error(w, "could not write metadata", http.StatusInternalServerError) + return + } + tomlPath := filepath.Join(s.contentDir, photo.Slug+".toml") + if err := os.WriteFile(tomlPath, buf.Bytes(), 0644); err != nil { + log.Printf("write %s: %v", tomlPath, err) + http.Error(w, "could not save metadata", http.StatusInternalServerError) + return + } + + // 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 edit: %v", err) + } + }() + + 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. +func (s *Server) findPhoto(slug string) (Photo, bool) { + photos, err := s.generator.loadPhotos() + if err != nil { + log.Printf("manage: %v", err) + return Photo{}, false + } + for _, p := range photos { + if p.Slug == slug { + return p, true + } + } + return Photo{}, false +} + +// 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 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 +// address, not the public name. Requests with neither header come from +// non-browser clients such as curl, which cannot be driven cross-site. +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 +} + +// managePageData returns the template fields shared by the manage pages. +func (s *Server) managePageData() map[string]any { + g := s.generator + return map[string]any{ + "FontsURL": g.fontsURL, + "SerifFamily": g.serifFamily, + "MlFamily": g.mlFamily, + } +} + +// renderManage renders into a buffer first so a template error becomes a +// clean 500 instead of a truncated page. +func (s *Server) renderManage(w http.ResponseWriter, tmpl string, data map[string]any) { + var buf bytes.Buffer + if err := s.generator.tmpl.ExecuteTemplate(&buf, tmpl, data); err != nil { + log.Printf("render %s: %v", tmpl, err) + http.Error(w, "could not render page", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Always reflect the sidecars as they are now, never a cached copy. + w.Header().Set("Cache-Control", "no-store") + buf.WriteTo(w) +} diff --git a/manage_test.go b/manage_test.go new file mode 100644 index 0000000..8166cbd --- /dev/null +++ b/manage_test.go @@ -0,0 +1,355 @@ +package main + +import ( + "bytes" + "image" + "image/jpeg" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/BurntSushi/toml" +) + +// newTestServer returns a server over a content directory holding one photo, +// "sunrise", with a sidecar. +func newTestServer(t *testing.T) *Server { + t.Helper() + + // Not t.TempDir(): an edit kicks off a background rebuild that can still + // be writing when the test ends, and TempDir fails the test if its + // cleanup finds the directory busy. + root, err := os.MkdirTemp("", "photog-test-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(root) }) + + content := filepath.Join(root, "content") + output := filepath.Join(root, "public") + for _, d := range []string{content, output} { + if err := os.MkdirAll(d, 0755); err != nil { + t.Fatal(err) + } + } + + 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, "sunrise.jpg"), img.Bytes()) + writeFile(t, filepath.Join(content, "sunrise.toml"), + []byte("caption = \"Tempelhof at sunrise\"\ndate = \"2026-06-14\"\n")) + + g := NewGenerator(content, output, "https://photos.example.com", + "Ada", "", "", "", "", "Crimson Pro", "Manjari", 60, viewTimeline) + return NewServer("0", content, output, false, true, g) +} + +func readMeta(t *testing.T, s *Server, slug string) PhotoMeta { + t.Helper() + var meta PhotoMeta + if _, err := toml.DecodeFile(filepath.Join(s.contentDir, slug+".toml"), &meta); err != nil { + t.Fatal(err) + } + return meta +} + +// postEdit submits the edit form for slug and returns the response. +func postEdit(s *Server, slug string, form url.Values, headers map[string]string) *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", "/manage/edit/"+slug, strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for k, v := range headers { + r.Header.Set(k, v) + } + r.SetPathValue("slug", slug) + w := httptest.NewRecorder() + s.handleEdit(w, r) + return w +} + +func TestManagePageListsPhotos(t *testing.T) { + s := newTestServer(t) + w := httptest.NewRecorder() + s.handleManagePage(w, httptest.NewRequest("GET", "/manage", nil)) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + body := w.Body.String() + for _, want := range []string{"Tempelhof at sunrise", "2026-06-14", `href="/manage/edit/sunrise"`} { + if !strings.Contains(body, want) { + t.Errorf("manage page is missing %q", want) + } + } +} + +func TestEditPagePrefillsForm(t *testing.T) { + s := newTestServer(t) + r := httptest.NewRequest("GET", "/manage/edit/sunrise", nil) + r.SetPathValue("slug", "sunrise") + w := httptest.NewRecorder() + s.handleEditPage(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + body := w.Body.String() + for _, want := range []string{`value="Tempelhof at sunrise"`, `value="2026-06-14"`} { + if !strings.Contains(body, want) { + t.Errorf("edit page is missing %q", want) + } + } +} + +func TestEditRewritesSidecar(t *testing.T) { + s := newTestServer(t) + form := url.Values{"caption": {`Tempelhof, "golden hour"`}, "date": {"2026-06-15"}} + w := postEdit(s, "sunrise", form, 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?saved=sunrise" { + t.Errorf("Location = %q", loc) + } + meta := readMeta(t, s, "sunrise") + if meta.Caption != `Tempelhof, "golden hour"` || meta.Date != "2026-06-15" { + t.Errorf("sidecar = %+v", meta) + } +} + +func TestEditEmptyCaptionFallsBackToSlug(t *testing.T) { + s := newTestServer(t) + w := postEdit(s, "sunrise", url.Values{"caption": {" "}, "date": {"2026-06-14"}}, nil) + + if w.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", w.Code) + } + if meta := readMeta(t, s, "sunrise"); meta.Caption != "sunrise" { + t.Errorf("caption = %q, want the slug", meta.Caption) + } +} + +func TestEditRejectsBadInput(t *testing.T) { + valid := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}} + tests := []struct { + name string + slug string + form url.Values + headers map[string]string + want int + }{ + {"unknown photo", "nope", valid, nil, http.StatusNotFound}, + {"path traversal", "../sunrise", valid, nil, http.StatusNotFound}, + {"malformed date", "sunrise", url.Values{"caption": {"changed"}, "date": {"15.06.2026"}}, nil, http.StatusBadRequest}, + {"missing date", "sunrise", url.Values{"caption": {"changed"}}, nil, http.StatusBadRequest}, + {"cross-site fetch", "sunrise", valid, map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden}, + {"foreign origin", "sunrise", valid, map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newTestServer(t) + w := postEdit(s, tt.slug, tt.form, tt.headers) + if w.Code != tt.want { + t.Errorf("status = %d, want %d", w.Code, tt.want) + } + if meta := readMeta(t, s, "sunrise"); meta.Caption != "Tempelhof at sunrise" || meta.Date != "2026-06-14" { + t.Errorf("sidecar was changed: %+v", meta) + } + }) + } +} + +func TestEditAcceptsOwnOrigin(t *testing.T) { + form := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}} + tests := []struct { + name string + headers map[string]string + }{ + {"same-origin fetch metadata", map[string]string{"Sec-Fetch-Site": "same-origin"}}, + // Behind nginx the Host header is the upstream address, so only + // -base-url can vouch for the public origin. + {"origin equal to base URL", map[string]string{"Origin": "https://photos.example.com"}}, + {"origin equal to host", map[string]string{"Origin": "http://example.com"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newTestServer(t) + if w := postEdit(s, "sunrise", form, tt.headers); w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body) + } + }) + } +} + +// 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) + } + } + }) + } +} + +func TestManageRoutesNeedTheFlag(t *testing.T) { + for _, manage := range []bool{false, true} { + s := newTestServer(t) + s.manage = manage + mux := s.routes() + + want := http.StatusNotFound + if manage { + want = http.StatusOK + } + for _, path := range []string{"/manage", "/manage/edit/sunrise", "/manage/delete/sunrise"} { + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != want { + t.Errorf("manage=%v: GET %s = %d, want %d", manage, path, w.Code, want) + } + } + // The delete POST only does something with the flag: without it the + // photo must survive, with it (no Origin header, as from curl) it goes. + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest("POST", "/manage/delete/sunrise", nil)) + _, err := os.Stat(filepath.Join(s.contentDir, "sunrise.jpg")) + present := err == nil + if present == manage { + t.Errorf("manage=%v: POST delete = %d, photo present afterwards = %v", manage, w.Code, present) + } + // /upload and /health are there regardless. + for _, path := range []string{"/upload", "/health"} { + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != http.StatusOK { + t.Errorf("manage=%v: GET %s = %d, want 200", manage, path, w.Code) + } + } + } +} diff --git a/module.nix b/module.nix index 260b2f9..4e71c2d 100644 --- a/module.nix +++ b/module.nix @@ -88,6 +88,8 @@ in { description = "View published at the site root. The other one is published under /timeline/ or /grid/."; }; + manage.enable = lib.mkEnableOption "the /manage pages for editing and deleting published photos (served behind the same htpasswd as /upload)"; + nginx = { enable = lib.mkEnableOption "nginx virtual host for photogallery"; @@ -100,7 +102,7 @@ in { htpasswdFile = lib.mkOption { type = lib.types.path; example = "/etc/nginx/.htpasswd-gallery"; - description = "Path to htpasswd file protecting the /upload endpoint."; + description = "Path to htpasswd file protecting the /upload and /manage endpoints."; }; enableACME = lib.mkOption { @@ -140,7 +142,7 @@ in { User = "photogallery"; Group = "photogallery"; - ExecStart = lib.escapeShellArgs [ + ExecStart = lib.escapeShellArgs ([ "${pkg}/bin/photogallery" "--content" cfg.contentDir "--output" cfg.outputDir @@ -155,7 +157,7 @@ in { "--ml-family" cfg.mlFamily "--page-size" (toString cfg.pageSize) "--default-view" cfg.defaultView - ]; + ] ++ lib.optional cfg.manage.enable "--manage"); Restart = "on-failure"; RestartSec = "5s"; @@ -191,6 +193,12 @@ in { basicAuthFile = cfg.nginx.htpasswdFile; extraConfig = "client_max_body_size 20M;"; }; + + locations."/manage" = lib.mkIf cfg.manage.enable { + proxyPass = "http://127.0.0.1:${toString cfg.port}"; + basicAuth = "gallery"; + basicAuthFile = cfg.nginx.htpasswdFile; + }; }; }; }) diff --git a/server.go b/server.go index 6e6138c..c6bccca 100644 --- a/server.go +++ b/server.go @@ -18,26 +18,43 @@ type Server struct { contentDir string outputDir string serve bool + manage bool generator *Generator } -func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Server { +func NewServer(port, contentDir, outputDir string, serve, manage bool, g *Generator) *Server { return &Server{ port: port, contentDir: contentDir, outputDir: outputDir, serve: serve, + manage: manage, generator: g, } } -func (s *Server) Listen() error { +// routes builds the handler. The /manage pages are only registered with +// -manage, so a service without the flag has no edit or delete code path at +// all and answers 404 there. +func (s *Server) routes() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("GET /upload", s.handleUploadPage) mux.HandleFunc("POST /upload", s.handleUpload) + if s.manage { + 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") }) + return mux +} + +func (s *Server) Listen() error { + mux := s.routes() if s.serve { // Serve the generated site on the same port — for local development only. 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/edit.html b/templates/edit.html new file mode 100644 index 0000000..f167b81 --- /dev/null +++ b/templates/edit.html @@ -0,0 +1,110 @@ + + + + + + + Edit — {{.Photo.Caption}} + + + + + +
+ ← Manage +

Edit photo

+ + +

{{.Photo.Slug}}

+ + +
+
+ + +
+ +
+ + +
+ + +
+
+ + diff --git a/templates/manage.html b/templates/manage.html new file mode 100644 index 0000000..a619e83 --- /dev/null +++ b/templates/manage.html @@ -0,0 +1,117 @@ + + + + + + + Manage — Photo Gallery + + + + + +
+ ← Gallery +

Manage photos

+

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

+ + {{if .Saved}}

✓ saved “{{.Saved}}”

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

✓ deleted “{{.Deleted}}”

{{end}} + + {{if .Photos}} +
    + {{range .Photos}} +
  • + +
    +

    {{.Caption}}

    +

    {{.Date.Format "2006-01-02"}}

    +
    +
    + View + Edit + Delete +
    +
  • + {{end}} +
+ {{else}} +

No photos yet.

+ {{end}} +
+ +