diff --git a/CLAUDE.md b/CLAUDE.md index 510224b..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 @@ -46,9 +56,10 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first - Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url` - Manjari for Malayalam text (bio line), via `-ml-family` - Two views of the same photos, both 3-column square grid → 2-column on mobile, both with infinite-scroll load-more. Page size is configurable via `-page-size` (default 60; NixOS `services.photogallery.pageSize`): - - **Timeline is the site root** (`/`, `/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). + - **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. - Single photo page with sidebar and newer/older navigation ### Link previews @@ -143,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 | @@ -162,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 b10bcab..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; @@ -101,9 +104,12 @@ nix-shell -p apacheHttpd --run \ | `fontsURL` | Crimson Pro + Manjari | Stylesheet URL loading both web fonts | | `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif | | `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 | --- @@ -138,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 57db661..9945b0c 100644 --- a/generator.go +++ b/generator.go @@ -27,6 +27,11 @@ const ( mediumMaxSize = 1600 defaultPageSize = 60 + // The two paginated views. The one chosen as -default-view is published + // at the site root, the other under //. + viewTimeline = "timeline" + viewGrid = "grid" + // Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a // 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely // reported to drop the preview entirely once the image passes ~300 KB, so @@ -78,15 +83,19 @@ type Generator struct { serifFamily string mlFamily string pageSize int + defaultView string mu sync.Mutex tmpl *template.Template } -func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int) *Generator { +func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int, defaultView string) *Generator { tmpl := template.Must(template.ParseFS(assets, "templates/*.html")) if pageSize < 1 { pageSize = defaultPageSize } + if defaultView != viewGrid { + defaultView = viewTimeline + } return &Generator{ contentDir: contentDir, outputDir: outputDir, @@ -99,6 +108,7 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f serifFamily: serifFamily, mlFamily: mlFamily, pageSize: pageSize, + defaultView: defaultView, tmpl: tmpl, } } @@ -110,6 +120,23 @@ func (g *Generator) initial() string { return "" } +// viewBase returns the URL prefix a view is published under: "" for the +// default view, which owns the site root, and "/" for the other one. +func (g *Generator) viewBase(view string) string { + if view == g.defaultView { + return "" + } + return "/" + view +} + +// pageURL returns the URL of the 1-based page n of a view rooted at base. +func pageURL(base string, n int) string { + if n == 1 { + return base + "/" + } + return base + "/page/" + strconv.Itoa(n) + "/" +} + // Build performs a full rebuild of the site. It is safe to call concurrently; // concurrent calls are serialised by a mutex. func (g *Generator) Build() error { @@ -121,7 +148,7 @@ func (g *Generator) Build() error { return err } - for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "static"} { + for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "day", "static"} { if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil { return err } @@ -133,6 +160,13 @@ func (g *Generator) Build() error { } } + // The default view lives at the site root, so a directory named after it + // can only be left over from a run with the other -default-view. Drop it, + // or the old pages would stay reachable and go stale. + if err := os.RemoveAll(filepath.Join(g.outputDir, g.defaultView)); err != nil { + return err + } + if err := g.renderTimelinePages(photos); err != nil { return err } @@ -165,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) @@ -407,17 +483,17 @@ 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 if totalPages == 0 { totalPages = 1 } - gridDir := filepath.Join(g.outputDir, "grid") + base := g.viewBase(viewGrid) for i := 0; i < totalPages; i++ { start := i * g.pageSize @@ -427,31 +503,24 @@ func (g *Generator) renderGridPages(photos []Photo) error { } slice := photos[start:end] - dst := filepath.Join(gridDir, "index.html") - if i > 0 { - dir := filepath.Join(gridDir, "page", strconv.Itoa(i+1)) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - dst = filepath.Join(dir, "index.html") + dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1))) + if err := os.MkdirAll(dir, 0755); err != nil { + return err } + dst := filepath.Join(dir, "index.html") prev := "" next := "" - switch i { - case 0: - // no prev - case 1: - prev = "/grid/" - default: - prev = "/grid/page/" + strconv.Itoa(i) + "/" + if i > 0 { + prev = pageURL(base, i) } if i+1 < totalPages { - next = "/grid/page/" + strconv.Itoa(i+2) + "/" + next = pageURL(base, i+2) } data := map[string]any{ "Photos": slice, + "TimelineURL": pageURL(g.viewBase(viewTimeline), 1), "Total": total, "Page": i + 1, "TotalPages": totalPages, @@ -472,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. @@ -544,39 +613,35 @@ 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) totalPages := len(pages) + base := g.viewBase(viewTimeline) + for i, groups := range pages { - dst := filepath.Join(g.outputDir, "index.html") - if i > 0 { - dir := filepath.Join(g.outputDir, "page", strconv.Itoa(i+1)) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - dst = filepath.Join(dir, "index.html") + dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1))) + if err := os.MkdirAll(dir, 0755); err != nil { + return err } + dst := filepath.Join(dir, "index.html") prev := "" next := "" - switch i { - case 0: - // no prev - case 1: - prev = "/" - default: - prev = "/page/" + strconv.Itoa(i) + "/" + if i > 0 { + prev = pageURL(base, i) } if i+1 < totalPages { - next = "/page/" + strconv.Itoa(i+2) + "/" + next = pageURL(base, i+2) } data := map[string]any{ "Groups": groups, + "GridURL": pageURL(g.viewBase(viewGrid), 1), "Total": len(photos), "Page": i + 1, "TotalPages": totalPages, @@ -597,19 +662,58 @@ 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 } err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{ "Date": grp.Label, + "TimelineURL": pageURL(g.viewBase(viewTimeline), 1), "Count": grp.Count, "Photos": grp.Photos, "Total": len(photos), diff --git a/generator_test.go b/generator_test.go new file mode 100644 index 0000000..f483f07 --- /dev/null +++ b/generator_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "bytes" + "image" + "image/jpeg" + "os" + "path/filepath" + "strings" + "testing" +) + +// buildSite builds a site of three photos spread over two days, with a page +// size of two so both views need a second page, and returns the output dir. +func buildSite(t *testing.T, output, defaultView string) { + t.Helper() + 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) + } + for slug, date := range map[string]string{ + "one": "2026-06-14", + "two": "2026-06-14", + "three": "2026-06-10", + } { + writeFile(t, filepath.Join(content, slug+".jpg"), img.Bytes()) + writeFile(t, filepath.Join(content, slug+".toml"), + []byte("caption = \""+slug+"\"\ndate = \""+date+"\"\n")) + } + + g := NewGenerator(content, output, "https://photos.example.com", + "Ada", "", "", "", "", "Crimson Pro", "Manjari", 2, defaultView) + if err := g.Build(); err != nil { + t.Fatal(err) + } +} + +func writeFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } +} + +func readPage(t *testing.T, output, rel string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(output, filepath.FromSlash(rel), "index.html")) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// Timeline pages carry day sections, grid pages do not. +func isTimeline(page string) bool { + return strings.Contains(page, `class="tl-day"`) +} + +func TestDefaultViewOwnsSiteRoot(t *testing.T) { + tests := []struct { + defaultView string + gridAt string // URL of the grid's first page + timelineAt string // URL of the timeline's first page + }{ + {viewTimeline, "/grid/", "/"}, + {viewGrid, "/", "/timeline/"}, + {"nonsense", "/grid/", "/"}, // unknown values fall back to the timeline + } + for _, tt := range tests { + t.Run(tt.defaultView, func(t *testing.T) { + output := t.TempDir() + buildSite(t, output, tt.defaultView) + + timeline := readPage(t, output, tt.timelineAt) + grid := readPage(t, output, tt.gridAt) + if !isTimeline(timeline) { + t.Errorf("%s is not the timeline", tt.timelineAt) + } + if isTimeline(grid) { + t.Errorf("%s is not the grid", tt.gridAt) + } + + // The views link to each other, and to their own second page. + for page, want := range map[string]string{ + timeline: `href="` + tt.gridAt + `" class="rss-a">Grid`, + grid: `href="` + tt.timelineAt + `" class="rss-a">Timeline`, + } { + if !strings.Contains(page, want) { + t.Errorf("missing link %q", want) + } + } + for first, base := range map[string]string{ + timeline: strings.TrimSuffix(tt.timelineAt, "/"), + grid: strings.TrimSuffix(tt.gridAt, "/"), + } { + next := base + "/page/2/" + if !strings.Contains(first, `href="`+next+`" data-load-more`) { + t.Errorf("first page does not link to %s", next) + } + second := readPage(t, output, next) + if !strings.Contains(second, `class="pager-newer" href="`+base+`/"`) { + t.Errorf("%s does not link back to %s/", next, base) + } + } + + // Day pages lead back to the timeline, wherever it lives. + day := readPage(t, output, "/day/2026-06-14/") + if !strings.Contains(day, `href="`+tt.timelineAt+`" class="rss-a">← Timeline`) { + t.Errorf("day page does not link to the timeline at %s", tt.timelineAt) + } + }) + } +} + +func TestSwitchingDefaultViewDropsOldViewDir(t *testing.T) { + output := t.TempDir() + + buildSite(t, output, viewTimeline) + if _, err := os.Stat(filepath.Join(output, "grid", "index.html")); err != nil { + t.Fatalf("grid view missing before the switch: %v", err) + } + + buildSite(t, output, viewGrid) + if _, err := os.Stat(filepath.Join(output, "grid")); !os.IsNotExist(err) { + t.Errorf("stale /grid/ survived the switch to -default-view grid") + } + if isTimeline(readPage(t, output, "/")) { + 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 e9d706b..af873c5 100644 --- a/main.go +++ b/main.go @@ -20,15 +20,21 @@ 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() + if *defaultView != viewTimeline && *defaultView != viewGrid { + log.Fatalf("-default-view must be %q or %q, got %q", viewTimeline, viewGrid, *defaultView) + } + for _, d := range []string{*contentDir, *outputDir} { if err := os.MkdirAll(d, 0755); err != nil { log.Fatalf("mkdir %s: %v", d, err) } } - g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily, *pageSize) + g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily, *pageSize, *defaultView) if err := g.Build(); err != nil { log.Printf("initial build: %v", err) @@ -41,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 2080abe..4e71c2d 100644 --- a/module.nix +++ b/module.nix @@ -82,6 +82,14 @@ in { description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this."; }; + defaultView = lib.mkOption { + type = lib.types.enum [ "timeline" "grid" ]; + default = "timeline"; + 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"; @@ -94,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 { @@ -134,7 +142,7 @@ in { User = "photogallery"; Group = "photogallery"; - ExecStart = lib.escapeShellArgs [ + ExecStart = lib.escapeShellArgs ([ "${pkg}/bin/photogallery" "--content" cfg.contentDir "--output" cfg.outputDir @@ -148,7 +156,8 @@ in { "--serif-family" cfg.serifFamily "--ml-family" cfg.mlFamily "--page-size" (toString cfg.pageSize) - ]; + "--default-view" cfg.defaultView + ] ++ lib.optional cfg.manage.enable "--manage"); Restart = "on-failure"; RestartSec = "5s"; @@ -184,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/day.html b/templates/day.html index 1089d06..1052fe2 100644 --- a/templates/day.html +++ b/templates/day.html @@ -23,7 +23,7 @@

{{.Handle}}

{{.Total}} posts

{{.Bio}}{{if .BioAlt}}
{{.BioAlt}}{{end}}

- ← Timeline + ← Timeline 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/grid.html b/templates/grid.html index 5cba5e8..2955f01 100644 --- a/templates/grid.html +++ b/templates/grid.html @@ -23,7 +23,7 @@

{{.Handle}}

{{.Total}} posts

{{.Bio}}{{if .BioAlt}}
{{.BioAlt}}{{end}}

- Timeline + Timeline + + + + 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}} +
+ + diff --git a/templates/timeline.html b/templates/timeline.html index 89f9242..fbd353f 100644 --- a/templates/timeline.html +++ b/templates/timeline.html @@ -23,7 +23,7 @@

{{.Handle}}

{{.Total}} posts

{{.Bio}}{{if .BioAlt}}
{{.BioAlt}}{{end}}

- Grid + Grid