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:
@@ -19,9 +19,19 @@ The binary does three things at once:
|
|||||||
- Serves a multipart upload API at `POST /upload`
|
- Serves a multipart upload API at `POST /upload`
|
||||||
- In dev mode (`--serve`), also serves `public/` as static files
|
- 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/<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
|
## Stack
|
||||||
|
|
||||||
@@ -49,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).
|
- **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.
|
- **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.
|
- 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
|
- Single photo page with sidebar and newer/older navigation
|
||||||
|
|
||||||
### Link previews
|
### Link previews
|
||||||
@@ -144,12 +154,15 @@ nix build # succeeds
|
|||||||
| `main.go` | Flags, wires generator + watcher + server |
|
| `main.go` | Flags, wires generator + watcher + server |
|
||||||
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
|
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
|
||||||
| `server.go` | Upload API + optional static file serving |
|
| `server.go` | Upload API + optional static file serving |
|
||||||
|
| `manage.go` | `/manage` list and edit handlers |
|
||||||
| `watcher.go` | fsnotify watcher with 500ms debounce |
|
| `watcher.go` | fsnotify watcher with 500ms debounce |
|
||||||
| `assets.go` | `//go:embed` for templates and static |
|
| `assets.go` | `//go:embed` for templates and static |
|
||||||
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
|
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
|
||||||
| `templates/grid.html` | Flat square grid at `/grid/`, paginated |
|
| `templates/grid.html` | Flat square grid at `/grid/`, paginated |
|
||||||
| `templates/day.html` | Single-day page at `/day/<date>/` |
|
| `templates/day.html` | Single-day page at `/day/<date>/` |
|
||||||
| `templates/photo.html` | Single photo page, neighbour links and prefetch |
|
| `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 |
|
| `static/photo-nav.js` | Arrow-key and swipe navigation |
|
||||||
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
|
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
|
||||||
| `flake.nix` | `buildGoModule` package + devShell + nixosModules |
|
| `flake.nix` | `buildGoModule` package + devShell + nixosModules |
|
||||||
@@ -163,7 +176,7 @@ nix build # succeeds
|
|||||||
|
|
||||||
- ActivityPub federation (planned later — start with RSS/Atom)
|
- ActivityPub federation (planned later — start with RSS/Atom)
|
||||||
- Multi-image upload
|
- 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
|
- Any auth beyond nginx htpasswd
|
||||||
- The Android app has never been run on real hardware
|
- 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
|
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ services.photogallery = {
|
|||||||
serifFamily = "EB Garamond";
|
serifFamily = "EB Garamond";
|
||||||
mlFamily = "Manjari";
|
mlFamily = "Manjari";
|
||||||
|
|
||||||
|
# Optional: edit and delete published photos at /manage (off by default)
|
||||||
|
manage.enable = true;
|
||||||
|
|
||||||
# Optional: built-in nginx vhost
|
# Optional: built-in nginx vhost
|
||||||
nginx = {
|
nginx = {
|
||||||
enable = true;
|
enable = true;
|
||||||
@@ -103,9 +106,10 @@ nix-shell -p apacheHttpd --run \
|
|||||||
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio |
|
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio |
|
||||||
| `pageSize` | `60` | Photos per page on the timeline and grid views |
|
| `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/` |
|
| `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.enable` | `false` | Configure an nginx vhost |
|
||||||
| `nginx.domain` | — | Domain for the 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 |
|
| `nginx.enableACME` | `true` | Enable Let's Encrypt |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -140,3 +144,20 @@ curl -u user:password \
|
|||||||
```
|
```
|
||||||
|
|
||||||
Or open `https://photos.yourdomain.tld/upload` in a browser.
|
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.
|
||||||
|
|||||||
+91
-10
@@ -199,6 +199,48 @@ func (g *Generator) Build() error {
|
|||||||
return nil
|
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.
|
// loadPhotos scans the content directory and returns photos sorted newest first.
|
||||||
func (g *Generator) loadPhotos() ([]Photo, error) {
|
func (g *Generator) loadPhotos() ([]Photo, error) {
|
||||||
entries, err := os.ReadDir(g.contentDir)
|
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)
|
return os.WriteFile(dst, buf.Bytes(), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderGridPages writes the paginated square-grid view under public/grid/:
|
// renderGridPages writes the paginated square-grid view: index.html and
|
||||||
// grid/index.html and grid/page/N/index.html, chunking photos into pageSize
|
// page/N/index.html under the grid's base (the site root when the grid is the
|
||||||
// slices for progressive pagination. The grid is the secondary view; the
|
// default view, /grid/ otherwise), chunking photos into pageSize slices for
|
||||||
// timeline is the site root.
|
// progressive pagination.
|
||||||
func (g *Generator) renderGridPages(photos []Photo) error {
|
func (g *Generator) renderGridPages(photos []Photo) error {
|
||||||
total := len(photos)
|
total := len(photos)
|
||||||
totalPages := (total + g.pageSize - 1) / g.pageSize
|
totalPages := (total + g.pageSize - 1) / g.pageSize
|
||||||
@@ -499,7 +541,7 @@ func (g *Generator) renderGridPages(photos []Photo) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return g.prunePages(base, totalPages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderPhotoPage writes public/photo/<slug>/index.html.
|
// renderPhotoPage writes public/photo/<slug>/index.html.
|
||||||
@@ -571,8 +613,9 @@ func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
|
|||||||
return pages
|
return pages
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderTimelinePages writes the day-grouped timeline as the site root:
|
// renderTimelinePages writes the day-grouped timeline: index.html and
|
||||||
// index.html and page/N/index.html. Photos are grouped by day, then packed
|
// 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.
|
// into pages of roughly pageSize without splitting a day across pages.
|
||||||
func (g *Generator) renderTimelinePages(photos []Photo) error {
|
func (g *Generator) renderTimelinePages(photos []Photo) error {
|
||||||
pages := chunkDayGroups(groupByDay(photos), g.pageSize)
|
pages := chunkDayGroups(groupByDay(photos), g.pageSize)
|
||||||
@@ -619,13 +662,51 @@ func (g *Generator) renderTimelinePages(photos []Photo) error {
|
|||||||
return err
|
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>/,
|
// 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 {
|
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)
|
dir := filepath.Join(g.outputDir, "day", grp.day)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -130,3 +130,36 @@ func TestSwitchingDefaultViewDropsOldViewDir(t *testing.T) {
|
|||||||
t.Errorf("site root is still the timeline")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ func main() {
|
|||||||
serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text")
|
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")
|
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)")
|
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 /<name>/")
|
defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other one is published under /<name>/")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -46,6 +47,6 @@ func main() {
|
|||||||
defer w.Close()
|
defer w.Close()
|
||||||
go w.Watch()
|
go w.Watch()
|
||||||
|
|
||||||
s := NewServer(*port, *contentDir, *outputDir, *serve, g)
|
s := NewServer(*port, *contentDir, *outputDir, *serve, *manage, g)
|
||||||
log.Fatal(s.Listen())
|
log.Fatal(s.Listen())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-3
@@ -88,6 +88,8 @@ in {
|
|||||||
description = "View published at the site root. The other one is published under /timeline/ or /grid/.";
|
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 = {
|
nginx = {
|
||||||
enable = lib.mkEnableOption "nginx virtual host for photogallery";
|
enable = lib.mkEnableOption "nginx virtual host for photogallery";
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@ in {
|
|||||||
htpasswdFile = lib.mkOption {
|
htpasswdFile = lib.mkOption {
|
||||||
type = lib.types.path;
|
type = lib.types.path;
|
||||||
example = "/etc/nginx/.htpasswd-gallery";
|
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 {
|
enableACME = lib.mkOption {
|
||||||
@@ -140,7 +142,7 @@ in {
|
|||||||
User = "photogallery";
|
User = "photogallery";
|
||||||
Group = "photogallery";
|
Group = "photogallery";
|
||||||
|
|
||||||
ExecStart = lib.escapeShellArgs [
|
ExecStart = lib.escapeShellArgs ([
|
||||||
"${pkg}/bin/photogallery"
|
"${pkg}/bin/photogallery"
|
||||||
"--content" cfg.contentDir
|
"--content" cfg.contentDir
|
||||||
"--output" cfg.outputDir
|
"--output" cfg.outputDir
|
||||||
@@ -155,7 +157,7 @@ in {
|
|||||||
"--ml-family" cfg.mlFamily
|
"--ml-family" cfg.mlFamily
|
||||||
"--page-size" (toString cfg.pageSize)
|
"--page-size" (toString cfg.pageSize)
|
||||||
"--default-view" cfg.defaultView
|
"--default-view" cfg.defaultView
|
||||||
];
|
] ++ lib.optional cfg.manage.enable "--manage");
|
||||||
|
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = "5s";
|
RestartSec = "5s";
|
||||||
@@ -191,6 +193,12 @@ in {
|
|||||||
basicAuthFile = cfg.nginx.htpasswdFile;
|
basicAuthFile = cfg.nginx.htpasswdFile;
|
||||||
extraConfig = "client_max_body_size 20M;";
|
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;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,26 +18,43 @@ type Server struct {
|
|||||||
contentDir string
|
contentDir string
|
||||||
outputDir string
|
outputDir string
|
||||||
serve bool
|
serve bool
|
||||||
|
manage bool
|
||||||
generator *Generator
|
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{
|
return &Server{
|
||||||
port: port,
|
port: port,
|
||||||
contentDir: contentDir,
|
contentDir: contentDir,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
serve: serve,
|
serve: serve,
|
||||||
|
manage: manage,
|
||||||
generator: g,
|
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 := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /upload", s.handleUploadPage)
|
mux.HandleFunc("GET /upload", s.handleUploadPage)
|
||||||
mux.HandleFunc("POST /upload", s.handleUpload)
|
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) {
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprintln(w, "ok")
|
fmt.Fprintln(w, "ok")
|
||||||
})
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Listen() error {
|
||||||
|
mux := s.routes()
|
||||||
|
|
||||||
if s.serve {
|
if s.serve {
|
||||||
// Serve the generated site on the same port — for local development only.
|
// Serve the generated site on the same port — for local development only.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<!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>Edit — {{.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; }
|
||||||
|
.edit-page {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
.edit-page .back-btn {
|
||||||
|
color: var(--fg3);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.edit-page .back-btn:hover { color: var(--fg); }
|
||||||
|
.edit-page h2 {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
margin: 16px 0 24px;
|
||||||
|
}
|
||||||
|
.edit-img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 340px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--bg0);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.edit-slug {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg4);
|
||||||
|
font-family: monospace;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.field { margin-bottom: 18px; }
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg4);
|
||||||
|
font-family: monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.field input[type="text"],
|
||||||
|
.field input[type="date"] {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg0);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.field input[type="text"] { font-family: var(--serif); }
|
||||||
|
.field input[type="date"] { font-family: monospace; font-size: 14px; color-scheme: dark; }
|
||||||
|
.field input:focus { border-color: var(--fg4); }
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
color: var(--fg2);
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.submit-btn:hover { background: var(--bg2); color: var(--fg); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="edit-page">
|
||||||
|
<a href="/manage" class="back-btn">← Manage</a>
|
||||||
|
<h2>Edit photo</h2>
|
||||||
|
|
||||||
|
<img class="edit-img" src="/{{.Photo.Medium}}" alt="">
|
||||||
|
<p class="edit-slug">{{.Photo.Slug}}</p>
|
||||||
|
|
||||||
|
<!-- A plain form post, no script: the server answers with a redirect
|
||||||
|
back to /manage, so a reload never resubmits the edit. -->
|
||||||
|
<form method="post" action="/manage/edit/{{.Photo.Slug}}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="caption">Caption</label>
|
||||||
|
<input type="text" id="caption" name="caption" value="{{.Photo.Caption}}" autocomplete="off" autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="date">Date</label>
|
||||||
|
<input type="date" id="date" name="date" value="{{.Photo.Date.Format "2006-01-02"}}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="submit-btn" type="submit">Save changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<!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>Manage — Photo Gallery</title>
|
||||||
|
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<style>
|
||||||
|
:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }
|
||||||
|
.manage-page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
.manage-page .back-btn {
|
||||||
|
color: var(--fg3);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.manage-page .back-btn:hover { color: var(--fg); }
|
||||||
|
.manage-page h2 {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
margin: 16px 0 6px;
|
||||||
|
}
|
||||||
|
.manage-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg4);
|
||||||
|
font-family: monospace;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.saved {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #1d2e1d;
|
||||||
|
color: #b8bb26;
|
||||||
|
border: 1px solid #344d28;
|
||||||
|
}
|
||||||
|
.mlist { list-style: none; }
|
||||||
|
.mrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.mrow img {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--bg0);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.minfo { flex: 1; min-width: 0; }
|
||||||
|
.mcaption {
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--fg);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.mdate {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg4);
|
||||||
|
font-family: monospace;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.mactions {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.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>
|
||||||
|
<body>
|
||||||
|
<div class="manage-page">
|
||||||
|
<a href="/" class="back-btn">← Gallery</a>
|
||||||
|
<h2>Manage photos</h2>
|
||||||
|
<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">
|
||||||
|
{{range .Photos}}
|
||||||
|
<li class="mrow">
|
||||||
|
<img src="/{{.Thumb}}" alt="" loading="lazy">
|
||||||
|
<div class="minfo">
|
||||||
|
<p class="mcaption">{{.Caption}}</p>
|
||||||
|
<p class="mdate">{{.Date.Format "2006-01-02"}}</p>
|
||||||
|
</div>
|
||||||
|
<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}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="mempty">No photos yet.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user