diff --git a/CLAUDE.md b/CLAUDE.md index 510224b..e41cce3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,9 +19,18 @@ The binary does three things at once: - Serves a multipart upload API at `POST /upload` - In dev mode (`--serve`), also serves `public/` as static files -Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`. +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`. -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`. 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`. +- Changing a date moves the photo to another day page, but the old `public/day//` is not removed — the generator never prunes `public/`. ## Stack @@ -143,12 +152,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 | @@ -162,7 +174,8 @@ nix build # succeeds - ActivityPub federation (planned later — start with RSS/Atom) - Multi-image upload -- Delete/edit via UI +- Delete via UI (edit is done, see Managing photos) +- Pruning `public/` when a photo is deleted or its date changes - 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 b10bcab..7c60504 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ nix-shell -p apacheHttpd --run \ | `mlFamily` | `Manjari` | CSS font-family for the alt-script bio | | `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 | --- @@ -138,3 +138,14 @@ curl -u user:password \ ``` Or open `https://photos.yourdomain.tld/upload` in a browser. + +## Editing a photo + +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. + +`/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/manage.go b/manage.go new file mode 100644 index 0000000..f9d052a --- /dev/null +++ b/manage.go @@ -0,0 +1,178 @@ +package main + +import ( + "bytes" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "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. + +// 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. +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 + } + } + } + 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) +} + +// 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 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..0013c32 --- /dev/null +++ b/manage_test.go @@ -0,0 +1,195 @@ +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) + return NewServer("0", content, output, false, g) +} + +func writeFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } +} + +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) + } + }) + } +} diff --git a/module.nix b/module.nix index 2080abe..c6f1ff8 100644 --- a/module.nix +++ b/module.nix @@ -94,7 +94,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 { @@ -184,6 +184,12 @@ in { basicAuthFile = cfg.nginx.htpasswdFile; extraConfig = "client_max_body_size 20M;"; }; + + locations."/manage" = { + 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..5caacce 100644 --- a/server.go +++ b/server.go @@ -35,6 +35,9 @@ func (s *Server) Listen() error { mux := http.NewServeMux() mux.HandleFunc("GET /upload", s.handleUploadPage) mux.HandleFunc("POST /upload", s.handleUpload) + 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 /health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }) 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..e399ba2 --- /dev/null +++ b/templates/manage.html @@ -0,0 +1,114 @@ + + + + + + + Manage — Photo Gallery + + + + + +
+ ← Gallery +

Manage photos

+

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

+ + {{if .Saved}}

✓ saved “{{.Saved}}”

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

    {{.Caption}}

    +

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

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

No photos yet.

+ {{end}} +
+ +