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 <noreply@anthropic.com>
This commit is contained in:
+355
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user