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}} +
+ +