feat(manage): delete photos from /manage, prune leftover pages

/manage/delete/{slug} asks for confirmation, 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.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
fox
2026-09-28 08:22:18 +02:00
co-authored by Claude Fable 5.1
parent b7fdc8a3bc
commit 185324c69c
9 changed files with 428 additions and 22 deletions
+130
View File
@@ -193,3 +193,133 @@ func TestEditAcceptsOwnOrigin(t *testing.T) {
})
}
}
// twoPhotoServer returns a built site with "sunrise" plus "dusk", which has a
// day of its own, so deleting it must also take that day's page away.
func twoPhotoServer(t *testing.T) *Server {
t.Helper()
s := newTestServer(t)
var img bytes.Buffer
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(s.contentDir, "dusk.jpg"), img.Bytes())
writeFile(t, filepath.Join(s.contentDir, "dusk.toml"),
[]byte("caption = \"Dusk\"\ndate = \"2026-07-01\"\n"))
if err := s.generator.Build(); err != nil {
t.Fatal(err)
}
return s
}
// photoFiles lists every file and directory that belongs to one photo.
func photoFiles(s *Server, slug, day string) []string {
return []string{
filepath.Join(s.contentDir, slug+".jpg"),
filepath.Join(s.contentDir, slug+".toml"),
filepath.Join(s.outputDir, "images", slug+".jpg"),
filepath.Join(s.outputDir, "thumbs", slug+".jpg"),
filepath.Join(s.outputDir, "medium", slug+".jpg"),
filepath.Join(s.outputDir, "og", slug+".jpg"),
filepath.Join(s.outputDir, "photo", slug),
filepath.Join(s.outputDir, "day", day),
}
}
func postDelete(s *Server, slug string, headers map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/manage/delete/"+slug, nil)
for k, v := range headers {
r.Header.Set(k, v)
}
r.SetPathValue("slug", slug)
w := httptest.NewRecorder()
s.handleDelete(w, r)
return w
}
func TestDeletePageAsksFirst(t *testing.T) {
s := twoPhotoServer(t)
r := httptest.NewRequest("GET", "/manage/delete/dusk", nil)
r.SetPathValue("slug", "dusk")
w := httptest.NewRecorder()
s.handleDeletePage(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if !strings.Contains(w.Body.String(), `action="/manage/delete/dusk"`) {
t.Errorf("confirmation page has no delete form")
}
// Looking at the confirmation page must not delete anything.
for _, f := range photoFiles(s, "dusk", "2026-07-01") {
if _, err := os.Stat(f); err != nil {
t.Errorf("%s is gone after a GET: %v", f, err)
}
}
}
func TestDeleteRemovesPhotoEverywhere(t *testing.T) {
s := twoPhotoServer(t)
w := postDelete(s, "dusk", nil)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body)
}
if loc := w.Header().Get("Location"); loc != "/manage?deleted=Dusk" {
t.Errorf("Location = %q", loc)
}
// The handler rebuilds in the background; build here to have a
// finished site to look at.
if err := s.generator.Build(); err != nil {
t.Fatal(err)
}
for _, f := range photoFiles(s, "dusk", "2026-07-01") {
if _, err := os.Stat(f); !os.IsNotExist(err) {
t.Errorf("%s survived the delete", f)
}
}
for _, f := range photoFiles(s, "sunrise", "2026-06-14") {
if _, err := os.Stat(f); err != nil {
t.Errorf("the other photo lost %s: %v", f, err)
}
}
for _, page := range []string{"index.html", "grid/index.html", "feed.xml"} {
data, err := os.ReadFile(filepath.Join(s.outputDir, page))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "dusk") {
t.Errorf("%s still mentions the deleted photo", page)
}
}
}
func TestDeleteRejectsBadInput(t *testing.T) {
tests := []struct {
name string
slug string
headers map[string]string
want int
}{
{"unknown photo", "nope", nil, http.StatusNotFound},
{"path traversal", "../dusk", nil, http.StatusNotFound},
{"cross-site fetch", "dusk", map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden},
{"foreign origin", "dusk", map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := twoPhotoServer(t)
if w := postDelete(s, tt.slug, tt.headers); w.Code != tt.want {
t.Errorf("status = %d, want %d", w.Code, tt.want)
}
for _, slug := range []string{"dusk", "sunrise"} {
if _, err := os.Stat(filepath.Join(s.contentDir, slug+".jpg")); err != nil {
t.Errorf("%s was deleted: %v", slug, err)
}
}
})
}
}