diff --git a/CLAUDE.md b/CLAUDE.md index c23a045..0512dd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,23 +19,91 @@ 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`, 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). +Routes: `GET /upload` (HTML upload form — multi-photo: the page posts one +`POST /upload` per selected photo, each with its own caption/date/tags), +`POST /upload` (multipart, one image per request), +`GET /manage` (edit/delete UI), the manage API under `/api/` (see Manage), +`GET /health`. Every route except `/health` is gated by HTTP Basic auth when +`-htpasswd` is set (see Auth). -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. +In production nginx sits in front: serves `public/` directly, proxies the +dynamic endpoints (`/upload`, `/manage`, `/api/`) to localhost, terminates TLS. +Auth is **no longer nginx's job** — the app checks credentials itself +(`auth.go`), so the nginx vhost just proxies. `/health` is **not** proxied, so +only `/upload`, `/manage` and `/api/` are reachable from outside the box; +anything new the app serves needs a matching `location` in `module.nix` or +nginx will 404 it as a missing static file. -### Managing photos +## Auth -`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: +`-htpasswd ` points at an Apache htpasswd file — **bcrypt only**, generate +with `htpasswd -B`. Each `POST /upload` and `GET /upload` then needs HTTP Basic +credentials matching a line in that file; `GET /health` stays open. `$2y$` +(Apache) and `$2a$`/`$2b$` hashes are all accepted; non-bcrypt lines (MD5 +`$apr1$`, SHA, crypt) are skipped with a warning. Empty `-htpasswd` disables +auth entirely (dev default) and logs a warning — `/upload` is then open. -- 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. +`GET /api/session` is a deliberately **unguarded** probe (`handleSession`): it +reads `r.BasicAuth()`, returns `200 {"authenticated":bool}` and never sends +`WWW-Authenticate`, so a public page can ask without triggering the browser's +login dialog. To stop it becoming a password oracle that sidesteps the limiter, +a request with no credentials is answered cheaply as `false`, while one with +*wrong* credentials runs through the same failed-attempt limiter (429 once +drained). `static/session.js` (loaded by every page template) calls it and, when +authenticated, builds a fixed toolbar with Upload / Manage / "Edit this" +(`/manage?edit=`, from the photo pages' `data-slug`) links and a +signed-in indicator. "Log out" is best-effort only — HTTP Basic has no reliable +sign-out; a dependable one would need cookie sessions. + +Verification lives in `auth.go`: unknown users still pay for one bcrypt +comparison (against a dummy hash) so timing can't enumerate usernames, and +**failed** attempts draw from a token-bucket limiter (`golang.org/x/time/rate`, +~1/s, burst 10) that returns `429` once drained — successful and bulk uploads +never touch it. Both apps (Android, Qt desktop) already send Basic auth, so no +client change was needed. Deps: `golang.org/x/crypto/bcrypt`, `golang.org/x/time/rate`. + +## Manage (edit / delete) + +`GET /manage` is an auth-gated web page (`templates/manage.html`) for editing +and deleting posts; it is **not linked from the public site** (reach it by URL). +Its API, all behind the same Basic auth and handled in `manage.go`: + +- `GET /api/photos` — every post as JSON (`slug, caption, date, tags, thumb, url`), newest first. +- `GET /api/thumb/{slug}` — serves that post's thumbnail JPEG from `public/thumbs/`, behind auth. Non-browser clients (the Android app) use this instead of the static `/thumbs/.jpg` path, because a front-door botwall on `location /` can block a non-browser client from the static site while `/api/` is proxied straight to the app. +- `POST /api/edit` — rewrite one post's sidecar (`slug, caption, date, tags`). The slug (and URL) never changes; editing only touches the `.toml`. +- `POST /api/tags` — bulk add/remove tags across posts (`slugs` CSV + `add`/`remove` CSV), leaving each post's caption and date intact. +- `POST /api/delete` — move posts (`slug` repeated, or `slugs` CSV) to `content/.trash/` (timestamp-suffixed, reversible), **not** a hard delete. +- `GET /api/trash` — list trashed items (`id` = `.`, `slug`, `deletedAt`, `image`, `hasMeta`), newest first. +- `POST /api/restore` — move a trashed item (`id`) back to `content/`, stripping the timestamp. Refuses with `409` if a live post already holds that slug. +- `POST /api/trash/empty` — **hard delete** trashed items: specific `id`s (repeated), or everything with `all=1`. The only non-reversible delete in the system. + +The manage page has a Trash panel (header toggle) for restore / delete-forever / +empty. `trash.go` handles these; filenames parse as `.` (slug +is dot-free and the stamp is a fixed `20060102-150405`, so parts peel off +unambiguously), and `id`s are validated the same way to block traversal. + +Each non-trash mutation triggers the same async rebuild as upload. Sidecars are written +atomically (temp + rename) so a concurrent build never sees a half-written +file. Slugs are validated (`resolveSlug`: bare filename only, must name an +existing content image) which closes path traversal and dotfile access. + +Every state-changing POST (`edit`, `delete`, `tags`, `restore`, `trash/empty`) +first passes `sameOrigin` (`server.go`): because Basic-auth credentials are +replayed by the browser on every request to the host, a cross-site page could +otherwise drive a logged-in user into a delete (CSRF). The check honours +`Sec-Fetch-Site`, falls back to comparing `Origin` against `-base-url`/`Host`, +and allows header-less non-browser clients (curl). Cross-site → `403`. (Ported +from the independent `/manage` on `plex/trunk-ng`.) + +**Delete relies on `Build`'s prune step** (`generator.go`): a rebuild drops the +derived HTML trees (`photo/ day/ album/ page/ grid/page/`) and regenerates them, +and `pruneRenditions` removes `images|thumbs|medium|og` files with no backing +photo. Without this a deleted post would linger at its old URL and in stale +day/album pages. This also cleans up after a sidecar removed by hand. ## Stack -- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image` +- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`, `golang.org/x/crypto` (bcrypt), `golang.org/x/time` (rate) - **Templates** — `html/template`, embedded via `embed.FS` - **Images** — four renditions per photo: original copy, 600×600 centre-cropped square thumbnail, a 1600px-longest-side "medium" for the detail page, and a 1200px "og" for link previews. Catmull-Rom resize. Outputs are skipped when newer than the source. - **Feed** — Atom (`encoding/xml`) @@ -46,22 +114,38 @@ In production nginx sits in front: serves `public/` directly, proxies `/upload` ```toml caption = "Tempelhof at sunrise" date = "2026-06-14" +tags = ["berlin", "sunrise"] ``` Missing sidecar → filename as caption, today as date. Photos sort newest first by that date. +`tags` is optional, free-text, and defines albums (see below). Tags are +normalized to lowercase on load/upload (`parseTags` in `albums.go`); a tag may +contain spaces (`"berlin trip"`), and its URL slug is derived with `sanitiseSlug`. + ## Design - Gruvbox dark hard colour scheme - 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 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. +- 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`). Which view sits at the site root is chosen by `-default-view` (`timeline` or `grid`, default `timeline`; NixOS `services.photogallery.defaultView`): the chosen one owns `/` (`/`, `/page/2/`, …) and the other is published under `//`. `viewBase`/`pageURL` (`generator.go`) derive every page path, pager link and the Timeline↔Grid cross-links (`TimelineURL`/`GridURL` in the template data) from that one setting; `Build` removes both `timeline/` and `grid/` each run so a flipped default leaves no stale root behind. + - **Timeline** (the default root): 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** is the flat square grid. At `/grid/` by default, or at the root when `-default-view grid`. - 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 +### Albums (tags) + +A second grouping axis, parallel to days: an album is just a tag (`albums.go`). +`groupByTag` mirrors `groupByDay`, and `renderAlbumPages` writes a paginated +square-grid album per tag at `/album//` (like the grid view). `/albums/` +is the index of every tag with counts; `/tags.json` is the machine-readable tag +list (`[{tag, slug, count}]`, served statically like `feed.xml`). Tags show on +each photo page and link to their album; an **Albums** link sits in the +timeline/grid header. Tags are assigned at upload via the `tags` field on +`POST /upload` (comma-separated) or by editing a sidecar's `tags`. All derived at +build time — no database. + ### Link previews Photo pages carry Open Graph + Twitter-card meta tags so a shared link unfurls @@ -108,7 +192,7 @@ go run . --serve ## Android app -Kotlin + Jetpack Compose uploader: pick or share a photo, add caption and date, post. Credentials (HTTP Basic) are sealed with an Android Keystore AES-GCM key. See `android/README.md` for the detail. +Kotlin + Jetpack Compose uploader: pick or share one or many photos, add caption/date/tags, post via a durable WorkManager queue. It also has a **Manage screen** (from the Post screen's "Manage" button) that lists published posts with thumbnails and edits caption/date/tags or deletes (to trash) via the same `/api/photos`, `/api/edit`, `/api/delete` endpoints the web `/manage` uses. The manage calls go through `UploadClient` (OkHttp + `org.json`, no extra deps); sending no `Origin`/`Sec-Fetch-Site` header, the app passes the server's same-origin CSRF check as a non-browser client. Thumbnails are fetched from `/api/thumb/` (authed, app-served) rather than the static `/thumbs/` path, so a botwall on `location /` doesn't blank them. The multi-photo picker is constructed with an explicit `maxItems` so it reliably opens in multi-select mode. Credentials (HTTP Basic) are sealed with an Android Keystore AES-GCM key. See `android/README.md` for the detail. Its own flake, deliberately separate from the root one so NixOS consumers of `nixosModules.default` don't pull the Android SDK into their lock: @@ -130,10 +214,12 @@ inputs.photogallery.nixosModules.default services.photogallery = { enable = true; baseURL = "https://photos.yourdomain.tld"; + # bcrypt htpasswd (`htpasswd -B`); handed to the service via LoadCredential, + # so a root-owned 0400 secret is fine. Omit to leave /upload open. + htpasswdFile = "/run/secrets/photogallery-htpasswd"; nginx = { - enable = true; - domain = "photos.yourdomain.tld"; - htpasswdFile = "/etc/nginx/.htpasswd-gallery"; + enable = true; + domain = "photos.yourdomain.tld"; }; }; ``` @@ -153,16 +239,20 @@ nix build # succeeds |---|---| | `main.go` | Flags, wires generator + watcher + server | | `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums | +| `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` | | `server.go` | Upload API + optional static file serving | -| `manage.go` | `/manage` list and edit handlers | +| `auth.go` | htpasswd (bcrypt) HTTP Basic auth + failed-attempt rate limit for `/upload` | +| `manage.go` | Edit/delete API (`/api/photos`, `/api/edit`, `/api/tags`, `/api/delete`) + trash move + atomic sidecar writes | +| `trash.go` | Trash API: list (`/api/trash`), `/api/restore`, `/api/trash/empty` (hard delete) | +| `templates/manage.html` | Auth-gated edit/delete web UI at `/manage`, with a Trash panel | | `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/album.html` | Album (tag) page at `/album//`, paginated | +| `templates/albums.html` | Album index at `/albums/` | | `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 | @@ -175,8 +265,7 @@ nix build # succeeds ## What's not done yet - ActivityPub federation (planned later — start with RSS/Atom) -- Multi-image upload -- Pruning renditions and photo pages of photos deleted from `content/` by hand (delete via `/manage` cleans up after itself) -- Any auth beyond nginx htpasswd +- Edit/delete in the **Qt desktop** app (web `/manage` and the Android app have it; the `/api/` endpoints are there for Qt to adopt). Restoring from trash is still web-only on all clients. +- Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes) - 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 53473c7..0321f63 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,50 @@ # photogallery -A minimal static photo gallery with an Atom feed and an upload API. +A single-user static photo gallery: a Go binary watches a directory of images + +`.toml` sidecars and regenerates a static site, with an Atom feed, albums, an +authenticated upload/manage API, and Android + desktop apps. No +database, no framework. + +## Contributors + +- **[Santhosh Raju](https://git.planet-express.in/fox)** — originated the ideas for the edit/delete ("manage") + capability and the configurable default view (`-default-view`, timeline or + grid as the site root). Both landed here as independent implementations + building on that work. + +## Features + +**Gallery** +- Two views of the same photos — a day-grouped **timeline** and a flat **grid** — either one as the site root (`-default-view`); the other lives under `/timeline/` or `/grid/`. 3-column square grid (2 on mobile) with infinite-scroll pagination. +- **Single-day pages** (`/day//`) linked from each timeline date heading. +- **Albums**: tag photos with free-text, lowercase tags; each tag is an album at `/album//`, indexed at `/albums/`, plus a machine-readable `/tags.json`. +- **Single photo pages** with newer/older navigation (arrow keys + touch swipe) and a link to the original. +- **Open Graph / Twitter-card link previews** so shared photo links unfurl. +- **Atom feed** at `/feed.xml`. Gruvbox-dark theme; configurable fonts and identity. + +**Content & management (no database)** +- `.toml` sidecars are the source of truth; the binary watches the content dir and rebuilds on any change. Four renditions per photo (original, 600² thumbnail, 1600px medium, 1200px OG), each skipped when already up to date. +- **Web upload** (`/upload`): pick one or many photos, each with its own EXIF-prefilled date, caption and tag-autocompleting tags field; posted with per-photo status and retry. +- **Manage UI** (`/manage`): edit caption/date/tags, bulk tag add/remove, and delete — delete is reversible (moves to `content/.trash/`, with restore / empty). +- Tag autocomplete across the upload and manage forms, fed by `/tags.json`. + +**Auth & security** +- App-side **HTTP Basic auth** (bcrypt htpasswd, `-htpasswd`) on every write endpoint; nginx just terminates TLS and proxies. Failed attempts are rate-limited; state-changing requests get a same-origin (CSRF) check. +- A **signed-in indicator** + management toolbar (Upload / Manage / Edit this) appear on every page when authenticated, via a silent `/api/session` probe (no login dialog for anonymous visitors). + +**Clients** +- The web upload + manage UI. +- **Android** uploader/manager (Kotlin/Compose, `android/`): pick or share photos, multi-photo bulk upload with captions/dates/tags, plus a Manage screen to edit or delete published posts. +- **Qt desktop** uploader (`desktop/`): bulk upload with configurable concurrency, HEIC support, credentials in the system keychain. + +**Deployment** +- One Go binary. NixOS flake + module (systemd service, tmpfiles, optional nginx vhost with ACME). ## How it works -1. Drop `photo.jpg` + `photo.toml` into the content directory (or upload via the web UI at `/upload`). -2. The binary watches the directory and rebuilds the static site on any change. -3. nginx serves `public/` and proxies `/upload` to the local API, secured with htpasswd. +1. Drop `photo.jpg` + `photo.toml` into the content directory (or upload via the web UI at `/upload`, the manage UI, or an app). +2. The binary watches the directory and rebuilds the static site on any change (and on every upload/edit/delete). +3. nginx terminates TLS, serves `public/`, and proxies the dynamic endpoints (`/upload`, `/manage`, `/api/`) to the local API. **Auth is the app's job** now (bcrypt htpasswd via `-htpasswd`), not nginx's. For each photo the generator produces a 600×600 square thumbnail for the grid and an aspect-preserved medium JPEG (longest side 1600) for the detail page. @@ -23,9 +61,13 @@ editable before submitting. ```toml caption = "Tempelhof at sunrise" date = "2026-06-14" +tags = ["berlin", "sunrise"] ``` -If no `.toml` is present the filename is used as the caption and today's date as the date. +If no `.toml` is present the filename is used as the caption and today's date as +the date. `tags` is optional, free-text and lowercased; each tag becomes an +album. A tag may contain spaces (`"berlin trip"`); its URL slug is derived +automatically. --- @@ -58,6 +100,14 @@ services.photogallery = { enable = true; baseURL = "https://photos.yourdomain.tld"; + # Auth for the upload/manage endpoints: a bcrypt htpasswd file. Handed to the + # service via systemd LoadCredential, so a root-owned 0400 secret is fine. + # Omit to leave /upload and /manage open. + htpasswdFile = "/run/secrets/photogallery-htpasswd"; + + # Optional: which view is the site root ("timeline" or "grid") + defaultView = "timeline"; + # Optional: identity shown on the header and copyright footer author = "Ada Lovelace"; handle = "@ada@example.com"; @@ -69,23 +119,19 @@ 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 + # Optional: built-in nginx vhost (TLS + proxy only; auth is app-side above) nginx = { - enable = true; - domain = "photos.yourdomain.tld"; - htpasswdFile = "/etc/nginx/.htpasswd-gallery"; + enable = true; + domain = "photos.yourdomain.tld"; }; }; ``` -Create the htpasswd file: +Create the htpasswd file (**bcrypt** — the app only accepts `-B` entries): ```bash nix-shell -p apacheHttpd --run \ - "htpasswd -c /etc/nginx/.htpasswd-gallery youruser" + "htpasswd -B -c /run/secrets/photogallery-htpasswd youruser" ``` ### Available options @@ -97,6 +143,9 @@ nix-shell -p apacheHttpd --run \ | `contentDir` | `/var/lib/photogallery/content` | Images and .toml sidecars | | `outputDir` | `/var/lib/photogallery/public` | Generated site output | | `port` | `8080` | Upload API port (localhost only) | +| `htpasswdFile` | `null` | bcrypt htpasswd guarding write endpoints (null = open) | +| `defaultView` | `timeline` | Site-root view: `timeline` or `grid` | +| `pageSize` | `60` | Photos per page (timeline/grid) | | `author` | `Ashik Salahudeen` | Name shown on the header, photo pages and copyright footer | | `handle` | `@puttaalu@inflo.ws` | Handle line under the author name | | `bio` | `Personal photo journal.` | Primary bio line | @@ -104,12 +153,8 @@ 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.enable` | `false` | Configure an nginx vhost (TLS + proxy) | | `nginx.domain` | — | Domain for the vhost | -| `nginx.htpasswdFile` | — | htpasswd file for `/upload` and `/manage` | | `nginx.enableACME` | `true` | Enable Let's Encrypt | --- @@ -140,24 +185,23 @@ curl -u user:password \ -F "image=@photo.jpg" \ -F "caption=Tempelhof at sunrise" \ -F "date=2026-06-14" \ + -F "tags=berlin, sunrise" \ https://photos.yourdomain.tld/upload ``` -Or open `https://photos.yourdomain.tld/upload` in a browser. +Or open `https://photos.yourdomain.tld/upload` in a browser, manage existing +posts at `/manage`, or use one of the apps below. -## Editing and deleting photos +## Apps -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. +Both use the same API (HTTP Basic auth) and need no server change. -*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. +- **Android** (`android/`) — Kotlin/Compose. Pick or share one or many photos, + set caption/date/tags, uploads run in a durable background queue. A Manage + screen lists published posts (with thumbnails) and edits their + caption/date/tags or deletes them to the server's trash, over the same + `/api/*` endpoints the web `/manage` uses. Has its own flake; + `cd android && nix develop && ./gradlew assembleRelease`. +- **Qt desktop** (`desktop/`) — Qt6/C++. Bulk upload with configurable + concurrency, HEIC support, credentials stored in the system keychain. Builds + via its own flake in `desktop/`. diff --git a/albums.go b/albums.go new file mode 100644 index 0000000..dd2c3f9 --- /dev/null +++ b/albums.go @@ -0,0 +1,206 @@ +package main + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// Tag is a normalized album tag: a lowercase free-text Name and its URL Slug. +type Tag struct { + Name string + Slug string +} + +// Album is all photos sharing one tag, newest first. +type Album struct { + Tag Tag + Count int + Photos []Photo +} + +// parseTags normalizes raw tag strings into unique Tags. Each input may itself +// be comma-separated (as typed in the upload form), so entries are split on +// commas, lowercased, trimmed, de-duplicated by name, and given a URL slug via +// sanitiseSlug. Entries that are empty or slug to nothing are dropped. +func parseTags(raw ...string) []Tag { + var tags []Tag + seen := map[string]bool{} + for _, entry := range raw { + for _, part := range strings.Split(entry, ",") { + name := strings.ToLower(strings.TrimSpace(part)) + if name == "" || seen[name] { + continue + } + slug := sanitiseSlug(name) + if slug == "" { + continue + } + seen[name] = true + tags = append(tags, Tag{Name: name, Slug: slug}) + } + } + return tags +} + +// groupByTag collapses photos (already sorted newest first) into one Album per +// distinct tag name, preserving photo order within each album. Albums are +// returned sorted alphabetically by tag name. If two different names produce the +// same URL slug, the later one is suffixed (-2, -3, …) and a warning is logged, +// so album URLs stay unique. +func groupByTag(photos []Photo) []Album { + byName := map[string][]Photo{} + for _, p := range photos { + for _, t := range p.Tags { + byName[t.Name] = append(byName[t.Name], p) + } + } + + names := make([]string, 0, len(byName)) + for name := range byName { + names = append(names, name) + } + sort.Strings(names) + + slugOwner := map[string]string{} + albums := make([]Album, 0, len(names)) + for _, name := range names { + slug := sanitiseSlug(name) + if owner, taken := slugOwner[slug]; taken && owner != name { + base := slug + for n := 2; ; n++ { + cand := base + "-" + strconv.Itoa(n) + if _, t := slugOwner[cand]; !t { + slug = cand + break + } + } + log.Printf("album slug collision: %q and %q both slug to %q; using %q for the latter", + owner, name, base, slug) + } + slugOwner[slug] = name + ps := byName[name] + albums = append(albums, Album{ + Tag: Tag{Name: name, Slug: slug}, + Count: len(ps), + Photos: ps, + }) + } + return albums +} + +// baseData returns the profile-header and site-wide fields every page template +// expects, so the album templates stay in step with the others. +func (g *Generator) baseData() map[string]any { + return map[string]any{ + "BaseURL": g.baseURL, + "Year": time.Now().Year(), + "Author": g.author, + "Handle": g.handle, + "Bio": g.bio, + "BioAlt": g.bioAlt, + "Initial": g.initial(), + "FontsURL": g.fontsURL, + "SerifFamily": g.serifFamily, + "MlFamily": g.mlFamily, + "TimelineURL": g.timelineURL(), + "GridURL": g.gridURL(), + } +} + +// renderAlbumPages writes one paginated album per tag under public/album//, +// a flat square grid like the grid view. total is the site-wide photo count, for +// the profile header. +func (g *Generator) renderAlbumPages(albums []Album, total int) error { + for _, al := range albums { + albumDir := filepath.Join(g.outputDir, "album", al.Tag.Slug) + if err := os.MkdirAll(albumDir, 0755); err != nil { + return err + } + base := "/album/" + al.Tag.Slug + "/" + + totalPages := (al.Count + g.pageSize - 1) / g.pageSize + if totalPages == 0 { + totalPages = 1 + } + + for i := 0; i < totalPages; i++ { + start := i * g.pageSize + end := start + g.pageSize + if end > al.Count { + end = al.Count + } + + dst := filepath.Join(albumDir, "index.html") + if i > 0 { + dir := filepath.Join(albumDir, "page", strconv.Itoa(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 = base + default: + prev = base + "page/" + strconv.Itoa(i) + "/" + } + if i+1 < totalPages { + next = base + "page/" + strconv.Itoa(i+2) + "/" + } + + data := g.baseData() + data["Tag"] = al.Tag + data["Count"] = al.Count + data["Photos"] = al.Photos[start:end] + data["Total"] = total + data["Page"] = i + 1 + data["TotalPages"] = totalPages + data["PrevURL"] = prev + data["NextURL"] = next + if err := g.render("album.html", dst, data); err != nil { + return err + } + } + } + return nil +} + +// renderAlbumsIndex writes public/albums/index.html — every tag with its count, +// linking to each album. +func (g *Generator) renderAlbumsIndex(albums []Album, total int) error { + data := g.baseData() + data["Albums"] = albums + data["Total"] = total + return g.render("albums.html", filepath.Join(g.outputDir, "albums", "index.html"), data) +} + +type tagJSON struct { + Tag string `json:"tag"` + Slug string `json:"slug"` + Count int `json:"count"` +} + +// renderTagsJSON writes public/tags.json — the machine-readable tag list, served +// statically like feed.xml. +func (g *Generator) renderTagsJSON(albums []Album) error { + out := make([]tagJSON, 0, len(albums)) + for _, a := range albums { + out = append(out, tagJSON{Tag: a.Tag.Name, Slug: a.Tag.Slug, Count: a.Count}) + } + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(g.outputDir, "tags.json"), b, 0644) +} diff --git a/android/README.md b/android/README.md index ed9c399..19ce205 100644 --- a/android/README.md +++ b/android/README.md @@ -1,11 +1,14 @@ # Gallery Upload — Android client -Native uploader for the photogallery `POST /upload` endpoint. Pick or share a -photo, add a caption and date, post. Credentials are entered once and sealed -with a key held in the Android Keystore. +Native uploader and manager for the photogallery server. Pick or share one or +many photos, add caption/date/tags, and post. A **Manage** screen lists the +published posts and edits their caption/date/tags or deletes them (to the +server's trash) over the same `/api/*` endpoints the web `/manage` uses. +Credentials are entered once and sealed with a key held in the Android Keystore. The server is not modified by this app. Everything it needs, it does client -side. +side; the management calls send no browser headers, so they pass the server's +same-origin (CSRF) check as a non-browser client. ## Build diff --git a/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt index fdfac9e..9004c0b 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable import androidx.core.content.ContextCompat import androidx.core.net.toUri +import ws.inflo.photogallery.ui.ManageScreen import ws.inflo.photogallery.ui.PhotoGalleryTheme import ws.inflo.photogallery.ui.PostScreen import ws.inflo.photogallery.ui.SettingsScreen @@ -37,9 +38,13 @@ class MainActivity : ComponentActivity() { PhotoGalleryTheme { // Land on settings until there is somewhere to post to. var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) } + var showManage by rememberSaveable { mutableStateOf(false) } + // Explicit maxItems so the system photo picker reliably opens in + // multi-select mode (the no-arg default has been seen to present + // single-select on some devices). val picker = rememberLauncherForActivityResult( - ActivityResultContracts.PickMultipleVisualMedia(), + ActivityResultContracts.PickMultipleVisualMedia(maxItems = 50), ) { uris -> if (uris.isNotEmpty()) viewModel.selectImages(uris) } val notificationPermission = rememberLauncherForActivityResult( @@ -57,8 +62,8 @@ class MainActivity : ComponentActivity() { } } - if (showSettings) { - SettingsScreen( + when { + showSettings -> SettingsScreen( state = viewModel.config, onBaseUrlChange = viewModel::setBaseUrl, onUsernameChange = viewModel::setUsername, @@ -70,8 +75,17 @@ class MainActivity : ComponentActivity() { null }, ) - } else { - PostScreen( + + showManage -> ManageScreen( + state = viewModel.manage, + onBack = { showManage = false }, + onRefresh = viewModel::loadManage, + onSave = viewModel::saveEdit, + onDelete = viewModel::deletePost, + onDismissNotice = viewModel::clearManageNotice, + ) + + else -> PostScreen( state = viewModel.post, onPickImages = { picker.launch( @@ -81,11 +95,16 @@ class MainActivity : ComponentActivity() { ) }, onCaptionChange = viewModel::setCaption, + onTagsChange = viewModel::setTags, onDateChange = viewModel::setDate, onRemove = viewModel::removeItem, onSubmit = viewModel::submit, onReset = viewModel::clear, onOpenSettings = { showSettings = true }, + onOpenManage = { + viewModel.loadManage() + showManage = true + }, onOpenUrl = ::openUrl, ) } diff --git a/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt b/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt index 7b23cc5..0957788 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt @@ -1,6 +1,9 @@ package ws.inflo.photogallery.net +import android.graphics.Bitmap +import android.graphics.BitmapFactory import okhttp3.Credentials +import okhttp3.FormBody import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody @@ -8,6 +11,8 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.Response +import org.json.JSONArray +import org.json.JSONException import java.io.File import java.io.IOException import java.util.concurrent.TimeUnit @@ -25,6 +30,22 @@ sealed interface UploadOutcome { data class Transient(val message: String) : UploadOutcome } +/** One published post, as returned by GET /api/photos. */ +data class RemotePhoto( + val slug: String, + val caption: String, + val date: String, // YYYY-MM-DD + val tags: List, + val thumb: String, // site-relative, e.g. "/thumbs/foo.jpg" + val url: String, // site-relative, e.g. "/photo/foo/" +) + +/** Result of listing posts for the manage screen. */ +sealed interface ListResult { + data class Ok(val photos: List) : ListResult + data class Err(val message: String) : ListResult +} + /** * Talks to the gallery's POST /upload endpoint. * @@ -60,6 +81,7 @@ class UploadClient( fileName: String, caption: String, date: String, + tags: String, ): UploadOutcome { val mediaType = if (fileName.endsWith(".png", ignoreCase = true)) { "image/png".toMediaType() @@ -72,6 +94,7 @@ class UploadClient( .addFormDataPart("image", fileName, file.asRequestBody(mediaType)) .addFormDataPart("caption", caption) .addFormDataPart("date", date) + .addFormDataPart("tags", tags) .build() val request = Request.Builder() @@ -120,6 +143,123 @@ class UploadClient( } } + /** + * Lists every published post for the manage screen. The manage endpoints + * sit behind the same Basic auth as /upload; the app sends no Origin or + * Sec-Fetch-Site header, so it passes the server's same-origin (CSRF) check + * as a non-browser client. + */ + fun listPhotos(): ListResult { + val request = Request.Builder().url("$base/api/photos").get().build() + return try { + client.newCall(request).execute().use { response -> + val text = response.body?.string().orEmpty() + when { + response.isSuccessful -> parsePhotos(text) + response.code == 401 || response.code == 403 -> + ListResult.Err("Wrong username or password") + else -> ListResult.Err("Server returned ${response.code}") + } + } + } catch (e: IOException) { + ListResult.Err(e.message ?: "Network error") + } catch (e: JSONException) { + ListResult.Err("Could not read the server's response") + } + } + + private fun parsePhotos(body: String): ListResult { + val arr = JSONArray(body) + val out = ArrayList(arr.length()) + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + val tagsArr = o.optJSONArray("tags") + val tags = ArrayList() + if (tagsArr != null) { + for (j in 0 until tagsArr.length()) tags.add(tagsArr.getString(j)) + } + out.add( + RemotePhoto( + slug = o.getString("slug"), + caption = o.optString("caption"), + date = o.optString("date"), + tags = tags, + thumb = o.optString("thumb"), + url = o.optString("url"), + ), + ) + } + return ListResult.Ok(out) + } + + /** + * Rewrites a post's sidecar. The slug (and its URL) never changes. Returns + * null on success, or a human-readable error. + */ + fun editPost(slug: String, caption: String, date: String, tags: String): String? { + val form = FormBody.Builder() + .add("slug", slug) + .add("caption", caption) + .add("date", date) + .add("tags", tags) + .build() + val request = Request.Builder().url("$base/api/edit").post(form).build() + return simpleResult(request) + } + + /** + * Moves a post to the server's trash (reversible there). Returns null on + * success, or a human-readable error — including the case where the server + * reports that nothing was deleted. + */ + fun deletePost(slug: String): String? { + val form = FormBody.Builder().add("slug", slug).build() + val request = Request.Builder().url("$base/api/delete").post(form).build() + return try { + client.newCall(request).execute().use { response -> + val text = response.body?.string().orEmpty() + when { + response.isSuccessful -> { + val deleted = runCatching { + org.json.JSONObject(text).optInt("deleted", 0) + }.getOrDefault(0) + if (deleted > 0) null else "Nothing deleted — not found on the server" + } + response.code == 401 || response.code == 403 -> "Wrong username or password" + else -> text.trim().ifEmpty { "Server returned ${response.code}" } + } + } + } catch (e: IOException) { + e.message ?: "Network error" + } + } + + /** Fetches a thumbnail by its site-relative [path], or null on any failure. */ + fun fetchThumbnail(path: String): Bitmap? { + val request = Request.Builder().url("$base$path").get().build() + return try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return null + response.body?.byteStream()?.let { BitmapFactory.decodeStream(it) } + } + } catch (e: IOException) { + null + } + } + + private fun simpleResult(request: Request): String? = try { + client.newCall(request).execute().use { response -> + val text = response.body?.string().orEmpty() + when { + response.isSuccessful -> null + response.code == 401 || response.code == 403 -> "Wrong username or password" + else -> text.trim().ifEmpty { "Server returned ${response.code}" } + } + } + } catch (e: IOException) { + e.message ?: "Network error" + } + /** Server replies "uploaded ."; recover the slug from that. */ private fun parseSlug(responseBody: String, fallback: String): String { val name = responseBody.trim().removePrefix("uploaded ").trim() diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/ManageScreen.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/ManageScreen.kt new file mode 100644 index 0000000..77a8b8a --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/ManageScreen.kt @@ -0,0 +1,307 @@ +package ws.inflo.photogallery.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM yyyy") + +@Composable +fun ManageScreen( + state: ManageUiState, + onBack: () -> Unit, + onRefresh: () -> Unit, + onSave: (slug: String, caption: String, date: LocalDate, tags: String) -> Unit, + onDelete: (slug: String) -> Unit, + onDismissNotice: () -> Unit, +) { + var editing by remember { mutableStateOf(null) } + var deleting by remember { mutableStateOf(null) } + + Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + ) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Manage posts", style = MaterialTheme.typography.headlineSmall, color = GruvGreen) + Row { + TextButton(onClick = onRefresh) { Text("Refresh") } + TextButton(onClick = onBack) { Text("Back") } + } + } + Spacer(Modifier.height(8.dp)) + } + + state.error?.let { msg -> + item { Notice(msg, GruvRed, onDismissNotice) } + } + state.message?.let { msg -> + item { Notice(msg, GruvAqua, onDismissNotice) } + } + + if (state.loading) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(color = GruvAqua) } + } + } else if (state.items.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + contentAlignment = Alignment.Center, + ) { Text("No posts.", color = GruvFg4) } + } + } + + items(state.items, key = { it.slug }) { post -> + PostRow( + post = post, + onEdit = { editing = post }, + onDelete = { deleting = post }, + ) + Spacer(Modifier.height(12.dp)) + } + } + } + + editing?.let { post -> + EditDialog( + post = post, + onDismiss = { editing = null }, + onSave = { caption, date, tags -> + onSave(post.slug, caption, date, tags) + editing = null + }, + ) + } + + deleting?.let { post -> + AlertDialog( + onDismissRequest = { deleting = null }, + title = { Text("Move to trash?") }, + text = { Text("\"${post.caption.ifBlank { post.slug }}\" will be moved to the server's trash.") }, + confirmButton = { + TextButton(onClick = { + onDelete(post.slug) + deleting = null + }) { Text("Delete", color = GruvRed) } + }, + dismissButton = { TextButton(onClick = { deleting = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +private fun Notice(message: String, color: androidx.compose.ui.graphics.Color, onDismiss: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(GruvBg1) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(message, color = color, style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onDismiss) { Text("Dismiss") } + } + Spacer(Modifier.height(12.dp)) +} + +@Composable +private fun PostRow(post: ManagePost, onEdit: () -> Unit, onDelete: () -> Unit) { + Row(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .size(72.dp) + .background(GruvBg1), + contentAlignment = Alignment.Center, + ) { + val preview = post.preview + if (preview != null) { + Image( + bitmap = preview.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } + + Spacer(Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + post.caption.ifBlank { post.slug }, + color = GruvFg, + style = MaterialTheme.typography.bodyLarge, + ) + Text(post.date.format(DISPLAY_DATE), color = GruvFg4, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + if (post.tags.isNotEmpty()) { + Text(post.tags.joinToString(" · "), color = GruvAqua, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(4.dp)) + Row { + if (post.busy) { + CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(18.dp)) + } else { + OutlinedButton(onClick = onEdit) { Text("Edit") } + Spacer(Modifier.width(8.dp)) + TextButton(onClick = onDelete) { Text("Delete", color = GruvRed) } + } + } + } + } +} + +@Composable +private fun EditDialog( + post: ManagePost, + onDismiss: () -> Unit, + onSave: (caption: String, date: LocalDate, tags: String) -> Unit, +) { + var caption by remember { mutableStateOf(post.caption) } + var tags by remember { mutableStateOf(post.tags.joinToString(", ")) } + var date by remember { mutableStateOf(post.date) } + var pickingDate by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit ${post.slug}") }, + text = { + Column { + val preview = post.preview + if (preview != null) { + Image( + bitmap = preview.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .background(GruvBg1), + ) + Spacer(Modifier.height(10.dp)) + } + OutlinedTextField( + value = caption, + onValueChange = { caption = it }, + label = { Text("Caption") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = tags, + onValueChange = { tags = it }, + label = { Text("Tags") }, + placeholder = { Text("comma-separated") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = { pickingDate = true }) { + Text(date.format(DISPLAY_DATE)) + } + } + }, + confirmButton = { + TextButton(onClick = { onSave(caption, date, tags) }) { Text("Save") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) + + if (pickingDate) { + DatePickerModal( + initial = date, + onDismiss = { pickingDate = false }, + onConfirm = { + date = it + pickingDate = false + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DatePickerModal( + initial: LocalDate, + onDismiss: () -> Unit, + onConfirm: (LocalDate) -> Unit, +) { + val pickerState = rememberDatePickerState( + initialSelectedDateMillis = initial + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(), + ) + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + val millis = pickerState.selectedDateMillis + if (millis != null) { + onConfirm(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()) + } else { + onDismiss() + } + }) { Text("OK") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) { + DatePicker(state = pickerState) + } +} diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt index 60df797..8c5d589 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt @@ -51,11 +51,13 @@ fun PostScreen( state: PostUiState, onPickImages: () -> Unit, onCaptionChange: (id: String, value: String) -> Unit, + onTagsChange: (id: String, value: String) -> Unit, onDateChange: (id: String, value: LocalDate) -> Unit, onRemove: (id: String) -> Unit, onSubmit: () -> Unit, onReset: () -> Unit, onOpenSettings: () -> Unit, + onOpenManage: () -> Unit, onOpenUrl: (String) -> Unit, ) { // Which item's date is being edited, if any. @@ -77,7 +79,10 @@ fun PostScreen( verticalAlignment = Alignment.CenterVertically, ) { Text("Post photos", style = MaterialTheme.typography.headlineSmall, color = GruvGreen) - TextButton(onClick = onOpenSettings) { Text("Settings") } + Row { + TextButton(onClick = onOpenManage) { Text("Manage") } + TextButton(onClick = onOpenSettings) { Text("Settings") } + } } Spacer(Modifier.height(12.dp)) } @@ -107,6 +112,7 @@ fun PostScreen( ItemCard( item = item, onCaptionChange = { onCaptionChange(item.id, it) }, + onTagsChange = { onTagsChange(item.id, it) }, onEditDate = { dateEditFor = item }, onRemove = { onRemove(item.id) }, onOpenUrl = onOpenUrl, @@ -185,6 +191,7 @@ fun PostScreen( private fun ItemCard( item: PickedItem, onCaptionChange: (String) -> Unit, + onTagsChange: (String) -> Unit, onEditDate: () -> Unit, onRemove: () -> Unit, onOpenUrl: (String) -> Unit, @@ -220,6 +227,16 @@ private fun ItemCard( modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(6.dp)) + OutlinedTextField( + value = item.tags, + onValueChange = onTagsChange, + label = { Text("Tags") }, + placeholder = { Text("comma-separated") }, + singleLine = true, + enabled = item.status !is ItemStatus.Uploading, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(6.dp)) Row(verticalAlignment = Alignment.CenterVertically) { OutlinedButton( onClick = onEditDate, diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt index 2bf4d37..c1e70af 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt @@ -17,11 +17,14 @@ import kotlinx.coroutines.withContext import ws.inflo.photogallery.data.LastPost import ws.inflo.photogallery.data.SettingsStore import ws.inflo.photogallery.image.ImagePrep +import ws.inflo.photogallery.net.ListResult +import ws.inflo.photogallery.net.RemotePhoto import ws.inflo.photogallery.net.UploadClient import ws.inflo.photogallery.work.UploadQueue import ws.inflo.photogallery.work.UploadWorker import java.io.File import java.time.LocalDate +import java.time.format.DateTimeFormatter import java.util.UUID /** Per-photo status within a batch. */ @@ -41,6 +44,7 @@ data class PickedItem( val source: Uri, val preview: Bitmap? = null, val caption: String = "", + val tags: String = "", // raw comma-separated; the server normalizes val date: LocalDate = LocalDate.now(), val status: ItemStatus = ItemStatus.Pending, ) @@ -59,6 +63,25 @@ data class PostUiState( items.all { it.status is ItemStatus.Success || it.status is ItemStatus.Error } } +/** One published post on the manage screen. */ +data class ManagePost( + val slug: String, + val caption: String, + val date: LocalDate, + val tags: List, + val thumb: String, // site-relative path + val url: String, // site-relative path + val preview: Bitmap? = null, + val busy: Boolean = false, // a save/delete is in flight +) + +data class ManageUiState( + val loading: Boolean = false, + val items: List = emptyList(), + val error: String? = null, + val message: String? = null, +) + data class SettingsUiState( val baseUrl: String = "", val username: String = "", @@ -114,6 +137,10 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) { updateItem(id) { it.copy(caption = value) } } + fun setTags(id: String, value: String) { + updateItem(id) { it.copy(tags = value) } + } + fun setDate(id: String, value: LocalDate) { updateItem(id) { it.copy(date = value) } } @@ -159,6 +186,7 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) { uri = Uri.fromFile(staged), caption = current.caption, date = current.date, + tags = current.tags, uniqueSuffix = item.id.take(6), ) observe(item.id, workId) @@ -219,6 +247,107 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) { post = post.copy(items = post.items.map { if (it.id == id) transform(it) else it }) } + // ── Manage screen ─────────────────────────────────────────────────────── + + var manage by mutableStateOf(ManageUiState()) + private set + + private val isoDate: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private fun client(): UploadClient = + UploadClient(settings.baseUrl, settings.username, settings.password) + + /** Loads the post list, then fills in thumbnails in the background. */ + fun loadManage() { + if (!settings.isConfigured) { + manage = manage.copy(error = "Set the server URL and credentials first") + return + } + manage = manage.copy(loading = true, error = null, message = null) + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { client().listPhotos() } + when (result) { + is ListResult.Ok -> { + manage = manage.copy( + loading = false, + items = result.photos.map { it.toPost() }, + ) + loadThumbnails(result.photos) + } + is ListResult.Err -> + manage = manage.copy(loading = false, error = result.message) + } + } + } + + private fun loadThumbnails(photos: List) { + for (p in photos) { + viewModelScope.launch { + // Fetch via the authed /api/thumb/ endpoint, not the static + // /thumbs/ path: a front-door botwall can block this non-browser + // client from the static site while /api/ is proxied to the app. + val bmp = withContext(Dispatchers.IO) { client().fetchThumbnail("/api/thumb/" + p.slug) } + if (bmp != null) updateManage(p.slug) { it.copy(preview = bmp) } + } + } + } + + fun saveEdit(slug: String, caption: String, date: LocalDate, tags: String) { + updateManage(slug) { it.copy(busy = true) } + viewModelScope.launch { + val err = withContext(Dispatchers.IO) { + client().editPost(slug, caption, date.format(isoDate), tags) + } + if (err == null) { + updateManage(slug) { + it.copy( + busy = false, + caption = caption, + date = date, + tags = parseTagList(tags), + ) + } + manage = manage.copy(message = "Saved") + } else { + updateManage(slug) { it.copy(busy = false) } + manage = manage.copy(error = err) + } + } + } + + fun deletePost(slug: String) { + updateManage(slug) { it.copy(busy = true) } + viewModelScope.launch { + val err = withContext(Dispatchers.IO) { client().deletePost(slug) } + if (err == null) { + manage = manage.copy( + items = manage.items.filterNot { it.slug == slug }, + message = "Moved to trash", + ) + } else { + updateManage(slug) { it.copy(busy = false) } + manage = manage.copy(error = err) + } + } + } + + fun clearManageNotice() { + manage = manage.copy(error = null, message = null) + } + + private fun RemotePhoto.toPost(): ManagePost { + val d = runCatching { LocalDate.parse(date, isoDate) }.getOrDefault(LocalDate.now()) + return ManagePost(slug = slug, caption = caption, date = d, tags = tags, thumb = thumb, url = url) + } + + // Local split for optimistic UI only; the server normalizes authoritatively. + private fun parseTagList(raw: String): List = + raw.split(",").map { it.trim() }.filter { it.isNotEmpty() } + + private inline fun updateManage(slug: String, transform: (ManagePost) -> ManagePost) { + manage = manage.copy(items = manage.items.map { if (it.slug == slug) transform(it) else it }) + } + // ── Settings screen ───────────────────────────────────────────────────── fun setBaseUrl(value: String) { diff --git a/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt b/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt index a35fae3..dbde648 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt @@ -34,6 +34,7 @@ object UploadQueue { uri: Uri, caption: String, date: LocalDate, + tags: String, uniqueSuffix: String, ): UUID { val slug = LocalDateTime.now().format(SLUG_FORMAT) + "-" + uniqueSuffix @@ -45,6 +46,7 @@ object UploadQueue { .putString(UploadWorker.KEY_SLUG, slug) .putString(UploadWorker.KEY_CAPTION, caption) .putString(UploadWorker.KEY_DATE, date.format(ISO_DATE)) + .putString(UploadWorker.KEY_TAGS, tags) .build(), ) .setConstraints( diff --git a/android/app/src/main/java/ws/inflo/photogallery/work/UploadWorker.kt b/android/app/src/main/java/ws/inflo/photogallery/work/UploadWorker.kt index 70837c3..47e08be 100644 --- a/android/app/src/main/java/ws/inflo/photogallery/work/UploadWorker.kt +++ b/android/app/src/main/java/ws/inflo/photogallery/work/UploadWorker.kt @@ -38,6 +38,7 @@ class UploadWorker( val slug = inputData.getString(KEY_SLUG) ?: return@withContext Result.failure() val caption = inputData.getString(KEY_CAPTION).orEmpty() val date = inputData.getString(KEY_DATE).orEmpty() + val tags = inputData.getString(KEY_TAGS).orEmpty() val settings = SettingsStore(applicationContext) if (!settings.isConfigured) { @@ -66,6 +67,7 @@ class UploadWorker( fileName = prepared.file.name, caption = caption, date = date, + tags = tags, ) // Keep the staged and prepared copies only while a retry might still @@ -147,6 +149,7 @@ class UploadWorker( const val KEY_SLUG = "slug" const val KEY_CAPTION = "caption" const val KEY_DATE = "date" + const val KEY_TAGS = "tags" const val KEY_RESULT_SLUG = "result_slug" const val KEY_RESULT_URL = "result_url" diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..3125bbd --- /dev/null +++ b/auth.go @@ -0,0 +1,102 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "net/http" + "os" + "strings" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/time/rate" +) + +// Authenticator checks HTTP Basic credentials against bcrypt hashes loaded from +// an Apache-style htpasswd file. Only bcrypt entries are supported — generate +// them with `htpasswd -B`. This replaces nginx's basic_auth so the app can run +// behind a plain TLS-terminating reverse proxy with no auth of its own. +type Authenticator struct { + creds map[string][]byte // username -> bcrypt hash + + // A single bcrypt comparison runs on every failed lookup so that a request + // for an unknown user costs the same as one for a known user with a wrong + // password, closing a user-enumeration timing side channel. + dummyHash []byte +} + +// LoadHtpasswd reads user:hash lines from path. Blank lines and #-comments are +// skipped, as is any entry whose hash is not bcrypt (with a warning). It is an +// error for the file to yield no usable entries. +func LoadHtpasswd(path string) (*Authenticator, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + a := &Authenticator{creds: make(map[string][]byte)} + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + user, hash, ok := strings.Cut(line, ":") + if !ok || user == "" { + continue + } + // bcrypt hashes start $2a$, $2b$ or $2y$; everything else (MD5 $apr1$, + // SHA {SHA}, crypt) is unsupported here. + if !strings.HasPrefix(hash, "$2") { + log.Printf("auth: skipping non-bcrypt entry for %q — regenerate with `htpasswd -B`", user) + continue + } + a.creds[user] = []byte(hash) + } + if err := sc.Err(); err != nil { + return nil, err + } + if len(a.creds) == 0 { + return nil, fmt.Errorf("no bcrypt entries in %s (use `htpasswd -B`)", path) + } + + // Cost 10 matches htpasswd -B's default, keeping the dummy comparison the + // same price as a real one. + a.dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy"), 10) + return a, nil +} + +// check reports whether user/pass matches a stored credential. +func (a *Authenticator) check(user, pass string) bool { + hash, ok := a.creds[user] + if !ok { + // Spend the same time as a real comparison to avoid leaking which + // usernames exist. + bcrypt.CompareHashAndPassword(a.dummyHash, []byte(pass)) + return false + } + return bcrypt.CompareHashAndPassword(hash, []byte(pass)) == nil +} + +// basicAuth wraps next, demanding valid HTTP Basic credentials. On a failed +// attempt it consumes a token from limiter; once the limiter is exhausted, +// further failures get 429 instead of 401, throttling brute-force guessing. +// The limiter is only touched on failure, so legitimate (even bursty, bulk) +// uploads are never throttled. +func (a *Authenticator) basicAuth(limiter *rate.Limiter, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if ok && a.check(user, pass) { + next(w, r) + return + } + if !limiter.Allow() { + http.Error(w, "too many failed attempts", http.StatusTooManyRequests) + return + } + w.Header().Set("WWW-Authenticate", `Basic realm="photog"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + } +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..5b24835 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/time/rate" +) + +// bcryptHash returns a cost-10 hash of pw, matching `htpasswd -B`'s default. +func bcryptHash(t *testing.T, pw string) string { + t.Helper() + h, err := bcrypt.GenerateFromPassword([]byte(pw), 10) + if err != nil { + t.Fatalf("GenerateFromPassword: %v", err) + } + return string(h) +} + +func TestLoadHtpasswdAndCheck(t *testing.T) { + // alice's hash is rewritten to the $2y$ variant that Apache's htpasswd -B + // emits, to prove it is accepted. $2a$ and $2y$ are the same algorithm, so + // swapping the prefix on a valid hash yields a valid hash for the same + // password. bob keeps the $2a$ form. + aliceHash := bcryptHash(t, "s3cret") + if !strings.HasPrefix(aliceHash, "$2a$") { + t.Fatalf("expected $2a$ prefix, got %q", aliceHash[:4]) + } + aliceHashY := "$2y$" + strings.TrimPrefix(aliceHash, "$2a$") + bobHash := bcryptHash(t, "hunter2") + + file := filepath.Join(t.TempDir(), "htpasswd") + content := strings.Join([]string{ + "# a comment", + "", + "alice:" + aliceHashY, + "bob:" + bobHash, + "legacy:$apr1$abcd$0123456789", // MD5 — must be skipped + }, "\n") + "\n" + if err := os.WriteFile(file, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + a, err := LoadHtpasswd(file) + if err != nil { + t.Fatalf("LoadHtpasswd: %v", err) + } + if _, ok := a.creds["legacy"]; ok { + t.Error("MD5 entry should have been skipped") + } + + cases := []struct { + user, pass string + want bool + }{ + {"alice", "s3cret", true}, // $2y$ accepted + {"bob", "hunter2", true}, // $2a$ accepted + {"alice", "wrong", false}, // wrong password + {"bob", "s3cret", false}, // right password, wrong user + {"nobody", "s3cret", false}, // unknown user + {"legacy", "whatever", false}, + } + for _, c := range cases { + if got := a.check(c.user, c.pass); got != c.want { + t.Errorf("check(%q, %q) = %v, want %v", c.user, c.pass, got, c.want) + } + } +} + +func TestBasicAuthMiddleware(t *testing.T) { + a := &Authenticator{creds: map[string][]byte{"alice": []byte(bcryptHash(t, "s3cret"))}} + a.dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy"), 10) + + var reached bool + // Burst of 2 failures before throttling, so the 429 path is reachable. + limiter := rate.NewLimiter(rate.Every(time.Hour), 2) + h := a.basicAuth(limiter, func(w http.ResponseWriter, r *http.Request) { + reached = true + w.WriteHeader(http.StatusCreated) + }) + + do := func(user, pass string, withCreds bool) *httptest.ResponseRecorder { + reached = false + req := httptest.NewRequest(http.MethodPost, "/upload", nil) + if withCreds { + req.SetBasicAuth(user, pass) + } + rec := httptest.NewRecorder() + h(rec, req) + return rec + } + + // Good credentials pass through. + if rec := do("alice", "s3cret", true); rec.Code != http.StatusCreated || !reached { + t.Fatalf("valid creds: code=%d reached=%v", rec.Code, reached) + } + + // No credentials → 401 with a challenge. + rec := do("", "", false) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("missing creds: code=%d, want 401", rec.Code) + } + if !strings.HasPrefix(rec.Header().Get("WWW-Authenticate"), "Basic ") { + t.Errorf("missing WWW-Authenticate challenge: %q", rec.Header().Get("WWW-Authenticate")) + } + if reached { + t.Error("handler reached despite missing creds") + } + + // Wrong password burns the limiter (burst 2: one above + this one), then + // the next failure is throttled to 429. + if rec := do("alice", "nope", true); rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong pass #2: code=%d, want 401", rec.Code) + } + if rec := do("alice", "nope", true); rec.Code != http.StatusTooManyRequests { + t.Fatalf("wrong pass #3: code=%d, want 429", rec.Code) + } + + // A valid request still succeeds while failures are throttled — the + // limiter only gates failures. + if rec := do("alice", "s3cret", true); rec.Code != http.StatusCreated { + t.Fatalf("valid creds under throttle: code=%d, want 201", rec.Code) + } +} + +func TestLoadHtpasswdErrors(t *testing.T) { + if _, err := LoadHtpasswd(filepath.Join(t.TempDir(), "does-not-exist")); err == nil { + t.Error("expected error for missing file") + } + + empty := filepath.Join(t.TempDir(), "htpasswd") + if err := os.WriteFile(empty, []byte("# only comments\n\nlegacy:$apr1$x$y\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := LoadHtpasswd(empty); err == nil { + t.Error("expected error when no bcrypt entries are usable") + } +} diff --git a/default_view_test.go b/default_view_test.go new file mode 100644 index 0000000..924c0d5 --- /dev/null +++ b/default_view_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// buildWithView builds a two-photo site with the given default view and returns +// the output dir. +func buildWithView(t *testing.T, view string) string { + t.Helper() + content := t.TempDir() + output := t.TempDir() + writePNG(t, filepath.Join(content, "a.png")) + writePNG(t, filepath.Join(content, "b.png")) + + g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60, view) + if err := g.Build(); err != nil { + t.Fatalf("build (%s): %v", view, err) + } + return output +} + +func exists(p string) bool { _, err := os.Stat(p); return err == nil } + +func TestDefaultViewTimeline(t *testing.T) { + out := buildWithView(t, viewTimeline) + + // Timeline owns the root; grid is the secondary under /grid/. + if !exists(filepath.Join(out, "index.html")) { + t.Error("root index.html missing") + } + if !exists(filepath.Join(out, "grid", "index.html")) { + t.Error("/grid/ should exist as the secondary view") + } + // No /timeline/ dir — timeline is at the root, not under its own name. + if exists(filepath.Join(out, "timeline")) { + t.Error("/timeline/ should not exist when timeline is the default") + } + + // The root (timeline) links to grid at /grid/. + root := readFile(t, filepath.Join(out, "index.html")) + if !strings.Contains(root, `href="/grid/"`) { + t.Error("timeline root should link to /grid/") + } +} + +func TestDefaultViewGrid(t *testing.T) { + out := buildWithView(t, viewGrid) + + // Grid owns the root; timeline is the secondary under /timeline/. + if !exists(filepath.Join(out, "index.html")) { + t.Error("root index.html missing") + } + if !exists(filepath.Join(out, "timeline", "index.html")) { + t.Error("/timeline/ should exist as the secondary view") + } + // No /grid/ dir — grid is at the root now. + if exists(filepath.Join(out, "grid")) { + t.Error("/grid/ should not exist when grid is the default") + } + + // The root (grid) links back to the timeline at /timeline/. + root := readFile(t, filepath.Join(out, "index.html")) + if !strings.Contains(root, `href="/timeline/"`) { + t.Error("grid root should link to /timeline/") + } +} + +// TestDefaultViewSwitchCleansUp confirms that flipping the default view does not +// leave the previous root's view directory behind. +func TestDefaultViewSwitchCleansUp(t *testing.T) { + content := t.TempDir() + output := t.TempDir() + writePNG(t, filepath.Join(content, "a.png")) + + // First build with grid as root → creates /timeline/. + g1 := NewGenerator(content, output, "http://x", "A", "@a", "b", "", "f", "s", "m", 60, viewGrid) + if err := g1.Build(); err != nil { + t.Fatal(err) + } + if !exists(filepath.Join(output, "timeline")) { + t.Fatal("expected /timeline/ after grid-default build") + } + + // Rebuild with timeline as root → /timeline/ must be gone, /grid/ present. + g2 := NewGenerator(content, output, "http://x", "A", "@a", "b", "", "f", "s", "m", 60, viewTimeline) + if err := g2.Build(); err != nil { + t.Fatal(err) + } + if exists(filepath.Join(output, "timeline")) { + t.Error("/timeline/ left over after switching default to timeline") + } + if !exists(filepath.Join(output, "grid", "index.html")) { + t.Error("/grid/ should exist after switching default to timeline") + } +} + +func readFile(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/desktop/mainwindow.cpp b/desktop/mainwindow.cpp index 0cfb829..7fbd453 100644 --- a/desktop/mainwindow.cpp +++ b/desktop/mainwindow.cpp @@ -313,7 +313,7 @@ void MainWindow::startItem(PhotoItemWidget *item) refreshPosting(); }); client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(), - caption, item->date()); + caption, item->date(), item->tags()); }); watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() { return ImagePrep::prepare(source, cacheDir, slug); diff --git a/desktop/photoitemwidget.cpp b/desktop/photoitemwidget.cpp index 1943d0c..ab7230d 100644 --- a/desktop/photoitemwidget.cpp +++ b/desktop/photoitemwidget.cpp @@ -47,6 +47,10 @@ PhotoItemWidget::PhotoItemWidget(QString sourcePath, QWidget *parent) m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd")); fields->addWidget(m_date); + m_tags = new QLineEdit; + m_tags->setPlaceholderText(QStringLiteral("tags (comma-separated)")); + fields->addWidget(m_tags); + m_statusLabel = new QLabel; m_statusLabel->setOpenExternalLinks(true); m_statusLabel->setWordWrap(true); @@ -70,6 +74,11 @@ QString PhotoItemWidget::date() const return m_date->date().toString(QStringLiteral("yyyy-MM-dd")); } +QString PhotoItemWidget::tags() const +{ + return m_tags->text(); +} + void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url) { m_status = status; @@ -79,6 +88,7 @@ void PhotoItemWidget::setStatus(Status status, const QString &message, const QSt const bool editable = status == Status::Pending || status == Status::Error; m_caption->setEnabled(editable); m_date->setEnabled(editable); + m_tags->setEnabled(editable); m_remove->setEnabled(status != Status::Preparing && status != Status::Uploading); switch (status) { diff --git a/desktop/photoitemwidget.h b/desktop/photoitemwidget.h index 21f7023..da6a007 100644 --- a/desktop/photoitemwidget.h +++ b/desktop/photoitemwidget.h @@ -21,6 +21,7 @@ public: QString sourcePath() const { return m_sourcePath; } QString caption() const; QString date() const; // yyyy-MM-dd + QString tags() const; // raw comma-separated; the server normalizes Status status() const { return m_status; } void setStatus(Status status, const QString &message = {}, const QString &url = {}); @@ -35,6 +36,7 @@ private: QLabel *m_thumb = nullptr; QLineEdit *m_caption = nullptr; QDateEdit *m_date = nullptr; + QLineEdit *m_tags = nullptr; QLabel *m_statusLabel = nullptr; QPushButton *m_remove = nullptr; }; diff --git a/desktop/uploadclient.cpp b/desktop/uploadclient.cpp index 9f2d515..118460b 100644 --- a/desktop/uploadclient.cpp +++ b/desktop/uploadclient.cpp @@ -26,7 +26,7 @@ QByteArray UploadClient::authHeader() const } void UploadClient::upload(const QString &filePath, const QString &fileName, - const QString &caption, const QString &date) + const QString &caption, const QString &date, const QString &tags) { auto *file = new QFile(filePath); if (!file->open(QIODevice::ReadOnly)) { @@ -57,6 +57,7 @@ void UploadClient::upload(const QString &filePath, const QString &fileName, }; multiPart->append(textPart(QStringLiteral("caption"), caption)); multiPart->append(textPart(QStringLiteral("date"), date)); + multiPart->append(textPart(QStringLiteral("tags"), tags)); QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload"))); request.setRawHeader("Authorization", authHeader()); diff --git a/desktop/uploadclient.h b/desktop/uploadclient.h index 79ab7af..8bd3247 100644 --- a/desktop/uploadclient.h +++ b/desktop/uploadclient.h @@ -29,7 +29,7 @@ public: // The filename matters: server.go derives the slug from it, so re-sending // the same name overwrites the same post instead of duplicating it. void upload(const QString &filePath, const QString &fileName, - const QString &caption, const QString &date); + const QString &caption, const QString &date, const QString &tags); // Credential check via GET /upload (same Basic-auth gate the POST uses). // /health is not usable: nginx only proxies location /upload. diff --git a/flake.nix b/flake.nix index c2467a2..5ab3e78 100644 --- a/flake.nix +++ b/flake.nix @@ -18,7 +18,7 @@ # Run `go mod tidy` to generate go.sum, then `nix build`. # Nix will fail and print the correct hash — paste it here. - vendorHash = "sha256-SxQDRProQtauWXPvPzbZZChLQd0iey9hnXFLiPYTS98="; + vendorHash = "sha256-Q+VeDZX9DlZtOZrYRwZo19eyob8jIu19IRhdvChmVL4="; meta = with pkgs.lib; { description = "Minimal static photo gallery"; diff --git a/generator.go b/generator.go index 9945b0c..e7456ce 100644 --- a/generator.go +++ b/generator.go @@ -27,8 +27,8 @@ const ( mediumMaxSize = 1600 defaultPageSize = 60 - // The two paginated views. The one chosen as -default-view is published - // at the site root, the other under //. + // The two paginated views. The one chosen as -default-view is published at + // the site root, the other under //. viewTimeline = "timeline" viewGrid = "grid" @@ -46,8 +46,9 @@ var ogQualityLadder = []int{85, 78, 70, 62} // PhotoMeta is the structure of a .toml sidecar file. type PhotoMeta struct { - Caption string `toml:"caption"` - Date string `toml:"date"` // YYYY-MM-DD + Caption string `toml:"caption"` + Date string `toml:"date"` // YYYY-MM-DD + Tags []string `toml:"tags,omitempty"` // free-text, lowercased; each tag is an album } // Photo is the in-memory representation of one image. @@ -61,6 +62,7 @@ type Photo struct { OGHeight int Caption string Date time.Time + Tags []Tag // albums this photo belongs to } // DayGroup is a run of photos sharing the same calendar day, for the timeline. @@ -113,15 +115,8 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f } } -func (g *Generator) initial() string { - for _, r := range g.author { - return strings.ToUpper(string(r)) - } - 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. +// 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 "" @@ -137,6 +132,18 @@ func pageURL(base string, n int) string { return base + "/page/" + strconv.Itoa(n) + "/" } +// timelineURL and gridURL are the roots of each view, for cross-links in the +// page headers. +func (g *Generator) timelineURL() string { return pageURL(g.viewBase(viewTimeline), 1) } +func (g *Generator) gridURL() string { return pageURL(g.viewBase(viewGrid), 1) } + +func (g *Generator) initial() string { + for _, r := range g.author { + return strings.ToUpper(string(r)) + } + return "" +} + // 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 { @@ -148,25 +155,36 @@ func (g *Generator) Build() error { return err } - for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "day", "static"} { + // Drop fully-derived HTML trees before regenerating them, so a deleted + // post, a retag, a changed date or a flipped -default-view leaves no + // orphaned page behind. These are cheap to rewrite (no up-to-date skipping + // applies to HTML), unlike the image renditions, which are pruned by slug + // just below. Both "timeline" and "grid" are removed: whichever is the + // default owns the site root and has no dir of its own, so a dir by that + // name can only be a leftover from the opposite -default-view. + for _, d := range []string{"photo", "day", "album", "page", "timeline", "grid"} { + if err := os.RemoveAll(filepath.Join(g.outputDir, d)); err != nil { + return err + } + } + + for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "day", "album", "albums", "static"} { if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil { return err } } + // Remove rendition files whose source photo is gone (e.g. after a delete), + // keeping the expensive resize outputs of surviving photos in place so + // up-to-date skipping still works. + g.pruneRenditions(photos) + for i := range photos { if err := g.processImage(&photos[i]); err != nil { log.Printf("processImage %s: %v", photos[i].Slug, err) } } - // 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 } @@ -176,6 +194,16 @@ func (g *Generator) Build() error { if err := g.renderDayPages(photos); err != nil { return err } + albums := groupByTag(photos) + if err := g.renderAlbumPages(albums, len(photos)); err != nil { + return err + } + if err := g.renderAlbumsIndex(albums, len(photos)); err != nil { + return err + } + if err := g.renderTagsJSON(albums); err != nil { + return err + } for i := range photos { var newer, older *Photo if i > 0 { @@ -199,48 +227,6 @@ 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) @@ -285,6 +271,7 @@ func (g *Generator) loadPhotos() ([]Photo, error) { OG: "og/" + slug + ".jpg", Caption: meta.Caption, Date: date, + Tags: parseTags(meta.Tags...), }) } @@ -294,6 +281,35 @@ func (g *Generator) loadPhotos() ([]Photo, error) { return photos, nil } +// pruneRenditions deletes files in the image output directories that no longer +// have a backing photo in content/. Called on every build, it is what makes a +// delete (and the stale sidecar left by any removed source) actually disappear +// from public/ rather than linger as an orphan reachable by direct URL. +func (g *Generator) pruneRenditions(photos []Photo) { + valid := make(map[string]bool, len(photos)) + for _, p := range photos { + valid[p.Slug] = true + } + for _, d := range []string{"images", "thumbs", "medium", "og"} { + full := filepath.Join(g.outputDir, d) + entries, err := os.ReadDir(full) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() { + continue + } + slug := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) + if !valid[slug] { + if err := os.Remove(filepath.Join(full, e.Name())); err != nil { + log.Printf("prune %s/%s: %v", d, e.Name(), err) + } + } + } + } +} + // processImage copies the original to public/images/ and writes a square // thumbnail to public/thumbs/ plus an aspect-preserved medium image to // public/medium/. Outputs are skipped when already newer than the source. @@ -483,10 +499,10 @@ func generateOG(src, dst string, maxDim int) error { return os.WriteFile(dst, buf.Bytes(), 0644) } -// 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. +// renderGridPages writes the paginated square-grid view. When grid is the +// -default-view it is the site root (index.html + page/N/); otherwise it lives +// under /grid/. Photos are chunked into pageSize slices for progressive +// pagination. func (g *Generator) renderGridPages(photos []Photo) error { total := len(photos) totalPages := (total + g.pageSize - 1) / g.pageSize @@ -520,12 +536,13 @@ func (g *Generator) renderGridPages(photos []Photo) error { data := map[string]any{ "Photos": slice, - "TimelineURL": pageURL(g.viewBase(viewTimeline), 1), "Total": total, "Page": i + 1, "TotalPages": totalPages, "PrevURL": prev, "NextURL": next, + "TimelineURL": g.timelineURL(), + "GridURL": g.gridURL(), "BaseURL": g.baseURL, "Year": time.Now().Year(), "Author": g.author, @@ -541,7 +558,7 @@ func (g *Generator) renderGridPages(photos []Photo) error { return err } } - return g.prunePages(base, totalPages) + return nil } // renderPhotoPage writes public/photo//index.html. @@ -613,14 +630,13 @@ func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup { return pages } -// 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 +// renderTimelinePages writes the day-grouped timeline. When timeline is the +// -default-view (the default) it is the site root (index.html + page/N/); +// otherwise it lives under /timeline/. 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 { @@ -641,12 +657,13 @@ func (g *Generator) renderTimelinePages(photos []Photo) error { data := map[string]any{ "Groups": groups, - "GridURL": pageURL(g.viewBase(viewGrid), 1), "Total": len(photos), "Page": i + 1, "TotalPages": totalPages, "PrevURL": prev, "NextURL": next, + "TimelineURL": g.timelineURL(), + "GridURL": g.gridURL(), "BaseURL": g.baseURL, "Year": time.Now().Year(), "Author": g.author, @@ -662,61 +679,24 @@ func (g *Generator) renderTimelinePages(photos []Photo) error { return err } } - 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 - } - } + return nil } // renderDayPages writes one page per calendar day under public/day//, -// 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. +// holding just that day's photos. Timeline date headings link here. func (g *Generator) renderDayPages(photos []Photo) error { - 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 { + for _, grp := range groupByDay(photos) { 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), + "TimelineURL": g.timelineURL(), + "GridURL": g.gridURL(), "BaseURL": g.baseURL, "Year": time.Now().Year(), "Author": g.author, diff --git a/go.mod b/go.mod index c3eb3f9..f950a3d 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,13 @@ module photogallery -go 1.22 +go 1.26.0 require ( github.com/BurntSushi/toml v1.4.0 github.com/fsnotify/fsnotify v1.7.0 + golang.org/x/crypto v0.58.0 golang.org/x/image v0.21.0 + golang.org/x/time v0.16.0 ) -require golang.org/x/sys v0.4.0 // indirect +require golang.org/x/sys v0.49.0 // indirect diff --git a/go.sum b/go.sum index b520222..e578e76 100644 --- a/go.sum +++ b/go.sum @@ -2,7 +2,11 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0 github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= -golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= -golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/main.go b/main.go index af873c5..819d9b1 100644 --- a/main.go +++ b/main.go @@ -20,8 +20,8 @@ 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 //") + htpasswd := flag.String("htpasswd", "", "path to an htpasswd file (bcrypt, `htpasswd -B`) guarding /upload; empty disables auth") + defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other is published under //") flag.Parse() if *defaultView != viewTimeline && *defaultView != viewGrid { @@ -47,6 +47,17 @@ func main() { defer w.Close() go w.Watch() - s := NewServer(*port, *contentDir, *outputDir, *serve, *manage, g) + var auth *Authenticator + if *htpasswd != "" { + auth, err = LoadHtpasswd(*htpasswd) + if err != nil { + log.Fatalf("htpasswd: %v", err) + } + log.Printf("upload auth: enabled (%s)", *htpasswd) + } else { + log.Print("upload auth: DISABLED (no -htpasswd); /upload is open") + } + + s := NewServer(*port, *contentDir, *outputDir, *serve, g, auth) log.Fatal(s.Listen()) } diff --git a/manage.go b/manage.go index 1b42bcb..463151f 100644 --- a/manage.go +++ b/manage.go @@ -1,10 +1,9 @@ package main import ( - "bytes" + "encoding/json" "log" "net/http" - "net/url" "os" "path/filepath" "strings" @@ -13,212 +12,379 @@ import ( "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. +// contentExts are the image extensions a post may use; the sidecar is always +// .toml alongside. +var contentExts = []string{".jpg", ".jpeg", ".png"} -// maxEditBody caps the edit form. It carries a caption and a date, nothing else. -const maxEditBody = 64 << 10 +// photoJSON is one row of GET /api/photos, consumed by the manage page. +type photoJSON struct { + Slug string `json:"slug"` + Caption string `json:"caption"` + Date string `json:"date"` // YYYY-MM-DD + Tags []string `json:"tags"` + Thumb string `json:"thumb"` + URL string `json:"url"` +} -// handleManagePage lists every photo with links to edit and delete it. +// handleSession reports whether the request carries valid credentials, so the +// UI can reveal management links and a signed-in indicator. It always returns +// 200 and never sends WWW-Authenticate, so calling it from a public page does +// not trigger the browser's Basic-auth dialog. +// +// It must not become a password-probing oracle that sidesteps the /upload +// limiter: a request with no credentials (a logged-out browser) is answered +// cheaply as "not authenticated", but one carrying wrong credentials is run +// through the same failed-attempt limiter and gets 429 once that is drained. +func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + + if s.auth == nil { // auth disabled → everything is open + writeJSON(w, map[string]bool{"authenticated": true}) + return + } + + user, pass, ok := r.BasicAuth() + if !ok { + writeJSON(w, map[string]bool{"authenticated": false}) + return + } + if s.auth.check(user, pass) { + writeJSON(w, map[string]bool{"authenticated": true}) + return + } + if !s.authLimit.Allow() { + http.Error(w, "too many attempts", http.StatusTooManyRequests) + return + } + writeJSON(w, map[string]bool{"authenticated": false}) +} + +// handleThumb serves a photo's thumbnail from the generated output, behind the +// same Basic auth as the rest of /api/. Non-browser clients (the Android app) +// use this instead of the static /thumbs/.jpg path, because a front-door +// botwall on `location /` can block a non-browser client from the static site +// while `/api/` is proxied straight to this server. +func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if slug == "" || slug != filepath.Base(slug) || strings.HasPrefix(slug, ".") { + http.Error(w, "bad slug", http.StatusBadRequest) + return + } + f, err := os.Open(filepath.Join(s.outputDir, "thumbs", slug+".jpg")) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "image/jpeg") + http.ServeContent(w, r, slug+".jpg", fi.ModTime(), f) +} + +// handleManagePage serves the (auth-gated) edit/delete UI. func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) { + data, err := assets.ReadFile("templates/manage.html") + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) +} + +// handleListPhotos returns every post as JSON, newest first, for the manage UI. +func (s *Server) handleListPhotos(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) + http.Error(w, "could not list 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 - } + out := make([]photoJSON, 0, len(photos)) + for _, p := range photos { + tags := make([]string, 0, len(p.Tags)) + for _, t := range p.Tags { + tags = append(tags, t.Name) } + out = append(out, photoJSON{ + Slug: p.Slug, + Caption: p.Caption, + Date: p.Date.Format("2006-01-02"), + Tags: tags, + Thumb: "/" + p.Thumb, + URL: "/photo/" + p.Slug + "/", + }) } - data["Deleted"] = r.URL.Query().Get("deleted") - s.renderManage(w, "manage.html", data) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(out) } -// 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. +// handleEdit rewrites one post's sidecar (caption, date, tags). The slug — and +// therefore the URL — never changes. func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) { - if !s.sameOrigin(r) { - http.Error(w, "cross-site request refused", http.StatusForbidden) + if s.rejectCrossSite(w, r) { 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 { + if err := parseForm(r); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } - - caption := strings.TrimSpace(r.PostFormValue("caption")) - if caption == "" { - caption = photo.Slug + slug := r.FormValue("slug") + if _, ok := s.resolveSlug(slug); !ok { + http.Error(w, "unknown post", http.StatusNotFound) + return } - dateStr := r.PostFormValue("date") - if _, err := time.Parse("2006-01-02", dateStr); err != nil { + + date := r.FormValue("date") + if date == "" { + date = time.Now().Format("2006-01-02") + } + if _, err := time.Parse("2006-01-02", date); 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 + caption := r.FormValue("caption") + if caption == "" { + caption = slug } - // 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) - } - }() + var tags []string + for _, t := range parseTags(r.FormValue("tags")) { + tags = append(tags, t.Name) + } - http.Redirect(w, r, "/manage?saved="+url.QueryEscape(photo.Slug), http.StatusSeeOther) + if err := s.writeSidecar(slug, PhotoMeta{Caption: caption, Date: date, Tags: tags}); err != nil { + log.Printf("edit %s: %v", slug, err) + http.Error(w, "could not save", http.StatusInternalServerError) + return + } + s.rebuild() + w.WriteHeader(http.StatusOK) } -// 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. +// handleDelete moves one or more posts (image + sidecar) to content/.trash/. +// Slugs come as repeated `slug` fields or a comma-separated `slugs` field. func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { - if !s.sameOrigin(r) { - http.Error(w, "cross-site request refused", http.StatusForbidden) + if s.rejectCrossSite(w, r) { return } - - photo, ok := s.findPhoto(r.PathValue("slug")) - if !ok { - http.NotFound(w, r) + slugs, err := s.formSlugs(r) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) 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) + deleted := 0 + for _, slug := range slugs { + if _, ok := s.resolveSlug(slug); !ok { + continue } - }() - - http.Redirect(w, r, "/manage?deleted="+url.QueryEscape(photo.Caption), http.StatusSeeOther) + if err := s.moveToTrash(slug); err != nil { + log.Printf("delete %s: %v", slug, err) + continue + } + deleted++ + } + if deleted > 0 { + s.rebuild() + } + writeJSON(w, map[string]int{"deleted": deleted}) } -// 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 +// handleBulkTags adds and/or removes tags across the given posts, leaving each +// post's caption and date untouched. `add` and `remove` are comma-separated. +func (s *Server) handleBulkTags(w http.ResponseWriter, r *http.Request) { + if s.rejectCrossSite(w, r) { + return } - for _, p := range photos { - if p.Slug == slug { + slugs, err := s.formSlugs(r) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + add := parseTags(r.FormValue("add")) + remove := make(map[string]bool) + for _, t := range parseTags(r.FormValue("remove")) { + remove[t.Name] = true + } + if len(add) == 0 && len(remove) == 0 { + http.Error(w, "nothing to add or remove", http.StatusBadRequest) + return + } + + updated := 0 + for _, slug := range slugs { + if _, ok := s.resolveSlug(slug); !ok { + continue + } + meta := s.readSidecar(slug) + + // Start from the current tags, append the additions, drop the removals, + // then let parseTags normalise and dedupe the result. + names := append([]string{}, meta.Tags...) + for _, t := range add { + names = append(names, t.Name) + } + var kept []string + for _, t := range parseTags(strings.Join(names, ",")) { + if !remove[t.Name] { + kept = append(kept, t.Name) + } + } + meta.Tags = kept + + if err := s.writeSidecar(slug, meta); err != nil { + log.Printf("bulk tags %s: %v", slug, err) + continue + } + updated++ + } + if updated > 0 { + s.rebuild() + } + writeJSON(w, map[string]int{"updated": updated}) +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +// parseForm reads form values from either an application/x-www-form-urlencoded +// or a multipart/form-data body. Browsers posting FormData send multipart, +// which r.ParseForm alone does not read from the body; ParseMultipartForm +// handles both — it runs ParseForm internally (so query and urlencoded values +// are parsed) and only reports ErrNotMultipart for a non-multipart body, by +// which point those values are already available. +func parseForm(r *http.Request) error { + if err := r.ParseMultipartForm(32 << 20); err != nil && err != http.ErrNotMultipart { + return err + } + return nil +} + +// resolveSlug reports whether slug names an existing post and returns the image +// path. It rejects anything that is not a bare, safe filename, closing path +// traversal and access to dotfiles such as the .trash directory. +func (s *Server) resolveSlug(slug string) (string, bool) { + if slug == "" || slug != filepath.Base(slug) || strings.HasPrefix(slug, ".") { + return "", false + } + for _, ext := range contentExts { + p := filepath.Join(s.contentDir, slug+ext) + if fi, err := os.Stat(p); err == nil && !fi.IsDir() { return p, true } } - return Photo{}, false + return "", 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" +// formSlugs collects slugs from repeated `slug` fields and/or a comma-separated +// `slugs` field, de-duplicated. +func (s *Server) formSlugs(r *http.Request) ([]string, error) { + if err := parseForm(r); err != nil { + return nil, err } - origin := r.Header.Get("Origin") - if origin == "" { - return true + seen := make(map[string]bool) + var out []string + add := func(v string) { + v = strings.TrimSpace(v) + if v != "" && !seen[v] { + seen[v] = true + out = append(out, v) + } } - if origin == s.generator.baseURL { - return true + for _, v := range r.Form["slug"] { + add(v) } - u, err := url.Parse(origin) - return err == nil && u.Host == r.Host + for _, v := range strings.Split(r.FormValue("slugs"), ",") { + add(v) + } + return out, nil } -// 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, +// readSidecar loads a post's existing metadata, falling back to the same +// defaults loadPhotos uses when the sidecar is missing or unreadable. +func (s *Server) readSidecar(slug string) PhotoMeta { + meta := PhotoMeta{Caption: slug, Date: time.Now().Format("2006-01-02")} + path := filepath.Join(s.contentDir, slug+".toml") + if _, err := os.Stat(path); err == nil { + if _, err := toml.DecodeFile(path, &meta); err != nil { + log.Printf("read sidecar %s: %v", path, err) + } } + return meta } -// 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) +// writeSidecar writes .toml atomically (temp file + rename) so a +// concurrent build can never observe a half-written sidecar. +func (s *Server) writeSidecar(slug string, meta PhotoMeta) error { + path := filepath.Join(s.contentDir, slug+".toml") + tmp, err := os.CreateTemp(s.contentDir, ".tmp-sidecar-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op once the rename below succeeds + + if err := toml.NewEncoder(tmp).Encode(meta); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// moveToTrash relocates a post's image and sidecar into content/.trash/, so a +// delete is reversible. A timestamp keeps re-deleted slugs from colliding. +func (s *Server) moveToTrash(slug string) error { + trash := filepath.Join(s.contentDir, ".trash") + if err := os.MkdirAll(trash, 0755); err != nil { + return err + } + stamp := time.Now().Format("20060102-150405") + + // Move every file the post owns: its image (whichever extension) and its + // sidecar. Missing pieces are not an error. + var names []string + for _, ext := range contentExts { + names = append(names, slug+ext) + } + names = append(names, slug+".toml") + + for _, name := range names { + src := filepath.Join(s.contentDir, name) + if _, err := os.Stat(src); err != nil { + continue + } + ext := filepath.Ext(name) + base := strings.TrimSuffix(name, ext) + dst := filepath.Join(trash, base+"."+stamp+ext) + if err := os.Rename(src, dst); err != nil { + return err + } + } + return nil +} + +// rebuild kicks off an asynchronous site rebuild, matching the upload handler. +// Build is mutex-guarded, so overlapping calls are safe. Tests may install a +// rebuildHook to make this deterministic. +func (s *Server) rebuild() { + if s.rebuildHook != nil { + s.rebuildHook() 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) + go func() { + if err := s.generator.Build(); err != nil { + log.Printf("rebuild after manage op: %v", err) + } + }() +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) } diff --git a/manage_test.go b/manage_test.go index 8166cbd..7e7c05c 100644 --- a/manage_test.go +++ b/manage_test.go @@ -2,8 +2,10 @@ package main import ( "bytes" + "encoding/json" "image" - "image/jpeg" + "image/png" + "mime/multipart" "net/http" "net/http/httptest" "net/url" @@ -15,341 +17,316 @@ import ( "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 { +// newTestServer builds a Server over fresh temp content/output dirs with a +// couple of posts, runs one build, and returns it. +func newTestServer(t *testing.T) (*Server, string, string) { t.Helper() + content := t.TempDir() + output := t.TempDir() - // 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-") + writePNG(t, filepath.Join(content, "foo.png")) + if err := os.WriteFile(filepath.Join(content, "foo.toml"), + []byte("caption = \"Foo\"\ndate = \"2026-01-02\"\ntags = [\"berlin\", \"street\"]\n"), 0600); err != nil { + t.Fatal(err) + } + writePNG(t, filepath.Join(content, "bar.png")) // no sidecar + + g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60, viewTimeline) + if err := g.Build(); err != nil { + t.Fatalf("initial build: %v", err) + } + s := NewServer("0", content, output, false, g, nil) + // Make rebuilds a no-op; tests call Build explicitly when they need to + // assert on generated output, so no background goroutine races teardown. + s.rebuildHook = func() {} + return s, content, output +} + +func writePNG(t *testing.T, path string) { + t.Helper() + f, err := os.Create(path) 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 { + defer f.Close() + if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, 2, 2))); 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())) +func form(v url.Values) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(v.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 + return r } -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) - } +// multipartForm builds a multipart/form-data request, as a browser's FormData +// does — the content type the manage handlers must parse, not just urlencoded. +func multipartForm(fields map[string]string) *http.Request { + var b bytes.Buffer + mw := multipart.NewWriter(&b) + for k, v := range fields { + mw.WriteField(k, v) } + mw.Close() + r := httptest.NewRequest(http.MethodPost, "/", &b) + r.Header.Set("Content-Type", mw.FormDataContentType()) + return r } -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 { +func readMeta(t *testing.T, content, slug string) PhotoMeta { 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) + var m PhotoMeta + if _, err := toml.DecodeFile(filepath.Join(content, slug+".toml"), &m); err != nil { + t.Fatalf("decode %s: %v", slug, 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")) + return m +} +func TestResolveSlug(t *testing.T) { + s, _, _ := newTestServer(t) + cases := map[string]bool{ + "foo": true, + "bar": true, + "nope": false, + "../server": false, + "foo/../foo": false, + ".trash": false, + "": false, + "sub/foo": false, + } + for slug, want := range cases { + if _, ok := s.resolveSlug(slug); ok != want { + t.Errorf("resolveSlug(%q) = %v, want %v", slug, ok, want) + } + } +} + +func TestHandleEdit(t *testing.T) { + s, content, _ := newTestServer(t) + + rec := httptest.NewRecorder() + s.handleEdit(rec, form(url.Values{ + "slug": {"foo"}, + "caption": {"New caption"}, + "date": {"2020-05-06"}, + "tags": {"Paris, , night"}, + })) + if rec.Code != http.StatusOK { + t.Fatalf("edit: code=%d body=%s", rec.Code, rec.Body) + } + m := readMeta(t, content, "foo") + if m.Caption != "New caption" || m.Date != "2020-05-06" { + t.Errorf("caption/date not saved: %+v", m) + } + if strings.Join(m.Tags, ",") != "paris,night" { // lowercased, empty dropped + t.Errorf("tags = %v, want [paris night]", m.Tags) + } + + // Bad date rejected. + rec = httptest.NewRecorder() + s.handleEdit(rec, form(url.Values{"slug": {"foo"}, "date": {"06-05-2020"}})) + if rec.Code != http.StatusBadRequest { + t.Errorf("bad date: code=%d, want 400", rec.Code) + } + + // Unknown slug rejected (and cannot escape content dir). + rec = httptest.NewRecorder() + s.handleEdit(rec, form(url.Values{"slug": {"../evil"}, "date": {"2020-01-01"}})) + if rec.Code != http.StatusNotFound { + t.Errorf("unknown slug: code=%d, want 404", rec.Code) + } +} + +func TestHandleBulkTags(t *testing.T) { + s, content, _ := newTestServer(t) + + // Add to bar (no sidecar yet) and foo (already has tags). + rec := httptest.NewRecorder() + s.handleBulkTags(rec, form(url.Values{"slugs": {"foo,bar"}, "add": {"aqua, berlin"}})) + if rec.Code != http.StatusOK { + t.Fatalf("bulk add: code=%d body=%s", rec.Code, rec.Body) + } + foo := readMeta(t, content, "foo") + // berlin already present → not duplicated; aqua appended. + if got := strings.Join(foo.Tags, ","); got != "berlin,street,aqua" { + t.Errorf("foo tags = %q, want berlin,street,aqua", got) + } + if got := strings.Join(readMeta(t, content, "bar").Tags, ","); got != "aqua,berlin" { + t.Errorf("bar tags = %q, want aqua,berlin", got) + } + + // Remove across both. + rec = httptest.NewRecorder() + s.handleBulkTags(rec, form(url.Values{"slugs": {"foo,bar"}, "remove": {"berlin"}})) + if rec.Code != http.StatusOK { + t.Fatalf("bulk remove: code=%d", rec.Code) + } + if got := strings.Join(readMeta(t, content, "foo").Tags, ","); got != "street,aqua" { + t.Errorf("foo tags after remove = %q, want street,aqua", got) + } + + // Empty add+remove is a 400. + rec = httptest.NewRecorder() + s.handleBulkTags(rec, form(url.Values{"slugs": {"foo"}})) + if rec.Code != http.StatusBadRequest { + t.Errorf("empty bulk: code=%d, want 400", rec.Code) + } +} + +func TestHandleDeleteMovesToTrashAndPrunes(t *testing.T) { + s, content, output := newTestServer(t) + + // Pre-condition: foo's rendition and page exist. + for _, p := range []string{"thumbs/foo.jpg", "images/foo.png", "photo/foo/index.html"} { + if _, err := os.Stat(filepath.Join(output, p)); err != nil { + t.Fatalf("expected %s before delete: %v", p, err) + } + } + + rec := httptest.NewRecorder() + s.handleDelete(rec, form(url.Values{"slug": {"foo"}})) + if rec.Code != http.StatusOK { + t.Fatalf("delete: code=%d", rec.Code) + } + var res map[string]int + json.Unmarshal(rec.Body.Bytes(), &res) + if res["deleted"] != 1 { + t.Errorf("deleted = %d, want 1", res["deleted"]) + } + + // Source files moved out of content/ ... + if _, err := os.Stat(filepath.Join(content, "foo.png")); !os.IsNotExist(err) { + t.Error("foo.png should be gone from content") + } + // ... and into .trash/ (timestamp-suffixed). + trash, _ := os.ReadDir(filepath.Join(content, ".trash")) + var png, tml bool + for _, e := range trash { + if strings.HasPrefix(e.Name(), "foo.") && strings.HasSuffix(e.Name(), ".png") { + png = true + } + if strings.HasPrefix(e.Name(), "foo.") && strings.HasSuffix(e.Name(), ".toml") { + tml = true + } + } + if !png || !tml { + t.Errorf("trash missing files: png=%v toml=%v entries=%v", png, tml, trash) + } + + // The handler rebuilds asynchronously; run a deterministic build, then + // confirm the prune removed foo's output and kept bar's. 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), + for _, p := range []string{"thumbs/foo.jpg", "images/foo.png", "photo/foo"} { + if _, err := os.Stat(filepath.Join(output, p)); !os.IsNotExist(err) { + t.Errorf("%s should have been pruned", p) + } + } + if _, err := os.Stat(filepath.Join(output, "thumbs/bar.jpg")); err != nil { + t.Errorf("bar's thumb should survive: %v", err) } } -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 -} +// TestMultipartBodies guards the regression where the manage handlers used +// r.ParseForm (urlencoded only) and silently ignored the multipart bodies that +// browsers send via FormData, returning 200 with deleted:0 / no change. +func TestMultipartBodies(t *testing.T) { + s, content, _ := newTestServer(t) -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) + // Edit via multipart. + rec := httptest.NewRecorder() + s.handleEdit(rec, multipartForm(map[string]string{ + "slug": "foo", "caption": "Multipart", "date": "2021-02-03", "tags": "x,y", + })) + if rec.Code != http.StatusOK { + t.Fatalf("multipart edit: code=%d", rec.Code) + } + if m := readMeta(t, content, "foo"); m.Caption != "Multipart" || strings.Join(m.Tags, ",") != "x,y" { + t.Errorf("multipart edit not applied: %+v", m) + } - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) + // Delete via multipart actually moves the file. + rec = httptest.NewRecorder() + s.handleDelete(rec, multipartForm(map[string]string{"slug": "bar"})) + var res map[string]int + json.Unmarshal(rec.Body.Bytes(), &res) + if res["deleted"] != 1 { + t.Fatalf("multipart delete: deleted=%d, want 1 (body=%s)", res["deleted"], rec.Body) } - 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) - } + if _, err := os.Stat(filepath.Join(content, "bar.png")); !os.IsNotExist(err) { + t.Error("bar.png should have moved to trash") } } -func TestDeleteRemovesPhotoEverywhere(t *testing.T) { - s := twoPhotoServer(t) - w := postDelete(s, "dusk", nil) +func TestCSRFGuard(t *testing.T) { + s, _, _ := newTestServer(t) // generator.baseURL == "http://x" - 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) + // A cross-site Origin is refused before any mutation runs. + r := form(url.Values{"slug": {"foo"}}) + r.Header.Set("Origin", "http://evil.example") + rec := httptest.NewRecorder() + s.handleDelete(rec, r) + if rec.Code != http.StatusForbidden { + t.Errorf("cross-site Origin: code=%d, want 403", rec.Code) } - for _, f := range photoFiles(s, "dusk", "2026-07-01") { - if _, err := os.Stat(f); !os.IsNotExist(err) { - t.Errorf("%s survived the delete", f) - } + // Sec-Fetch-Site: cross-site is likewise refused. + r = form(url.Values{"slug": {"foo"}}) + r.Header.Set("Sec-Fetch-Site", "cross-site") + rec = httptest.NewRecorder() + s.handleDelete(rec, r) + if rec.Code != http.StatusForbidden { + t.Errorf("Sec-Fetch cross-site: code=%d, want 403", rec.Code) } - 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) - } + + // Same-origin is allowed through (no slugs → deletes nothing, 200). + r = form(url.Values{}) + r.Header.Set("Sec-Fetch-Site", "same-origin") + rec = httptest.NewRecorder() + s.handleDelete(rec, r) + if rec.Code != http.StatusOK { + t.Errorf("same-origin: code=%d, want 200", rec.Code) } - 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) - } + + // A matching Origin is allowed. + r = form(url.Values{}) + r.Header.Set("Origin", "http://x") + rec = httptest.NewRecorder() + s.handleDelete(rec, r) + if rec.Code != http.StatusOK { + t.Errorf("matching Origin: code=%d, want 200", rec.Code) } } -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}, +func TestHandleListPhotos(t *testing.T) { + s, _, _ := newTestServer(t) + rec := httptest.NewRecorder() + s.handleListPhotos(rec, httptest.NewRequest(http.MethodGet, "/api/photos", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("list: code=%d", rec.Code) } - 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) - } - } - }) + var photos []photoJSON + if err := json.Unmarshal(rec.Body.Bytes(), &photos); err != nil { + t.Fatalf("decode: %v", 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) - } + if len(photos) != 2 { + t.Fatalf("got %d photos, want 2", len(photos)) + } + var foo *photoJSON + for i := range photos { + if photos[i].Slug == "foo" { + foo = &photos[i] } } + if foo == nil { + t.Fatal("foo not in list") + } + if foo.Thumb != "/thumbs/foo.jpg" || foo.URL != "/photo/foo/" { + t.Errorf("foo paths wrong: %+v", foo) + } + if strings.Join(foo.Tags, ",") != "berlin,street" { + t.Errorf("foo tags = %v", foo.Tags) + } } diff --git a/module.nix b/module.nix index 4e71c2d..f58c784 100644 --- a/module.nix +++ b/module.nix @@ -85,10 +85,22 @@ in { 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/."; + description = "View published at the site root; the other 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)"; + htpasswdFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/secrets/photogallery-htpasswd"; + description = '' + Path to an Apache htpasswd file (bcrypt only — generate with + `htpasswd -B`) guarding the /upload endpoint. The app performs the + auth itself, so nginx no longer needs basic_auth. The file is handed + to the service via systemd LoadCredential, so it may be root-owned and + 0400 (e.g. an agenix/sops secret). Leave null to disable auth entirely + — then /upload is open and anyone who can reach it can post. + ''; + }; nginx = { enable = lib.mkEnableOption "nginx virtual host for photogallery"; @@ -99,12 +111,6 @@ in { description = "Domain for the nginx virtual host."; }; - htpasswdFile = lib.mkOption { - type = lib.types.path; - example = "/etc/nginx/.htpasswd-gallery"; - description = "Path to htpasswd file protecting the /upload and /manage endpoints."; - }; - enableACME = lib.mkOption { type = lib.types.bool; default = true; @@ -157,7 +163,14 @@ in { "--ml-family" cfg.mlFamily "--page-size" (toString cfg.pageSize) "--default-view" cfg.defaultView - ] ++ lib.optional cfg.manage.enable "--manage"); + ] ++ lib.optionals (cfg.htpasswdFile != null) [ + # %d is systemd's credentials directory; LoadCredential below drops + # the htpasswd file there, readable only by this service. + "--htpasswd" "%d/htpasswd" + ]); + + LoadCredential = lib.optional (cfg.htpasswdFile != null) + "htpasswd:${cfg.htpasswdFile}"; Restart = "on-failure"; RestartSec = "5s"; @@ -187,17 +200,22 @@ in { tryFiles = "$uri $uri/ =404"; }; + # Auth is handled by the app (see services.photogallery.htpasswdFile), + # so nginx just proxies the dynamic endpoints: the upload form/API, + # the manage (edit/delete) page and its JSON API. Everything else is + # the static site served from root above. locations."/upload" = { - proxyPass = "http://127.0.0.1:${toString cfg.port}"; - basicAuth = "gallery"; - basicAuthFile = cfg.nginx.htpasswdFile; - extraConfig = "client_max_body_size 20M;"; + proxyPass = "http://127.0.0.1:${toString cfg.port}"; + 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; + locations."/manage" = { + proxyPass = "http://127.0.0.1:${toString cfg.port}"; + }; + + locations."/api/" = { + proxyPass = "http://127.0.0.1:${toString cfg.port}"; + extraConfig = "client_max_body_size 20M;"; }; }; }; diff --git a/server.go b/server.go index c6bccca..a1d3110 100644 --- a/server.go +++ b/server.go @@ -5,12 +5,14 @@ import ( "io" "log" "net/http" + "net/url" "os" "path/filepath" "strings" "time" "github.com/BurntSushi/toml" + "golang.org/x/time/rate" ) type Server struct { @@ -18,43 +20,98 @@ type Server struct { contentDir string outputDir string serve bool - manage bool generator *Generator + auth *Authenticator // nil disables auth (dev only) + authLimit *rate.Limiter + + // rebuildHook overrides how a mutation triggers a rebuild. nil means the + // default asynchronous Build; tests set it to run synchronously (or not at + // all) so no background goroutine outlives the test. + rebuildHook func() } -func NewServer(port, contentDir, outputDir string, serve, manage bool, g *Generator) *Server { +func NewServer(port, contentDir, outputDir string, serve bool, g *Generator, auth *Authenticator) *Server { return &Server{ port: port, contentDir: contentDir, outputDir: outputDir, serve: serve, - manage: manage, generator: g, + auth: auth, + // Brute-force throttle: ~1 failed auth/sec steady, burst of 10. Only + // failures draw from it, so legitimate bulk uploads are never slowed. + authLimit: rate.NewLimiter(rate.Every(time.Second), 10), } } -// 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) +// guard applies HTTP Basic auth to h when an Authenticator is configured, and +// passes it through untouched otherwise (local --serve development). +func (s *Server) guard(h http.HandlerFunc) http.HandlerFunc { + if s.auth == nil { + return h } - mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "ok") - }) - return mux + return s.auth.basicAuth(s.authLimit, h) +} + +// sameOrigin reports whether a form/API 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 drive a logged-in user's +// browser into submitting an edit, delete or retag on their behalf (CSRF). +// +// Sec-Fetch-Site is preferred; where it is absent the Origin header is compared +// against -base-url and the request Host (behind nginx the Host reaching this +// server is the proxy upstream, not the public name). A request with neither +// header is a non-browser client such as curl, which cannot be driven +// cross-site, so it is allowed. +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 +} + +// rejectCrossSite writes a 403 and returns true when r is a cross-site request. +// State-changing handlers call it first: `if s.rejectCrossSite(w, r) { return }`. +func (s *Server) rejectCrossSite(w http.ResponseWriter, r *http.Request) bool { + if s.sameOrigin(r) { + return false + } + http.Error(w, "cross-site request refused", http.StatusForbidden) + return true } func (s *Server) Listen() error { - mux := s.routes() + mux := http.NewServeMux() + mux.HandleFunc("GET /upload", s.guard(s.handleUploadPage)) + mux.HandleFunc("POST /upload", s.guard(s.handleUpload)) + + // Edit/delete UI and its API. All auth-gated like /upload; destructive + // endpoints (delete) move posts to content/.trash/ rather than erasing. + mux.HandleFunc("GET /manage", s.guard(s.handleManagePage)) + mux.HandleFunc("GET /api/photos", s.guard(s.handleListPhotos)) + mux.HandleFunc("POST /api/edit", s.guard(s.handleEdit)) + mux.HandleFunc("POST /api/delete", s.guard(s.handleDelete)) + mux.HandleFunc("POST /api/tags", s.guard(s.handleBulkTags)) + mux.HandleFunc("GET /api/thumb/{slug}", s.guard(s.handleThumb)) + mux.HandleFunc("GET /api/trash", s.guard(s.handleListTrash)) + mux.HandleFunc("POST /api/restore", s.guard(s.handleRestore)) + mux.HandleFunc("POST /api/trash/empty", s.guard(s.handleEmptyTrash)) + + // Login-state probe — deliberately NOT guarded, so public pages can ask + // without triggering a Basic-auth dialog. It does its own silent check. + mux.HandleFunc("GET /api/session", s.handleSession) + + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "ok") + }) if s.serve { // Serve the generated site on the same port — for local development only. @@ -84,6 +141,7 @@ func (s *Server) handleUploadPage(w http.ResponseWriter, r *http.Request) { // image — the image file (jpg or png) // caption — human-readable caption (optional, defaults to filename) // date — YYYY-MM-DD (optional, defaults to today) +// tags — comma-separated album tags (optional; lowercased, deduped) func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(32 << 20); err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -112,11 +170,18 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { caption = strings.TrimSuffix(header.Filename, ext) } + // Normalize the tags once here so the sidecar stores clean lowercase names; + // the generator derives album slugs from them at build time. + var tags []string + for _, t := range parseTags(r.FormValue("tags")) { + tags = append(tags, t.Name) + } + slug := sanitiseSlug(strings.TrimSuffix(header.Filename, ext)) if slug == "" { slug = fmt.Sprintf("photo-%d", time.Now().UnixMilli()) } - imgPath := filepath.Join(s.contentDir, slug+ext) + imgPath := filepath.Join(s.contentDir, slug+ext) tomlPath := filepath.Join(s.contentDir, slug+".toml") out, err := os.Create(imgPath) @@ -138,7 +203,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { return } defer tf.Close() - if err := toml.NewEncoder(tf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil { + if err := toml.NewEncoder(tf).Encode(PhotoMeta{Caption: caption, Date: dateStr, Tags: tags}); err != nil { http.Error(w, "could not write metadata", http.StatusInternalServerError) return } diff --git a/static/session.js b/static/session.js new file mode 100644 index 0000000..42e936c --- /dev/null +++ b/static/session.js @@ -0,0 +1,65 @@ +// Shows a management toolbar + signed-in indicator on every page when the +// browser holds valid credentials. The public gallery is static and carries no +// markup for this; the bar is built here only after /api/session confirms the +// viewer is authenticated, so logged-out visitors see nothing and get no login +// dialog. +// +// A page may set (the photo pages do) to get an +// "Edit this" link straight to that post in /manage. +(function () { + fetch("/api/session", { headers: { Accept: "application/json" }, cache: "no-store" }) + .then((r) => (r.ok ? r.json() : { authenticated: false })) + .then((s) => { + if (s && s.authenticated) buildBar(); + }) + .catch(() => {}); + + function el(tag, cls, text) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + } + + function buildBar() { + document.body.classList.add("authed"); + + const bar = el("div", "session-bar"); + const path = location.pathname.replace(/\/$/, ""); + + const link = (href, label) => { + const a = el("a", null, label); + a.href = href; + bar.appendChild(a); + }; + + if (path !== "/upload") link("/upload", "Upload"); + if (path !== "/manage") link("/manage", "Manage"); + + const slug = document.body.dataset.slug; + if (slug) link("/manage?edit=" + encodeURIComponent(slug), "Edit this"); + + bar.appendChild(el("span", "session-dot", "● signed in")); + + // Best-effort logout. HTTP Basic has no real sign-out — the browser caches + // credentials and clears them reliably only when the window closes — so + // this overwrites the cached credential with a throwaway one and reloads. + // It works in some browsers and not others; a dependable logout would need + // cookie sessions. + const out = el("a", "session-out", "log out"); + out.href = "#"; + out.title = "Best-effort — HTTP Basic auth has no reliable logout"; + out.addEventListener("click", (e) => { + e.preventDefault(); + fetch("/api/session", { + headers: { Authorization: "Basic " + btoa("logout:" + Date.now()) }, + cache: "no-store", + }) + .catch(() => {}) + .finally(() => setTimeout(() => location.reload(), 150)); + }); + bar.appendChild(out); + + document.body.appendChild(bar); + } +})(); diff --git a/static/style.css b/static/style.css index 54537cc..4a40375 100644 --- a/static/style.css +++ b/static/style.css @@ -422,3 +422,104 @@ body { .dlayout { flex-direction: column; } .dsidebar { width: 100%; border-left: none; border-top: 1px solid var(--line); } } + +/* ── Tags / albums ─────────────────────────────────────────── */ + +.dtags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 14px; +} + +.tag, +.album-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + background: var(--bg0); + border: 1px solid var(--bg2); + color: var(--fg3); + font-family: monospace; + font-size: 12px; + text-decoration: none; + border-radius: 2px; + transition: color 0.15s, border-color 0.15s; +} + +.tag:hover, +.album-chip:hover { + color: var(--aqua); + border-color: var(--fg4); +} + +.album-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.album-chip { + font-size: 14px; + padding: 7px 14px; +} + +.album-chip-count { + color: var(--fg4); + font-size: 11px; +} + +.album-empty { + color: var(--fg4); + font-family: var(--serif); + margin-top: 16px; +} + +/* Tag autocomplete dropdown (tagcomplete.js), shared by upload and manage. */ +.tagac-menu { + position: absolute; + z-index: 50; + background: var(--bg1); + border: 1px solid var(--bg2); + max-height: 220px; + overflow-y: auto; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); +} +.tagac-item { + padding: 7px 11px; + font-family: var(--serif); + font-size: 14px; + color: var(--fg2); + cursor: pointer; +} +.tagac-item.sel, +.tagac-item:hover { + background: var(--bg2); + color: var(--fg); +} + +/* Session toolbar (session.js), shown on any page when signed in. */ +.session-bar { + position: fixed; + top: 0; + right: 0; + z-index: 60; + display: flex; + align-items: center; + gap: 14px; + padding: 7px 14px; + background: var(--bg1); + border-left: 1px solid var(--bg2); + border-bottom: 1px solid var(--bg2); + font-family: monospace; + font-size: 12px; +} +.session-bar a { + color: var(--aqua, #8ec07c); + text-decoration: none; +} +.session-bar a:hover { text-decoration: underline; } +.session-dot { color: var(--green, #b8bb26); } +.session-out { color: var(--fg4) !important; } diff --git a/static/tagcomplete.js b/static/tagcomplete.js new file mode 100644 index 0000000..1c4acae --- /dev/null +++ b/static/tagcomplete.js @@ -0,0 +1,152 @@ +// Per-token tag autocomplete for comma-separated tag inputs, shared by the +// upload and manage pages. Suggestions come from /tags.json (regenerated on +// every build), so new tags appear on the next load. Only the token after the +// last comma is completed; earlier tags in the field are left untouched. +// +// Usage: add data-tagcomplete to an , or call TagComplete.attach(input) +// for inputs created dynamically. TagComplete.invalidate() drops the cached +// tag list after an edit that may have introduced new tags. +(function () { + let tags = null; // cached list of tag names + let inflight = null; + + const menu = document.createElement("div"); + menu.className = "tagac-menu"; + menu.style.display = "none"; + menu.setAttribute("role", "listbox"); + document.body.appendChild(menu); + + let items = []; // currently shown suggestions + let sel = -1; // highlighted index + + function loadTags() { + if (tags) return Promise.resolve(tags); + if (inflight) return inflight; + inflight = fetch("/tags.json", { headers: { Accept: "application/json" } }) + .then((r) => (r.ok ? r.json() : [])) + .then((list) => { + tags = (list || []).map((t) => t.tag); + return tags; + }) + .catch(() => { + tags = []; + return tags; + }); + return inflight; + } + + // The token being typed is the text after the last comma. + function lastToken(value) { + return value.slice(value.lastIndexOf(",") + 1).replace(/^\s+/, ""); + } + + function replaceLastToken(value, tag) { + const i = value.lastIndexOf(","); + const head = i < 0 ? "" : value.slice(0, i + 1) + " "; + return head + tag + ", "; + } + + function presentTags(value) { + return value + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + } + + function hide() { + menu.style.display = "none"; + sel = -1; + items = []; + } + + function highlight() { + [...menu.children].forEach((c, i) => c.classList.toggle("sel", i === sel)); + if (sel >= 0 && menu.children[sel]) { + menu.children[sel].scrollIntoView({ block: "nearest" }); + } + } + + function render(input) { + const token = lastToken(input.value).toLowerCase(); + if (!token) { + hide(); + return; + } + const present = new Set(presentTags(input.value)); + items = (tags || []) + .filter((t) => t.toLowerCase().includes(token) && !present.has(t.toLowerCase())) + .slice(0, 8); + if (items.length === 0) { + hide(); + return; + } + sel = 0; + menu.innerHTML = ""; + items.forEach((t, idx) => { + const row = document.createElement("div"); + row.className = "tagac-item" + (idx === 0 ? " sel" : ""); + row.setAttribute("role", "option"); + row.textContent = t; + // mousedown (not click) so it fires before the input's blur. + row.addEventListener("mousedown", (e) => { + e.preventDefault(); + choose(input, idx); + }); + menu.appendChild(row); + }); + const r = input.getBoundingClientRect(); + menu.style.left = window.scrollX + r.left + "px"; + menu.style.top = window.scrollY + r.bottom + "px"; + menu.style.width = r.width + "px"; + menu.style.display = "block"; + } + + function choose(input, idx) { + if (idx < 0 || idx >= items.length) return; + input.value = replaceLastToken(input.value, items[idx]); + hide(); + input.focus(); + input.dispatchEvent(new Event("input", { bubbles: true })); + } + + function attach(input) { + if (!input || input.dataset.tagacBound) return; + input.dataset.tagacBound = "1"; + input.setAttribute("autocomplete", "off"); + input.addEventListener("focus", () => loadTags().then(() => render(input))); + input.addEventListener("input", () => loadTags().then(() => render(input))); + input.addEventListener("keydown", (e) => { + if (menu.style.display === "none" || items.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + sel = (sel + 1) % items.length; + highlight(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + sel = (sel - 1 + items.length) % items.length; + highlight(); + } else if (e.key === "Enter" || e.key === "Tab") { + if (sel >= 0) { + e.preventDefault(); + choose(input, sel); + } + } else if (e.key === "Escape") { + hide(); + } + }); + // Delay so a mousedown on the menu is processed before hiding. + input.addEventListener("blur", () => setTimeout(hide, 120)); + } + + window.TagComplete = { + attach, + invalidate() { + tags = null; + inflight = null; + }, + }; + + document.addEventListener("DOMContentLoaded", () => { + document.querySelectorAll("input[data-tagcomplete]").forEach(attach); + }); +})(); diff --git a/templates/album.html b/templates/album.html new file mode 100644 index 0000000..5419a15 --- /dev/null +++ b/templates/album.html @@ -0,0 +1,97 @@ + + + + + + {{.Tag.Name}} · Photo Gallery + + + + + + + + + +
+
+
+ {{.Initial}} +
+
+

{{.Author}}

+

{{.Handle}}

+

{{.Total}} posts

+

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

+ Timeline + ← Albums +
+
+
+ +
+
+
+

{{.Tag.Name}}{{.Count}}

+
+ {{range .Photos}} + + {{.Caption}} + + {{end}} +
+
+ + {{if or .PrevURL .NextURL}} + + {{end}} +
+
+ +
+ © 2000–{{.Year}} {{.Author}}. All rights reserved. +
+ + + + + diff --git a/templates/albums.html b/templates/albums.html new file mode 100644 index 0000000..a613812 --- /dev/null +++ b/templates/albums.html @@ -0,0 +1,55 @@ + + + + + + Albums · Photo Gallery + + + + + + + + + +
+
+
+ {{.Initial}} +
+
+

{{.Author}}

+

{{.Handle}}

+

{{.Total}} posts

+

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

+ ← Timeline +
+
+
+ +
+
+
+

Albums{{len .Albums}}

+ {{if .Albums}} +
+ {{range .Albums}} + + {{.Tag.Name}}{{.Count}} + + {{end}} +
+ {{else}} +

No albums yet — add tags to a photo to create one.

+ {{end}} +
+
+
+ +
+ © 2000–{{.Year}} {{.Author}}. All rights reserved. +
+ + + diff --git a/templates/day.html b/templates/day.html index 1052fe2..0672a26 100644 --- a/templates/day.html +++ b/templates/day.html @@ -46,5 +46,6 @@
© 2000–{{.Year}} {{.Author}}. All rights reserved.
+ diff --git a/templates/delete.html b/templates/delete.html deleted file mode 100644 index 5247e57..0000000 --- a/templates/delete.html +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - 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 deleted file mode 100644 index f167b81..0000000 --- a/templates/edit.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - Edit — {{.Photo.Caption}} - - - - - -
- ← Manage -

Edit photo

- - -

{{.Photo.Slug}}

- - -
-
- - -
- -
- - -
- - -
-
- - diff --git a/templates/grid.html b/templates/grid.html index 2955f01..ea9a0d6 100644 --- a/templates/grid.html +++ b/templates/grid.html @@ -24,13 +24,14 @@

{{.Total}} posts

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

Timeline + Albums - RSS feed + Feed - Manage — Photo Gallery - + + + +
- ← Gallery -

Manage photos

-

{{len .Photos}} {{if eq (len .Photos) 1}}photo{{else}}photos{{end}}

+
+

Manage posts

+ + Upload   Trash   ← Gallery +
+
Loading…
- {{if .Saved}}

✓ saved “{{.Saved}}”

{{end}} - {{if .Deleted}}

✓ deleted “{{.Deleted}}”

{{end}} +
+
+ Trash + + +
+
+ +
- {{if .Photos}} -
    - {{range .Photos}} -
  • - -
    -

    {{.Caption}}

    -

    {{.Date.Format "2006-01-02"}}

    -
    -
    - View - Edit - Delete -
    -
  • - {{end}} -
- {{else}} -

No photos yet.

- {{end}} +
+ 0 selected + + + + + +
+ +
+
+ +
+ + + + diff --git a/templates/photo.html b/templates/photo.html index f6b9d87..0259415 100644 --- a/templates/photo.html +++ b/templates/photo.html @@ -35,7 +35,7 @@ {{if .Older}} {{end}} - +

{{.Photo.Caption}}

{{.Photo.Date.Format "Jan 02, 2006"}}

+ {{if .Photo.Tags}} +
+ {{range .Photo.Tags}}{{.Name}}{{end}} +
+ {{end}}

↓ Full size

@@ -68,5 +73,6 @@ + diff --git a/templates/timeline.html b/templates/timeline.html index fbd353f..0f79e31 100644 --- a/templates/timeline.html +++ b/templates/timeline.html @@ -24,13 +24,14 @@

{{.Total}} posts

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

Grid + Albums - RSS feed + Feed
← Gallery -

Upload photo

+

Upload photos

-
- -
- -

Click to choose a photo

- jpg or png -
- Preview -
Click to change
+ + + +
No photos selected
+
+ + - -
- - -
- -
- - -
- - -
+ + diff --git a/trash.go b/trash.go new file mode 100644 index 0000000..0d3f07a --- /dev/null +++ b/trash.go @@ -0,0 +1,206 @@ +package main + +import ( + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// trashStamp is the timestamp moveToTrash appends: 20060102-150405. +var trashStamp = regexp.MustCompile(`^\d{8}-\d{6}$`) + +// trashItem is one recoverable post in content/.trash/, grouping the image and +// its sidecar that were trashed together (same slug, same timestamp). +type trashItem struct { + ID string `json:"id"` // ".", the restore/empty handle + Slug string `json:"slug"` // original slug it would restore to + DeletedAt string `json:"deletedAt"` + Image string `json:"image"` // trash filename of the image, "" if only a sidecar + HasMeta bool `json:"hasMeta"` // whether a sidecar is present +} + +func (s *Server) trashDir() string { return filepath.Join(s.contentDir, ".trash") } + +// parseTrashName splits a trash filename "." into its parts. +// slug is a bare sanitised name (no dots) and stamp is a fixed timestamp, so +// the extension and the stamp peel off unambiguously from the right. +func parseTrashName(name string) (slug, stamp, ext string, ok bool) { + ext = filepath.Ext(name) + rest := strings.TrimSuffix(name, ext) // "." + i := strings.LastIndex(rest, ".") + if i < 0 { + return "", "", "", false + } + slug, stamp = rest[:i], rest[i+1:] + if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") { + return "", "", "", false + } + if !trashStamp.MatchString(stamp) { + return "", "", "", false + } + return slug, stamp, ext, true +} + +func isImageExt(ext string) bool { + for _, e := range contentExts { + if ext == e { + return true + } + } + return false +} + +// handleListTrash returns trashed items, newest first. +func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) { + entries, err := os.ReadDir(s.trashDir()) + if err != nil { + // No trash dir yet → empty list, not an error. + writeJSON(w, []trashItem{}) + return + } + + byID := make(map[string]*trashItem) + for _, e := range entries { + if e.IsDir() { + continue + } + slug, stamp, ext, ok := parseTrashName(e.Name()) + if !ok { + continue + } + id := slug + "." + stamp + it := byID[id] + if it == nil { + when := stamp + if t, err := time.ParseInLocation("20060102-150405", stamp, time.Local); err == nil { + when = t.Format("2006-01-02 15:04") + } + it = &trashItem{ID: id, Slug: slug, DeletedAt: when} + byID[id] = it + } + if ext == ".toml" { + it.HasMeta = true + } else if isImageExt(ext) { + it.Image = e.Name() + } + } + + items := make([]trashItem, 0, len(byID)) + for _, it := range byID { + items = append(items, *it) + } + // Newest first: the stamp sorts lexically in time order, and it is the tail + // of the ID. + sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID }) + writeJSON(w, items) +} + +// splitTrashID validates a "." handle and returns its parts. +func splitTrashID(id string) (slug, stamp string, ok bool) { + i := strings.LastIndex(id, ".") + if i < 0 { + return "", "", false + } + slug, stamp = id[:i], id[i+1:] + if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") { + return "", "", false + } + if !trashStamp.MatchString(stamp) { + return "", "", false + } + return slug, stamp, true +} + +// trashFiles returns the files in .trash belonging to one id (image + sidecar). +func (s *Server) trashFiles(slug, stamp string) []string { + var out []string + prefix := slug + "." + stamp + for _, ext := range append(append([]string{}, contentExts...), ".toml") { + p := filepath.Join(s.trashDir(), prefix+ext) + if _, err := os.Stat(p); err == nil { + out = append(out, p) + } + } + return out +} + +// handleRestore moves a trashed post back into content/, stripping the +// timestamp. It refuses to overwrite a live post with the same slug. +func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { + if s.rejectCrossSite(w, r) { + return + } + if err := parseForm(r); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + slug, stamp, ok := splitTrashID(r.FormValue("id")) + if !ok { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + if _, live := s.resolveSlug(slug); live { + http.Error(w, "a post with that slug already exists", http.StatusConflict) + return + } + + files := s.trashFiles(slug, stamp) + if len(files) == 0 { + http.Error(w, "not in trash", http.StatusNotFound) + return + } + for _, src := range files { + name := filepath.Base(src) + trashPrefix := slug + "." + stamp // "." + // "..png" → ".png"; "..toml" → ".toml" + dstName := slug + strings.TrimPrefix(name, trashPrefix) + if err := os.Rename(src, filepath.Join(s.contentDir, dstName)); err != nil { + log.Printf("restore %s: %v", name, err) + http.Error(w, "could not restore", http.StatusInternalServerError) + return + } + } + s.rebuild() + w.WriteHeader(http.StatusOK) +} + +// handleEmptyTrash permanently deletes trashed items — specific ids (repeated +// `id` fields), or everything with `all=1`. This is the only hard delete. +func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) { + if s.rejectCrossSite(w, r) { + return + } + if err := parseForm(r); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + if r.FormValue("all") == "1" { + if err := os.RemoveAll(s.trashDir()); err != nil { + log.Printf("empty trash: %v", err) + http.Error(w, "could not empty trash", http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"emptied": "all"}) + return + } + + removed := 0 + for _, id := range r.Form["id"] { + slug, stamp, ok := splitTrashID(id) + if !ok { + continue + } + for _, p := range s.trashFiles(slug, stamp) { + if err := os.Remove(p); err == nil { + removed++ + } + } + } + writeJSON(w, map[string]int{"removed": removed}) +} diff --git a/trash_test.go b/trash_test.go new file mode 100644 index 0000000..51a7888 --- /dev/null +++ b/trash_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" +) + +func listTrash(t *testing.T, s *Server) []trashItem { + t.Helper() + rec := httptest.NewRecorder() + s.handleListTrash(rec, httptest.NewRequest(http.MethodGet, "/api/trash", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("list trash: code=%d", rec.Code) + } + var items []trashItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("decode trash: %v", err) + } + return items +} + +func TestTrashListAndRestore(t *testing.T) { + s, content, _ := newTestServer(t) + + // Delete foo, then it should show up in the trash. + rec := httptest.NewRecorder() + s.handleDelete(rec, form(url.Values{"slug": {"foo"}})) + if rec.Code != http.StatusOK { + t.Fatalf("delete: %d", rec.Code) + } + + items := listTrash(t, s) + if len(items) != 1 { + t.Fatalf("trash has %d items, want 1", len(items)) + } + it := items[0] + if it.Slug != "foo" || !it.HasMeta || it.Image == "" { + t.Errorf("trash item wrong: %+v", it) + } + + // Restore it. + rec = httptest.NewRecorder() + s.handleRestore(rec, form(url.Values{"id": {it.ID}})) + if rec.Code != http.StatusOK { + t.Fatalf("restore: code=%d body=%s", rec.Code, rec.Body) + } + for _, p := range []string{"foo.png", "foo.toml"} { + if _, err := os.Stat(filepath.Join(content, p)); err != nil { + t.Errorf("restored %s missing: %v", p, err) + } + } + if items := listTrash(t, s); len(items) != 0 { + t.Errorf("trash not empty after restore: %+v", items) + } + // Caption survived the round trip. + if readMeta(t, content, "foo").Caption != "Foo" { + t.Error("restored sidecar lost its caption") + } +} + +func TestRestoreRefusesLiveCollision(t *testing.T) { + s, content, _ := newTestServer(t) + + rec := httptest.NewRecorder() + s.handleDelete(rec, form(url.Values{"slug": {"foo"}})) + if rec.Code != http.StatusOK { + t.Fatalf("delete: %d", rec.Code) + } + id := listTrash(t, s)[0].ID + + // A new live post now occupies the slug. + writePNG(t, filepath.Join(content, "foo.png")) + + rec = httptest.NewRecorder() + s.handleRestore(rec, form(url.Values{"id": {id}})) + if rec.Code != http.StatusConflict { + t.Errorf("restore over live post: code=%d, want 409", rec.Code) + } + // The trashed copy is left intact for a manual decision. + if len(listTrash(t, s)) != 1 { + t.Error("trashed item should remain after refused restore") + } +} + +func TestEmptyTrash(t *testing.T) { + s, _, _ := newTestServer(t) + + // Trash both posts. + rec := httptest.NewRecorder() + s.handleDelete(rec, form(url.Values{"slugs": {"foo,bar"}})) + if rec.Code != http.StatusOK { + t.Fatalf("delete: %d", rec.Code) + } + items := listTrash(t, s) + if len(items) != 2 { + t.Fatalf("trash has %d, want 2", len(items)) + } + + // Empty one by id. + rec = httptest.NewRecorder() + s.handleEmptyTrash(rec, form(url.Values{"id": {items[0].ID}})) + if rec.Code != http.StatusOK { + t.Fatalf("empty one: %d", rec.Code) + } + if len(listTrash(t, s)) != 1 { + t.Error("expected 1 item left after deleting one") + } + + // Empty all. + rec = httptest.NewRecorder() + s.handleEmptyTrash(rec, form(url.Values{"all": {"1"}})) + if rec.Code != http.StatusOK { + t.Fatalf("empty all: %d", rec.Code) + } + if len(listTrash(t, s)) != 0 { + t.Error("trash not empty after all") + } +} + +func TestSplitTrashIDRejectsJunk(t *testing.T) { + cases := map[string]bool{ + "foo.20260102-150405": true, + "foo.bar.20260102-150405": false, // slug "foo.bar" has a dot → rejected + "../x.20260102-150405": false, // traversal + "foo.2026": false, // malformed stamp + "foo": false, // no stamp + "": false, + } + for id, want := range cases { + if _, _, ok := splitTrashID(id); ok != want { + t.Errorf("splitTrashID(%q) = %v, want %v", id, ok, want) + } + } +} diff --git a/watcher.go b/watcher.go index 4ec8efb..800187f 100644 --- a/watcher.go +++ b/watcher.go @@ -30,6 +30,12 @@ func NewWatcher(dir string, g *Generator) (*Watcher, error) { // Watch blocks, watching for changes and triggering rebuilds. // Rapid events (e.g. scp writing an image then its sidecar) are debounced // with a 500 ms window so only one rebuild fires per logical upload. +// +// Rename is watched alongside Create/Write/Remove: moving a photo out of the +// content directory — which is exactly what a /manage delete does when it +// sends files to content/.trash/ — arrives as a Rename (inotify IN_MOVED_FROM) +// on Linux, not a Remove, so without it a delete (or a manual mv) would leave +// the site unrebuilt. func (w *Watcher) Watch() { var timer *time.Timer rebuild := func() { @@ -47,7 +53,8 @@ func (w *Watcher) Watch() { if !isRelevant(event.Name) { continue } - if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) { + if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || + event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) { if timer != nil { timer.Stop() }