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:
@@ -19,7 +19,7 @@ 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 /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `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`/`POST /manage/delete/{slug}` (confirm, then delete), `GET /health`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -30,7 +30,8 @@ In production nginx sits in front: serves `public/` directly, proxies `/upload`
|
||||
- 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/<date>/` is not removed — the generator never prunes `public/`.
|
||||
- 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/<slug>/`, 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/<date>/` 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
|
||||
|
||||
@@ -58,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/<YYYY-MM-DD>/`) 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 `/<name>/`: 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 `/<name>/`: 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
|
||||
@@ -175,8 +176,7 @@ nix build # succeeds
|
||||
|
||||
- ActivityPub federation (planned later — start with RSS/Atom)
|
||||
- Multi-image upload
|
||||
- Delete via UI (edit is done, see Managing photos)
|
||||
- Pruning `public/` when a photo is deleted or its date changes
|
||||
- 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
|
||||
|
||||
@@ -141,12 +141,16 @@ curl -u user:password \
|
||||
|
||||
Or open `https://photos.yourdomain.tld/upload` in a browser.
|
||||
|
||||
## Editing a photo
|
||||
## Editing and deleting photos
|
||||
|
||||
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
|
||||
|
||||
+91
-10
@@ -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/<slug>/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/<ISO>/,
|
||||
// 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
|
||||
|
||||
@@ -123,3 +123,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,14 @@ import (
|
||||
"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.
|
||||
// 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 a link to its edit form.
|
||||
// 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 {
|
||||
@@ -38,6 +38,7 @@ func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
data["Deleted"] = r.URL.Query().Get("deleted")
|
||||
s.renderManage(w, "manage.html", data)
|
||||
}
|
||||
|
||||
@@ -111,6 +112,51 @@ func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
@@ -130,8 +176,8 @@ func (s *Server) findPhoto(slug string) (Photo, bool) {
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
|
||||
+130
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ func (s *Server) Listen() error {
|
||||
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")
|
||||
})
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Delete — {{.Photo.Caption}}</title>
|
||||
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<style>
|
||||
:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }
|
||||
.delete-page {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
.delete-page .back-btn {
|
||||
color: var(--fg3);
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
}
|
||||
.delete-page .back-btn:hover { color: var(--fg); }
|
||||
.delete-page h2 {
|
||||
font-family: var(--serif);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
margin: 16px 0 24px;
|
||||
}
|
||||
.delete-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 340px;
|
||||
object-fit: contain;
|
||||
background: var(--bg0);
|
||||
border: 1px solid var(--bg2);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.delete-caption {
|
||||
font-size: 18px;
|
||||
color: var(--fg);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.delete-meta {
|
||||
font-size: 12px;
|
||||
color: var(--fg4);
|
||||
font-family: monospace;
|
||||
margin: 4px 0 24px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.delete-warning {
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
line-height: 1.5;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 20px;
|
||||
background: #2e1d1d;
|
||||
color: #fb4934;
|
||||
border: 1px solid #5a2828;
|
||||
}
|
||||
.delete-btn {
|
||||
width: 100%;
|
||||
background: #2e1d1d;
|
||||
border: 1px solid #5a2828;
|
||||
color: #fb4934;
|
||||
font-family: var(--serif);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.delete-btn:hover { background: #5a2828; color: var(--fg); }
|
||||
.cancel-btn {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg2);
|
||||
color: var(--fg2);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cancel-btn:hover { background: var(--bg2); color: var(--fg); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="delete-page">
|
||||
<a href="/manage" class="back-btn">← Manage</a>
|
||||
<h2>Delete photo</h2>
|
||||
|
||||
<img class="delete-img" src="/{{.Photo.Medium}}" alt="">
|
||||
<p class="delete-caption">{{.Photo.Caption}}</p>
|
||||
<p class="delete-meta">{{.Photo.Date.Format "2006-01-02"}} · {{.Photo.Slug}}</p>
|
||||
|
||||
<p class="delete-warning">This removes the photo, its caption and every
|
||||
page and image generated from it. It cannot be undone.</p>
|
||||
|
||||
<form method="post" action="/manage/delete/{{.Photo.Slug}}">
|
||||
<button class="delete-btn" type="submit">Delete permanently</button>
|
||||
</form>
|
||||
<a href="/manage" class="cancel-btn">Cancel</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -79,6 +79,7 @@
|
||||
.mactions a { color: var(--fg3); text-decoration: none; }
|
||||
.mactions a:hover { color: var(--fg); }
|
||||
.mactions .medit { color: var(--aqua); }
|
||||
.mactions .mdelete { color: #fb4934; }
|
||||
.mempty { color: var(--fg4); font-size: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -89,6 +90,7 @@
|
||||
<p class="manage-count">{{len .Photos}} {{if eq (len .Photos) 1}}photo{{else}}photos{{end}}</p>
|
||||
|
||||
{{if .Saved}}<p class="saved">✓ saved “{{.Saved}}”</p>{{end}}
|
||||
{{if .Deleted}}<p class="saved">✓ deleted “{{.Deleted}}”</p>{{end}}
|
||||
|
||||
{{if .Photos}}
|
||||
<ul class="mlist">
|
||||
@@ -102,6 +104,7 @@
|
||||
<div class="mactions">
|
||||
<a href="/photo/{{.Slug}}/">View</a>
|
||||
<a href="/manage/edit/{{.Slug}}" class="medit">Edit</a>
|
||||
<a href="/manage/delete/{{.Slug}}" class="mdelete">Delete</a>
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user