Merge trunk into trunk-ng

# Conflicts:
#	CLAUDE.md
#	README.md
#	generator.go
#	main.go
#	manage.go
#	manage_test.go
#	module.nix
#	server.go
#	templates/grid.html
#	templates/manage.html
#	templates/timeline.html
This commit is contained in:
puttaalu committed 2026-10-19 17:04:58 +02:00
commit 3614cfe1f8
44 files changed
+3641 -1211

No files matched your search

+113 -24
View File
@@ -19,23 +19,91 @@ The binary does three things at once:
- Serves a multipart upload API at `POST /upload` - Serves a multipart upload API at `POST /upload`
- In dev mode (`--serve`), also serves `public/` as static files - In dev mode (`--serve`), also serves `public/` as static files
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`, 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 <file>` 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. `GET /api/session` is a deliberately **unguarded** probe (`handleSession`): it
- Slugs from the URL are matched against the photos actually in `content/`, never joined into a path. reads `r.BasicAuth()`, returns `200 {"authenticated":bool}` and never sends
- `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`. `WWW-Authenticate`, so a public page can ask without triggering the browser's
- Delete is permanent and takes two steps: `GET /manage/delete/{slug}` only shows a confirmation page, the `POST` does the work. `Generator.Delete` removes the image and sidecar from `content/` first, then the four renditions and `public/photo/<slug>/`, all under the build mutex. There is no trash folder — recovery means restoring from a backup. login dialog. To stop it becoming a password oracle that sidesteps the limiter,
- Listing pages are put right by the rebuild that follows, which also prunes what a delete or a date change leaves behind: `renderDayPages` drops `public/day/<date>/` for days without photos, and `prunePages` drops pager pages past the last one. Renditions of photos removed from `content/` by hand (not via `/manage`) are still **not** pruned. 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=<slug>`, 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/<slug>.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>.<stamp>`, `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>.<stamp><ext>` (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 ## 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` - **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. - **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`) - **Feed** — Atom (`encoding/xml`)
@@ -46,22 +114,38 @@ In production nginx sits in front: serves `public/` directly, proxies `/upload`
```toml ```toml
caption = "Tempelhof at sunrise" caption = "Tempelhof at sunrise"
date = "2026-06-14" date = "2026-06-14"
tags = ["berlin", "sunrise"]
``` ```
Missing sidecar → filename as caption, today as date. Photos sort newest first by that date. 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 ## Design
- Gruvbox dark hard colour scheme - Gruvbox dark hard colour scheme
- Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url` - Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url`
- Manjari for Malayalam text (bio line), via `-ml-family` - 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`): - 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 `/<name>/`. `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 is the site root by default** (`/`, `/page/2/`, …). Photos grouped by calendar day: one ISO date heading (`2026-06-14`) + count per day, then the square grid. `groupByDay` collapses the newest-first list into consecutive same-day runs; `chunkDayGroups` packs whole days into pages targeting `pageSize`, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this). - **Timeline** (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** (`/grid/`, `/grid/page/2/`, …) is the flat square grid, linked from the profile header. - **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/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug. - Each timeline date heading links to a **single-day page** (`/day/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug.
- Which view owns the site root is set by `-default-view timeline|grid` (NixOS `services.photogallery.defaultView`). The other view moves under `/<name>/`: with `-default-view grid` the grid is `/` and the timeline is `/timeline/`. `viewBase` and `pageURL` in `generator.go` derive every page path and pager link from that, and the templates take the cross-links as `GridURL` / `TimelineURL` — never hardcode `/grid/` or `/`. On each build the directory named after the default view is removed, since it can only be a leftover from before a switch.
- Single photo page with sidebar and newer/older navigation - 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/<slug>/` (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 ### Link previews
Photo pages carry Open Graph + Twitter-card meta tags so a shared link unfurls Photo pages carry Open Graph + Twitter-card meta tags so a shared link unfurls
@@ -108,7 +192,7 @@ go run . --serve
## Android app ## 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/<slug>` (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: 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 = { services.photogallery = {
enable = true; enable = true;
baseURL = "https://photos.yourdomain.tld"; 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 = { nginx = {
enable = true; enable = true;
domain = "photos.yourdomain.tld"; domain = "photos.yourdomain.tld";
htpasswdFile = "/etc/nginx/.htpasswd-gallery";
}; };
}; };
``` ```
@@ -153,16 +239,20 @@ nix build # succeeds
|---|---| |---|---|
| `main.go` | Flags, wires generator + watcher + server | | `main.go` | Flags, wires generator + watcher + server |
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums | | `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
| `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` |
| `server.go` | Upload API + optional static file serving | | `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 | | `watcher.go` | fsnotify watcher with 500ms debounce |
| `assets.go` | `//go:embed` for templates and static | | `assets.go` | `//go:embed` for templates and static |
| `templates/timeline.html` | Site root: photos grouped by day, paginated | | `templates/timeline.html` | Site root: photos grouped by day, paginated |
| `templates/grid.html` | Flat square grid at `/grid/`, paginated | | `templates/grid.html` | Flat square grid at `/grid/`, paginated |
| `templates/day.html` | Single-day page at `/day/<date>/` | | `templates/day.html` | Single-day page at `/day/<date>/` |
| `templates/album.html` | Album (tag) page at `/album/<slug>/`, paginated |
| `templates/albums.html` | Album index at `/albums/` |
| `templates/photo.html` | Single photo page, neighbour links and prefetch | | `templates/photo.html` | Single photo page, neighbour links and prefetch |
| `templates/manage.html` | `/manage`: list of photos with edit links |
| `templates/edit.html` | `/manage/edit/{slug}`: caption and date form |
| `static/photo-nav.js` | Arrow-key and swipe navigation | | `static/photo-nav.js` | Arrow-key and swipe navigation |
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) | | `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
| `flake.nix` | `buildGoModule` package + devShell + nixosModules | | `flake.nix` | `buildGoModule` package + devShell + nixosModules |
@@ -175,8 +265,7 @@ nix build # succeeds
## What's not done yet ## What's not done yet
- ActivityPub federation (planned later — start with RSS/Atom) - ActivityPub federation (planned later — start with RSS/Atom)
- Multi-image upload - 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.
- Pruning renditions and photo pages of photos deleted from `content/` by hand (delete via `/manage` cleans up after itself) - Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes)
- Any auth beyond nginx htpasswd
- The Android app has never been run on real hardware - The Android app has never been run on real hardware
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part - Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
+78 -34
View File
@@ -1,12 +1,50 @@
# photogallery # 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/<date>/`) linked from each timeline date heading.
- **Albums**: tag photos with free-text, lowercase tags; each tag is an album at `/album/<tag>/`, 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 ## How it works
1. Drop `photo.jpg` + `photo.toml` into the content directory (or upload via the web UI at `/upload`). 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. 2. The binary watches the directory and rebuilds the static site on any change (and on every upload/edit/delete).
3. nginx serves `public/` and proxies `/upload` to the local API, secured with htpasswd. 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 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. and an aspect-preserved medium JPEG (longest side 1600) for the detail page.
@@ -23,9 +61,13 @@ editable before submitting.
```toml ```toml
caption = "Tempelhof at sunrise" caption = "Tempelhof at sunrise"
date = "2026-06-14" 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; enable = true;
baseURL = "https://photos.yourdomain.tld"; 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 # Optional: identity shown on the header and copyright footer
author = "Ada Lovelace"; author = "Ada Lovelace";
handle = "@ada@example.com"; handle = "@ada@example.com";
@@ -69,23 +119,19 @@ services.photogallery = {
serifFamily = "EB Garamond"; serifFamily = "EB Garamond";
mlFamily = "Manjari"; mlFamily = "Manjari";
# Optional: edit and delete published photos at /manage (off by default) # Optional: built-in nginx vhost (TLS + proxy only; auth is app-side above)
manage.enable = true;
# Optional: built-in nginx vhost
nginx = { nginx = {
enable = true; enable = true;
domain = "photos.yourdomain.tld"; domain = "photos.yourdomain.tld";
htpasswdFile = "/etc/nginx/.htpasswd-gallery";
}; };
}; };
``` ```
Create the htpasswd file: Create the htpasswd file (**bcrypt** — the app only accepts `-B` entries):
```bash ```bash
nix-shell -p apacheHttpd --run \ nix-shell -p apacheHttpd --run \
"htpasswd -c /etc/nginx/.htpasswd-gallery youruser" "htpasswd -B -c /run/secrets/photogallery-htpasswd youruser"
``` ```
### Available options ### Available options
@@ -97,6 +143,9 @@ nix-shell -p apacheHttpd --run \
| `contentDir` | `/var/lib/photogallery/content` | Images and .toml sidecars | | `contentDir` | `/var/lib/photogallery/content` | Images and .toml sidecars |
| `outputDir` | `/var/lib/photogallery/public` | Generated site output | | `outputDir` | `/var/lib/photogallery/public` | Generated site output |
| `port` | `8080` | Upload API port (localhost only) | | `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 | | `author` | `Ashik Salahudeen` | Name shown on the header, photo pages and copyright footer |
| `handle` | `@puttaalu@inflo.ws` | Handle line under the author name | | `handle` | `@puttaalu@inflo.ws` | Handle line under the author name |
| `bio` | `Personal photo journal.` | Primary bio line | | `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 | | `fontsURL` | Crimson Pro + Manjari | Stylesheet URL loading both web fonts |
| `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif | | `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif |
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio | | `mlFamily` | `Manjari` | CSS font-family for the alt-script bio |
| `pageSize` | `60` | Photos per page on the timeline and grid views | | `nginx.enable` | `false` | Configure an nginx vhost (TLS + proxy) |
| `defaultView` | `timeline` | View at the site root, `timeline` or `grid`; the other is served under `/timeline/` or `/grid/` |
| `manage.enable` | `false` | Serve the `/manage` pages (edit, delete) and, with `nginx.enable`, protect them with the same htpasswd |
| `nginx.enable` | `false` | Configure an nginx vhost |
| `nginx.domain` | — | Domain for the vhost | | `nginx.domain` | — | Domain for the vhost |
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` and `/manage` |
| `nginx.enableACME` | `true` | Enable Let's Encrypt | | `nginx.enableACME` | `true` | Enable Let's Encrypt |
--- ---
@@ -140,24 +185,23 @@ curl -u user:password \
-F "image=@photo.jpg" \ -F "image=@photo.jpg" \
-F "caption=Tempelhof at sunrise" \ -F "caption=Tempelhof at sunrise" \
-F "date=2026-06-14" \ -F "date=2026-06-14" \
-F "tags=berlin, sunrise" \
https://photos.yourdomain.tld/upload 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: Both use the same API (HTTP Basic auth) and need no server change.
`services.photogallery.manage.enable = true`). Then open
`https://photos.yourdomain.tld/manage` in a browser. It lists every
photo; *Edit* opens a form for the caption and date, and saving rewrites the
photo's `.toml` sidecar, which triggers a rebuild.
*Delete* asks for confirmation, then removes the image, its sidecar and - **Android** (`android/`) — Kotlin/Compose. Pick or share one or many photos,
everything generated from them. This is permanent — there is no trash folder, set caption/date/tags, uploads run in a durable background queue. A Manage
so keep a backup of the content directory. screen lists published posts (with thumbnails) and edits their
caption/date/tags or deletes them to the server's trash, over the same
`/manage` sits behind the same htpasswd as `/upload`, and nothing on the `/api/*` endpoints the web `/manage` uses. Has its own flake;
public site links to it. If you run your own nginx instead of the NixOS `cd android && nix develop && ./gradlew assembleRelease`.
module, add a `location /manage` block alongside the `/upload` one — without - **Qt desktop** (`desktop/`) — Qt6/C++. Bulk upload with configurable
it the path is not proxied and returns 404. concurrency, HEIC support, credentials stored in the system keychain. Builds
via its own flake in `desktop/`.
+206
View File
@@ -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/<slug>/,
// 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)
}
+7 -4
View File
@@ -1,11 +1,14 @@
# Gallery Upload — Android client # Gallery Upload — Android client
Native uploader for the photogallery `POST /upload` endpoint. Pick or share a Native uploader and manager for the photogallery server. Pick or share one or
photo, add a caption and date, post. Credentials are entered once and sealed many photos, add caption/date/tags, and post. A **Manage** screen lists the
with a key held in the Android Keystore. 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 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 ## Build
@@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import ws.inflo.photogallery.ui.ManageScreen
import ws.inflo.photogallery.ui.PhotoGalleryTheme import ws.inflo.photogallery.ui.PhotoGalleryTheme
import ws.inflo.photogallery.ui.PostScreen import ws.inflo.photogallery.ui.PostScreen
import ws.inflo.photogallery.ui.SettingsScreen import ws.inflo.photogallery.ui.SettingsScreen
@@ -37,9 +38,13 @@ class MainActivity : ComponentActivity() {
PhotoGalleryTheme { PhotoGalleryTheme {
// Land on settings until there is somewhere to post to. // Land on settings until there is somewhere to post to.
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) } 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( val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(), ActivityResultContracts.PickMultipleVisualMedia(maxItems = 50),
) { uris -> if (uris.isNotEmpty()) viewModel.selectImages(uris) } ) { uris -> if (uris.isNotEmpty()) viewModel.selectImages(uris) }
val notificationPermission = rememberLauncherForActivityResult( val notificationPermission = rememberLauncherForActivityResult(
@@ -57,8 +62,8 @@ class MainActivity : ComponentActivity() {
} }
} }
if (showSettings) { when {
SettingsScreen( showSettings -> SettingsScreen(
state = viewModel.config, state = viewModel.config,
onBaseUrlChange = viewModel::setBaseUrl, onBaseUrlChange = viewModel::setBaseUrl,
onUsernameChange = viewModel::setUsername, onUsernameChange = viewModel::setUsername,
@@ -70,8 +75,17 @@ class MainActivity : ComponentActivity() {
null 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, state = viewModel.post,
onPickImages = { onPickImages = {
picker.launch( picker.launch(
@@ -81,11 +95,16 @@ class MainActivity : ComponentActivity() {
) )
}, },
onCaptionChange = viewModel::setCaption, onCaptionChange = viewModel::setCaption,
onTagsChange = viewModel::setTags,
onDateChange = viewModel::setDate, onDateChange = viewModel::setDate,
onRemove = viewModel::removeItem, onRemove = viewModel::removeItem,
onSubmit = viewModel::submit, onSubmit = viewModel::submit,
onReset = viewModel::clear, onReset = viewModel::clear,
onOpenSettings = { showSettings = true }, onOpenSettings = { showSettings = true },
onOpenManage = {
viewModel.loadManage()
showManage = true
},
onOpenUrl = ::openUrl, onOpenUrl = ::openUrl,
) )
} }
@@ -1,6 +1,9 @@
package ws.inflo.photogallery.net package ws.inflo.photogallery.net
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import okhttp3.Credentials import okhttp3.Credentials
import okhttp3.FormBody
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
@@ -8,6 +11,8 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response import okhttp3.Response
import org.json.JSONArray
import org.json.JSONException
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -25,6 +30,22 @@ sealed interface UploadOutcome {
data class Transient(val message: String) : 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<String>,
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<RemotePhoto>) : ListResult
data class Err(val message: String) : ListResult
}
/** /**
* Talks to the gallery's POST /upload endpoint. * Talks to the gallery's POST /upload endpoint.
* *
@@ -60,6 +81,7 @@ class UploadClient(
fileName: String, fileName: String,
caption: String, caption: String,
date: String, date: String,
tags: String,
): UploadOutcome { ): UploadOutcome {
val mediaType = if (fileName.endsWith(".png", ignoreCase = true)) { val mediaType = if (fileName.endsWith(".png", ignoreCase = true)) {
"image/png".toMediaType() "image/png".toMediaType()
@@ -72,6 +94,7 @@ class UploadClient(
.addFormDataPart("image", fileName, file.asRequestBody(mediaType)) .addFormDataPart("image", fileName, file.asRequestBody(mediaType))
.addFormDataPart("caption", caption) .addFormDataPart("caption", caption)
.addFormDataPart("date", date) .addFormDataPart("date", date)
.addFormDataPart("tags", tags)
.build() .build()
val request = Request.Builder() 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<RemotePhoto>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val tagsArr = o.optJSONArray("tags")
val tags = ArrayList<String>()
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 <slug>.<ext>"; recover the slug from that. */ /** Server replies "uploaded <slug>.<ext>"; recover the slug from that. */
private fun parseSlug(responseBody: String, fallback: String): String { private fun parseSlug(responseBody: String, fallback: String): String {
val name = responseBody.trim().removePrefix("uploaded ").trim() val name = responseBody.trim().removePrefix("uploaded ").trim()
@@ -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<ManagePost?>(null) }
var deleting by remember { mutableStateOf<ManagePost?>(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)
}
}
@@ -51,11 +51,13 @@ fun PostScreen(
state: PostUiState, state: PostUiState,
onPickImages: () -> Unit, onPickImages: () -> Unit,
onCaptionChange: (id: String, value: String) -> Unit, onCaptionChange: (id: String, value: String) -> Unit,
onTagsChange: (id: String, value: String) -> Unit,
onDateChange: (id: String, value: LocalDate) -> Unit, onDateChange: (id: String, value: LocalDate) -> Unit,
onRemove: (id: String) -> Unit, onRemove: (id: String) -> Unit,
onSubmit: () -> Unit, onSubmit: () -> Unit,
onReset: () -> Unit, onReset: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenManage: () -> Unit,
onOpenUrl: (String) -> Unit, onOpenUrl: (String) -> Unit,
) { ) {
// Which item's date is being edited, if any. // Which item's date is being edited, if any.
@@ -77,7 +79,10 @@ fun PostScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("Post photos", style = MaterialTheme.typography.headlineSmall, color = GruvGreen) 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)) Spacer(Modifier.height(12.dp))
} }
@@ -107,6 +112,7 @@ fun PostScreen(
ItemCard( ItemCard(
item = item, item = item,
onCaptionChange = { onCaptionChange(item.id, it) }, onCaptionChange = { onCaptionChange(item.id, it) },
onTagsChange = { onTagsChange(item.id, it) },
onEditDate = { dateEditFor = item }, onEditDate = { dateEditFor = item },
onRemove = { onRemove(item.id) }, onRemove = { onRemove(item.id) },
onOpenUrl = onOpenUrl, onOpenUrl = onOpenUrl,
@@ -185,6 +191,7 @@ fun PostScreen(
private fun ItemCard( private fun ItemCard(
item: PickedItem, item: PickedItem,
onCaptionChange: (String) -> Unit, onCaptionChange: (String) -> Unit,
onTagsChange: (String) -> Unit,
onEditDate: () -> Unit, onEditDate: () -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
onOpenUrl: (String) -> Unit, onOpenUrl: (String) -> Unit,
@@ -220,6 +227,16 @@ private fun ItemCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(6.dp)) 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) { Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton( OutlinedButton(
onClick = onEditDate, onClick = onEditDate,
@@ -17,11 +17,14 @@ import kotlinx.coroutines.withContext
import ws.inflo.photogallery.data.LastPost import ws.inflo.photogallery.data.LastPost
import ws.inflo.photogallery.data.SettingsStore import ws.inflo.photogallery.data.SettingsStore
import ws.inflo.photogallery.image.ImagePrep 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.net.UploadClient
import ws.inflo.photogallery.work.UploadQueue import ws.inflo.photogallery.work.UploadQueue
import ws.inflo.photogallery.work.UploadWorker import ws.inflo.photogallery.work.UploadWorker
import java.io.File import java.io.File
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.UUID import java.util.UUID
/** Per-photo status within a batch. */ /** Per-photo status within a batch. */
@@ -41,6 +44,7 @@ data class PickedItem(
val source: Uri, val source: Uri,
val preview: Bitmap? = null, val preview: Bitmap? = null,
val caption: String = "", val caption: String = "",
val tags: String = "", // raw comma-separated; the server normalizes
val date: LocalDate = LocalDate.now(), val date: LocalDate = LocalDate.now(),
val status: ItemStatus = ItemStatus.Pending, val status: ItemStatus = ItemStatus.Pending,
) )
@@ -59,6 +63,25 @@ data class PostUiState(
items.all { it.status is ItemStatus.Success || it.status is ItemStatus.Error } 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<String>,
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<ManagePost> = emptyList(),
val error: String? = null,
val message: String? = null,
)
data class SettingsUiState( data class SettingsUiState(
val baseUrl: String = "", val baseUrl: String = "",
val username: String = "", val username: String = "",
@@ -114,6 +137,10 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
updateItem(id) { it.copy(caption = value) } updateItem(id) { it.copy(caption = value) }
} }
fun setTags(id: String, value: String) {
updateItem(id) { it.copy(tags = value) }
}
fun setDate(id: String, value: LocalDate) { fun setDate(id: String, value: LocalDate) {
updateItem(id) { it.copy(date = value) } updateItem(id) { it.copy(date = value) }
} }
@@ -159,6 +186,7 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
uri = Uri.fromFile(staged), uri = Uri.fromFile(staged),
caption = current.caption, caption = current.caption,
date = current.date, date = current.date,
tags = current.tags,
uniqueSuffix = item.id.take(6), uniqueSuffix = item.id.take(6),
) )
observe(item.id, workId) 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 }) 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<RemotePhoto>) {
for (p in photos) {
viewModelScope.launch {
// Fetch via the authed /api/thumb/<slug> 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<String> =
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 ───────────────────────────────────────────────────── // ── Settings screen ─────────────────────────────────────────────────────
fun setBaseUrl(value: String) { fun setBaseUrl(value: String) {
@@ -34,6 +34,7 @@ object UploadQueue {
uri: Uri, uri: Uri,
caption: String, caption: String,
date: LocalDate, date: LocalDate,
tags: String,
uniqueSuffix: String, uniqueSuffix: String,
): UUID { ): UUID {
val slug = LocalDateTime.now().format(SLUG_FORMAT) + "-" + uniqueSuffix val slug = LocalDateTime.now().format(SLUG_FORMAT) + "-" + uniqueSuffix
@@ -45,6 +46,7 @@ object UploadQueue {
.putString(UploadWorker.KEY_SLUG, slug) .putString(UploadWorker.KEY_SLUG, slug)
.putString(UploadWorker.KEY_CAPTION, caption) .putString(UploadWorker.KEY_CAPTION, caption)
.putString(UploadWorker.KEY_DATE, date.format(ISO_DATE)) .putString(UploadWorker.KEY_DATE, date.format(ISO_DATE))
.putString(UploadWorker.KEY_TAGS, tags)
.build(), .build(),
) )
.setConstraints( .setConstraints(
@@ -38,6 +38,7 @@ class UploadWorker(
val slug = inputData.getString(KEY_SLUG) ?: return@withContext Result.failure() val slug = inputData.getString(KEY_SLUG) ?: return@withContext Result.failure()
val caption = inputData.getString(KEY_CAPTION).orEmpty() val caption = inputData.getString(KEY_CAPTION).orEmpty()
val date = inputData.getString(KEY_DATE).orEmpty() val date = inputData.getString(KEY_DATE).orEmpty()
val tags = inputData.getString(KEY_TAGS).orEmpty()
val settings = SettingsStore(applicationContext) val settings = SettingsStore(applicationContext)
if (!settings.isConfigured) { if (!settings.isConfigured) {
@@ -66,6 +67,7 @@ class UploadWorker(
fileName = prepared.file.name, fileName = prepared.file.name,
caption = caption, caption = caption,
date = date, date = date,
tags = tags,
) )
// Keep the staged and prepared copies only while a retry might still // 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_SLUG = "slug"
const val KEY_CAPTION = "caption" const val KEY_CAPTION = "caption"
const val KEY_DATE = "date" const val KEY_DATE = "date"
const val KEY_TAGS = "tags"
const val KEY_RESULT_SLUG = "result_slug" const val KEY_RESULT_SLUG = "result_slug"
const val KEY_RESULT_URL = "result_url" const val KEY_RESULT_URL = "result_url"
+102
View File
@@ -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)
}
}
+144
View File
@@ -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")
}
}
+108
View File
@@ -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)
}
+1 -1
View File
@@ -313,7 +313,7 @@ void MainWindow::startItem(PhotoItemWidget *item)
refreshPosting(); refreshPosting();
}); });
client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(), client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(),
caption, item->date()); caption, item->date(), item->tags());
}); });
watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() { watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() {
return ImagePrep::prepare(source, cacheDir, slug); return ImagePrep::prepare(source, cacheDir, slug);
+10
View File
@@ -47,6 +47,10 @@ PhotoItemWidget::PhotoItemWidget(QString sourcePath, QWidget *parent)
m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd")); m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
fields->addWidget(m_date); 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 = new QLabel;
m_statusLabel->setOpenExternalLinks(true); m_statusLabel->setOpenExternalLinks(true);
m_statusLabel->setWordWrap(true); m_statusLabel->setWordWrap(true);
@@ -70,6 +74,11 @@ QString PhotoItemWidget::date() const
return m_date->date().toString(QStringLiteral("yyyy-MM-dd")); 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) void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url)
{ {
m_status = status; 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; const bool editable = status == Status::Pending || status == Status::Error;
m_caption->setEnabled(editable); m_caption->setEnabled(editable);
m_date->setEnabled(editable); m_date->setEnabled(editable);
m_tags->setEnabled(editable);
m_remove->setEnabled(status != Status::Preparing && status != Status::Uploading); m_remove->setEnabled(status != Status::Preparing && status != Status::Uploading);
switch (status) { switch (status) {
+2
View File
@@ -21,6 +21,7 @@ public:
QString sourcePath() const { return m_sourcePath; } QString sourcePath() const { return m_sourcePath; }
QString caption() const; QString caption() const;
QString date() const; // yyyy-MM-dd QString date() const; // yyyy-MM-dd
QString tags() const; // raw comma-separated; the server normalizes
Status status() const { return m_status; } Status status() const { return m_status; }
void setStatus(Status status, const QString &message = {}, const QString &url = {}); void setStatus(Status status, const QString &message = {}, const QString &url = {});
@@ -35,6 +36,7 @@ private:
QLabel *m_thumb = nullptr; QLabel *m_thumb = nullptr;
QLineEdit *m_caption = nullptr; QLineEdit *m_caption = nullptr;
QDateEdit *m_date = nullptr; QDateEdit *m_date = nullptr;
QLineEdit *m_tags = nullptr;
QLabel *m_statusLabel = nullptr; QLabel *m_statusLabel = nullptr;
QPushButton *m_remove = nullptr; QPushButton *m_remove = nullptr;
}; };
+2 -1
View File
@@ -26,7 +26,7 @@ QByteArray UploadClient::authHeader() const
} }
void UploadClient::upload(const QString &filePath, const QString &fileName, 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); auto *file = new QFile(filePath);
if (!file->open(QIODevice::ReadOnly)) { 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("caption"), caption));
multiPart->append(textPart(QStringLiteral("date"), date)); multiPart->append(textPart(QStringLiteral("date"), date));
multiPart->append(textPart(QStringLiteral("tags"), tags));
QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload"))); QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload")));
request.setRawHeader("Authorization", authHeader()); request.setRawHeader("Authorization", authHeader());
+1 -1
View File
@@ -29,7 +29,7 @@ public:
// The filename matters: server.go derives the slug from it, so re-sending // The filename matters: server.go derives the slug from it, so re-sending
// the same name overwrites the same post instead of duplicating it. // the same name overwrites the same post instead of duplicating it.
void upload(const QString &filePath, const QString &fileName, 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). // Credential check via GET /upload (same Basic-auth gate the POST uses).
// /health is not usable: nginx only proxies location /upload. // /health is not usable: nginx only proxies location /upload.
+1 -1
View File
@@ -18,7 +18,7 @@
# Run `go mod tidy` to generate go.sum, then `nix build`. # Run `go mod tidy` to generate go.sum, then `nix build`.
# Nix will fail and print the correct hash — paste it here. # Nix will fail and print the correct hash — paste it here.
vendorHash = "sha256-SxQDRProQtauWXPvPzbZZChLQd0iey9hnXFLiPYTS98="; vendorHash = "sha256-Q+VeDZX9DlZtOZrYRwZo19eyob8jIu19IRhdvChmVL4=";
meta = with pkgs.lib; { meta = with pkgs.lib; {
description = "Minimal static photo gallery"; description = "Minimal static photo gallery";
+96 -116
View File
@@ -27,8 +27,8 @@ const (
mediumMaxSize = 1600 mediumMaxSize = 1600
defaultPageSize = 60 defaultPageSize = 60
// The two paginated views. The one chosen as -default-view is published // The two paginated views. The one chosen as -default-view is published at
// at the site root, the other under /<name>/. // the site root, the other under /<name>/.
viewTimeline = "timeline" viewTimeline = "timeline"
viewGrid = "grid" viewGrid = "grid"
@@ -46,8 +46,9 @@ var ogQualityLadder = []int{85, 78, 70, 62}
// PhotoMeta is the structure of a .toml sidecar file. // PhotoMeta is the structure of a .toml sidecar file.
type PhotoMeta struct { type PhotoMeta struct {
Caption string `toml:"caption"` Caption string `toml:"caption"`
Date string `toml:"date"` // YYYY-MM-DD 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. // Photo is the in-memory representation of one image.
@@ -61,6 +62,7 @@ type Photo struct {
OGHeight int OGHeight int
Caption string Caption string
Date time.Time Date time.Time
Tags []Tag // albums this photo belongs to
} }
// DayGroup is a run of photos sharing the same calendar day, for the timeline. // 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 { // viewBase returns the URL prefix a view is published under: "" for the default
for _, r := range g.author { // view, which owns the site root, and "/<view>" for the other one.
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 "/<view>" for the other one.
func (g *Generator) viewBase(view string) string { func (g *Generator) viewBase(view string) string {
if view == g.defaultView { if view == g.defaultView {
return "" return ""
@@ -137,6 +132,18 @@ func pageURL(base string, n int) string {
return base + "/page/" + strconv.Itoa(n) + "/" 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; // Build performs a full rebuild of the site. It is safe to call concurrently;
// concurrent calls are serialised by a mutex. // concurrent calls are serialised by a mutex.
func (g *Generator) Build() error { func (g *Generator) Build() error {
@@ -148,25 +155,36 @@ func (g *Generator) Build() error {
return err 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 { if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err 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 { for i := range photos {
if err := g.processImage(&photos[i]); err != nil { if err := g.processImage(&photos[i]); err != nil {
log.Printf("processImage %s: %v", photos[i].Slug, err) 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 { if err := g.renderTimelinePages(photos); err != nil {
return err return err
} }
@@ -176,6 +194,16 @@ func (g *Generator) Build() error {
if err := g.renderDayPages(photos); err != nil { if err := g.renderDayPages(photos); err != nil {
return err 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 { for i := range photos {
var newer, older *Photo var newer, older *Photo
if i > 0 { if i > 0 {
@@ -199,48 +227,6 @@ func (g *Generator) Build() error {
return nil return nil
} }
// Delete removes a photo for good: its image and sidecar in the content
// directory, then everything generated from them. Pages that list the photo
// are put right by the next Build. It returns os.ErrNotExist when no photo
// has that slug.
func (g *Generator) Delete(slug string) error {
g.mu.Lock()
defer g.mu.Unlock()
photos, err := g.loadPhotos()
if err != nil {
return err
}
found := false
for _, p := range photos {
if p.Slug != slug {
continue
}
found = true
// The source goes first: if it cannot be removed, the photo stays
// fully intact rather than half deleted.
if err := os.Remove(filepath.Join(g.contentDir, filepath.Base(p.File))); err != nil {
return err
}
for _, rel := range []string{p.File, p.Thumb, p.Medium, p.OG} {
err := os.Remove(filepath.Join(g.outputDir, filepath.FromSlash(rel)))
if err != nil && !os.IsNotExist(err) {
return err
}
}
}
if !found {
return os.ErrNotExist
}
// slug is safe to join from here on: it matched a directory entry.
if err := os.Remove(filepath.Join(g.contentDir, slug+".toml")); err != nil && !os.IsNotExist(err) {
return err
}
return os.RemoveAll(filepath.Join(g.outputDir, "photo", slug))
}
// loadPhotos scans the content directory and returns photos sorted newest first. // loadPhotos scans the content directory and returns photos sorted newest first.
func (g *Generator) loadPhotos() ([]Photo, error) { func (g *Generator) loadPhotos() ([]Photo, error) {
entries, err := os.ReadDir(g.contentDir) entries, err := os.ReadDir(g.contentDir)
@@ -285,6 +271,7 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
OG: "og/" + slug + ".jpg", OG: "og/" + slug + ".jpg",
Caption: meta.Caption, Caption: meta.Caption,
Date: date, Date: date,
Tags: parseTags(meta.Tags...),
}) })
} }
@@ -294,6 +281,35 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
return photos, nil 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 // processImage copies the original to public/images/ and writes a square
// thumbnail to public/thumbs/ plus an aspect-preserved medium image to // thumbnail to public/thumbs/ plus an aspect-preserved medium image to
// public/medium/. Outputs are skipped when already newer than the source. // 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) return os.WriteFile(dst, buf.Bytes(), 0644)
} }
// renderGridPages writes the paginated square-grid view: index.html and // renderGridPages writes the paginated square-grid view. When grid is the
// page/N/index.html under the grid's base (the site root when the grid is the // -default-view it is the site root (index.html + page/N/); otherwise it lives
// default view, /grid/ otherwise), chunking photos into pageSize slices for // under /grid/. Photos are chunked into pageSize slices for progressive
// progressive pagination. // pagination.
func (g *Generator) renderGridPages(photos []Photo) error { func (g *Generator) renderGridPages(photos []Photo) error {
total := len(photos) total := len(photos)
totalPages := (total + g.pageSize - 1) / g.pageSize totalPages := (total + g.pageSize - 1) / g.pageSize
@@ -520,12 +536,13 @@ func (g *Generator) renderGridPages(photos []Photo) error {
data := map[string]any{ data := map[string]any{
"Photos": slice, "Photos": slice,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"Total": total, "Total": total,
"Page": i + 1, "Page": i + 1,
"TotalPages": totalPages, "TotalPages": totalPages,
"PrevURL": prev, "PrevURL": prev,
"NextURL": next, "NextURL": next,
"TimelineURL": g.timelineURL(),
"GridURL": g.gridURL(),
"BaseURL": g.baseURL, "BaseURL": g.baseURL,
"Year": time.Now().Year(), "Year": time.Now().Year(),
"Author": g.author, "Author": g.author,
@@ -541,7 +558,7 @@ func (g *Generator) renderGridPages(photos []Photo) error {
return err return err
} }
} }
return g.prunePages(base, totalPages) return nil
} }
// renderPhotoPage writes public/photo/<slug>/index.html. // renderPhotoPage writes public/photo/<slug>/index.html.
@@ -613,14 +630,13 @@ func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
return pages return pages
} }
// renderTimelinePages writes the day-grouped timeline: index.html and // renderTimelinePages writes the day-grouped timeline. When timeline is the
// page/N/index.html under the timeline's base (the site root when the // -default-view (the default) it is the site root (index.html + page/N/);
// timeline is the default view). Photos are grouped by day, then packed // otherwise it lives under /timeline/. Photos are grouped by day, then packed
// into pages of roughly pageSize without splitting a day across pages. // into pages of roughly pageSize without splitting a day across pages.
func (g *Generator) renderTimelinePages(photos []Photo) error { func (g *Generator) renderTimelinePages(photos []Photo) error {
pages := chunkDayGroups(groupByDay(photos), g.pageSize) pages := chunkDayGroups(groupByDay(photos), g.pageSize)
totalPages := len(pages) totalPages := len(pages)
base := g.viewBase(viewTimeline) base := g.viewBase(viewTimeline)
for i, groups := range pages { for i, groups := range pages {
@@ -641,12 +657,13 @@ func (g *Generator) renderTimelinePages(photos []Photo) error {
data := map[string]any{ data := map[string]any{
"Groups": groups, "Groups": groups,
"GridURL": pageURL(g.viewBase(viewGrid), 1),
"Total": len(photos), "Total": len(photos),
"Page": i + 1, "Page": i + 1,
"TotalPages": totalPages, "TotalPages": totalPages,
"PrevURL": prev, "PrevURL": prev,
"NextURL": next, "NextURL": next,
"TimelineURL": g.timelineURL(),
"GridURL": g.gridURL(),
"BaseURL": g.baseURL, "BaseURL": g.baseURL,
"Year": time.Now().Year(), "Year": time.Now().Year(),
"Author": g.author, "Author": g.author,
@@ -662,61 +679,24 @@ func (g *Generator) renderTimelinePages(photos []Photo) error {
return err return err
} }
} }
return g.prunePages(base, totalPages) return nil
}
// prunePages removes the pager pages of a view past its last one. They are
// left behind when the photo count shrinks or the views swap places, and
// would keep showing photos that are gone.
func (g *Generator) prunePages(base string, totalPages int) error {
// Page 1 is the view's own root, never a leftover: start past it even
// if a caller reports no pages at all.
for n := max(totalPages, 1) + 1; ; n++ {
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, n)))
if _, err := os.Stat(dir); err != nil {
return nil
}
if err := os.RemoveAll(dir); err != nil {
return err
}
}
} }
// renderDayPages writes one page per calendar day under public/day/<ISO>/, // renderDayPages writes one page per calendar day under public/day/<ISO>/,
// holding just that day's photos. Timeline date headings link here. Pages of // holding just that day's photos. Timeline date headings link here.
// days that no longer have photos, after a delete or a date change, are
// removed.
func (g *Generator) renderDayPages(photos []Photo) error { func (g *Generator) renderDayPages(photos []Photo) error {
groups := groupByDay(photos) for _, grp := range groupByDay(photos) {
live := make(map[string]bool, len(groups))
for _, grp := range groups {
live[grp.day] = true
}
dayDir := filepath.Join(g.outputDir, "day")
entries, err := os.ReadDir(dayDir)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() && !live[e.Name()] {
if err := os.RemoveAll(filepath.Join(dayDir, e.Name())); err != nil {
return err
}
}
}
for _, grp := range groups {
dir := filepath.Join(g.outputDir, "day", grp.day) dir := filepath.Join(g.outputDir, "day", grp.day)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return err return err
} }
err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{ err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
"Date": grp.Label, "Date": grp.Label,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"Count": grp.Count, "Count": grp.Count,
"Photos": grp.Photos, "Photos": grp.Photos,
"Total": len(photos), "Total": len(photos),
"TimelineURL": g.timelineURL(),
"GridURL": g.gridURL(),
"BaseURL": g.baseURL, "BaseURL": g.baseURL,
"Year": time.Now().Year(), "Year": time.Now().Year(),
"Author": g.author, "Author": g.author,
+4 -2
View File
@@ -1,11 +1,13 @@
module photogallery module photogallery
go 1.22 go 1.26.0
require ( require (
github.com/BurntSushi/toml v1.4.0 github.com/BurntSushi/toml v1.4.0
github.com/fsnotify/fsnotify v1.7.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/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
+6 -2
View File
@@ -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/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 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 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 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= 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.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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=
+14 -3
View File
@@ -20,8 +20,8 @@ func main() {
serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text") serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text")
mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line") mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line")
pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)") pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)")
manage := flag.Bool("manage", false, "serve the /manage pages for editing and deleting published photos (off by default; put them behind auth in nginx)") 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 one is published under /<name>/") defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other is published under /<name>/")
flag.Parse() flag.Parse()
if *defaultView != viewTimeline && *defaultView != viewGrid { if *defaultView != viewTimeline && *defaultView != viewGrid {
@@ -47,6 +47,17 @@ func main() {
defer w.Close() defer w.Close()
go w.Watch() 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()) log.Fatal(s.Listen())
} }
+325 -159
View File
@@ -1,10 +1,9 @@
package main package main
import ( import (
"bytes" "encoding/json"
"log" "log"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -13,212 +12,379 @@ import (
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
// The /manage pages edit and delete photos that are already published. Like // contentExts are the image extensions a post may use; the sidecar is always
// /upload they carry no auth of their own: in production nginx puts them // <slug>.toml alongside.
// behind htpasswd, and nothing on the public site links to them. var contentExts = []string{".jpg", ".jpeg", ".png"}
// maxEditBody caps the edit form. It carries a caption and a date, nothing else. // photoJSON is one row of GET /api/photos, consumed by the manage page.
const maxEditBody = 64 << 10 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/<slug>.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) { 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() photos, err := s.generator.loadPhotos()
if err != nil { if err != nil {
log.Printf("manage: %v", err) http.Error(w, "could not list photos", http.StatusInternalServerError)
http.Error(w, "could not read photos", http.StatusInternalServerError)
return return
} }
out := make([]photoJSON, 0, len(photos))
data := s.managePageData() for _, p := range photos {
data["Photos"] = photos tags := make([]string, 0, len(p.Tags))
if saved := r.URL.Query().Get("saved"); saved != "" { for _, t := range p.Tags {
for _, p := range photos { tags = append(tags, t.Name)
if p.Slug == saved {
data["Saved"] = p.Caption
}
} }
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") w.Header().Set("Content-Type", "application/json")
s.renderManage(w, "manage.html", data) json.NewEncoder(w).Encode(out)
} }
// handleEditPage shows the edit form for one photo, prefilled from its sidecar. // handleEdit rewrites one post's sidecar (caption, date, tags). The slug — and
func (s *Server) handleEditPage(w http.ResponseWriter, r *http.Request) { // therefore the URL — never changes.
photo, ok := s.findPhoto(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
data := s.managePageData()
data["Photo"] = photo
s.renderManage(w, "edit.html", data)
}
// handleEdit accepts the edit form with fields:
//
// caption — human-readable caption (empty falls back to the slug, as on upload)
// date — YYYY-MM-DD
//
// It rewrites the photo's .toml sidecar; the image itself is never touched.
func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) { if s.rejectCrossSite(w, r) {
http.Error(w, "cross-site request refused", http.StatusForbidden)
return return
} }
if err := parseForm(r); err != nil {
photo, ok := s.findPhoto(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxEditBody)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
return return
} }
slug := r.FormValue("slug")
caption := strings.TrimSpace(r.PostFormValue("caption")) if _, ok := s.resolveSlug(slug); !ok {
if caption == "" { http.Error(w, "unknown post", http.StatusNotFound)
caption = photo.Slug 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) http.Error(w, "date must be YYYY-MM-DD", http.StatusBadRequest)
return return
} }
// Encode first and write in one go, so a rebuild racing this request caption := r.FormValue("caption")
// never reads a half-written sidecar. if caption == "" {
var buf bytes.Buffer caption = slug
if err := toml.NewEncoder(&buf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil {
http.Error(w, "could not write metadata", http.StatusInternalServerError)
return
}
tomlPath := filepath.Join(s.contentDir, photo.Slug+".toml")
if err := os.WriteFile(tomlPath, buf.Bytes(), 0644); err != nil {
log.Printf("write %s: %v", tomlPath, err)
http.Error(w, "could not save metadata", http.StatusInternalServerError)
return
} }
// Build() is mutex-guarded so calling it here and from the watcher is safe. var tags []string
go func() { for _, t := range parseTags(r.FormValue("tags")) {
if err := s.generator.Build(); err != nil { tags = append(tags, t.Name)
log.Printf("rebuild after edit: %v", err) }
}
}()
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 // handleDelete moves one or more posts (image + sidecar) to content/.trash/.
// is a separate page rather than a button in the list so that it always takes // Slugs come as repeated `slug` fields or a comma-separated `slugs` field.
// two deliberate steps.
func (s *Server) handleDeletePage(w http.ResponseWriter, r *http.Request) {
photo, ok := s.findPhoto(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
data := s.managePageData()
data["Photo"] = photo
s.renderManage(w, "delete.html", data)
}
// handleDelete removes a photo for good: image, sidecar and everything
// generated from them. There is no undo.
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) { if s.rejectCrossSite(w, r) {
http.Error(w, "cross-site request refused", http.StatusForbidden)
return return
} }
slugs, err := s.formSlugs(r)
photo, ok := s.findPhoto(r.PathValue("slug")) if err != nil {
if !ok { http.Error(w, "bad request", http.StatusBadRequest)
http.NotFound(w, r)
return return
} }
deleted := 0
if err := s.generator.Delete(photo.Slug); err != nil { for _, slug := range slugs {
log.Printf("delete %s: %v", photo.Slug, err) if _, ok := s.resolveSlug(slug); !ok {
http.Error(w, "could not delete photo", http.StatusInternalServerError) continue
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)
} }
}() if err := s.moveToTrash(slug); err != nil {
log.Printf("delete %s: %v", slug, err)
http.Redirect(w, r, "/manage?deleted="+url.QueryEscape(photo.Caption), http.StatusSeeOther) 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 // handleBulkTags adds and/or removes tags across the given posts, leaving each
// against real entries, rather than building a path from the URL, is what // post's caption and date untouched. `add` and `remove` are comma-separated.
// keeps a crafted slug from reaching outside the content directory. func (s *Server) handleBulkTags(w http.ResponseWriter, r *http.Request) {
func (s *Server) findPhoto(slug string) (Photo, bool) { if s.rejectCrossSite(w, r) {
photos, err := s.generator.loadPhotos() return
if err != nil {
log.Printf("manage: %v", err)
return Photo{}, false
} }
for _, p := range photos { slugs, err := s.formSlugs(r)
if p.Slug == slug { 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 p, true
} }
} }
return Photo{}, false return "", false
} }
// sameOrigin reports whether a form post came from the gallery's own pages. // formSlugs collects slugs from repeated `slug` fields and/or a comma-separated
// Basic auth credentials are replayed by the browser on every request to the // `slugs` field, de-duplicated.
// host, so without this check any other website could submit the edit or func (s *Server) formSlugs(r *http.Request) ([]string, error) {
// delete form on a logged-in user's behalf. if err := parseForm(r); err != nil {
// return nil, err
// The Origin header is compared against -base-url as well as the Host header:
// behind nginx the Host that reaches this server is the proxy's upstream
// address, not the public name. Requests with neither header come from
// non-browser clients such as curl, which cannot be driven cross-site.
func (s *Server) sameOrigin(r *http.Request) bool {
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
return site == "same-origin" || site == "none"
} }
origin := r.Header.Get("Origin") seen := make(map[string]bool)
if origin == "" { var out []string
return true add := func(v string) {
v = strings.TrimSpace(v)
if v != "" && !seen[v] {
seen[v] = true
out = append(out, v)
}
} }
if origin == s.generator.baseURL { for _, v := range r.Form["slug"] {
return true add(v)
} }
u, err := url.Parse(origin) for _, v := range strings.Split(r.FormValue("slugs"), ",") {
return err == nil && u.Host == r.Host add(v)
}
return out, nil
} }
// managePageData returns the template fields shared by the manage pages. // readSidecar loads a post's existing metadata, falling back to the same
func (s *Server) managePageData() map[string]any { // defaults loadPhotos uses when the sidecar is missing or unreadable.
g := s.generator func (s *Server) readSidecar(slug string) PhotoMeta {
return map[string]any{ meta := PhotoMeta{Caption: slug, Date: time.Now().Format("2006-01-02")}
"FontsURL": g.fontsURL, path := filepath.Join(s.contentDir, slug+".toml")
"SerifFamily": g.serifFamily, if _, err := os.Stat(path); err == nil {
"MlFamily": g.mlFamily, 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 // writeSidecar writes <slug>.toml atomically (temp file + rename) so a
// clean 500 instead of a truncated page. // concurrent build can never observe a half-written sidecar.
func (s *Server) renderManage(w http.ResponseWriter, tmpl string, data map[string]any) { func (s *Server) writeSidecar(slug string, meta PhotoMeta) error {
var buf bytes.Buffer path := filepath.Join(s.contentDir, slug+".toml")
if err := s.generator.tmpl.ExecuteTemplate(&buf, tmpl, data); err != nil { tmp, err := os.CreateTemp(s.contentDir, ".tmp-sidecar-*")
log.Printf("render %s: %v", tmpl, err) if err != nil {
http.Error(w, "could not render page", http.StatusInternalServerError) 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 return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") go func() {
// Always reflect the sidecars as they are now, never a cached copy. if err := s.generator.Build(); err != nil {
w.Header().Set("Cache-Control", "no-store") log.Printf("rebuild after manage op: %v", err)
buf.WriteTo(w) }
}()
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
} }
+273 -296
View File
@@ -2,8 +2,10 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json"
"image" "image"
"image/jpeg" "image/png"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -15,341 +17,316 @@ import (
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
// newTestServer returns a server over a content directory holding one photo, // newTestServer builds a Server over fresh temp content/output dirs with a
// "sunrise", with a sidecar. // couple of posts, runs one build, and returns it.
func newTestServer(t *testing.T) *Server { func newTestServer(t *testing.T) (*Server, string, string) {
t.Helper() t.Helper()
content := t.TempDir()
output := t.TempDir()
// Not t.TempDir(): an edit kicks off a background rebuild that can still writePNG(t, filepath.Join(content, "foo.png"))
// be writing when the test ends, and TempDir fails the test if its if err := os.WriteFile(filepath.Join(content, "foo.toml"),
// cleanup finds the directory busy. []byte("caption = \"Foo\"\ndate = \"2026-01-02\"\ntags = [\"berlin\", \"street\"]\n"), 0600); err != nil {
root, err := os.MkdirTemp("", "photog-test-") 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { os.RemoveAll(root) }) defer f.Close()
if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
content := filepath.Join(root, "content")
output := filepath.Join(root, "public")
for _, d := range []string{content, output} {
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
}
var img bytes.Buffer
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
t.Fatal(err) 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 { func form(v url.Values) *http.Request {
t.Helper() r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(v.Encode()))
var meta PhotoMeta
if _, err := toml.DecodeFile(filepath.Join(s.contentDir, slug+".toml"), &meta); err != nil {
t.Fatal(err)
}
return meta
}
// postEdit submits the edit form for slug and returns the response.
func postEdit(s *Server, slug string, form url.Values, headers map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/manage/edit/"+slug, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for k, v := range headers { return r
r.Header.Set(k, v)
}
r.SetPathValue("slug", slug)
w := httptest.NewRecorder()
s.handleEdit(w, r)
return w
} }
func TestManagePageListsPhotos(t *testing.T) { // multipartForm builds a multipart/form-data request, as a browser's FormData
s := newTestServer(t) // does — the content type the manage handlers must parse, not just urlencoded.
w := httptest.NewRecorder() func multipartForm(fields map[string]string) *http.Request {
s.handleManagePage(w, httptest.NewRequest("GET", "/manage", nil)) var b bytes.Buffer
mw := multipart.NewWriter(&b)
if w.Code != http.StatusOK { for k, v := range fields {
t.Fatalf("status = %d, want 200", w.Code) mw.WriteField(k, v)
}
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)
}
} }
mw.Close()
r := httptest.NewRequest(http.MethodPost, "/", &b)
r.Header.Set("Content-Type", mw.FormDataContentType())
return r
} }
func TestEditPagePrefillsForm(t *testing.T) { func readMeta(t *testing.T, content, slug string) PhotoMeta {
s := newTestServer(t)
r := httptest.NewRequest("GET", "/manage/edit/sunrise", nil)
r.SetPathValue("slug", "sunrise")
w := httptest.NewRecorder()
s.handleEditPage(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{`value="Tempelhof at sunrise"`, `value="2026-06-14"`} {
if !strings.Contains(body, want) {
t.Errorf("edit page is missing %q", want)
}
}
}
func TestEditRewritesSidecar(t *testing.T) {
s := newTestServer(t)
form := url.Values{"caption": {`Tempelhof, "golden hour"`}, "date": {"2026-06-15"}}
w := postEdit(s, "sunrise", form, nil)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body)
}
if loc := w.Header().Get("Location"); loc != "/manage?saved=sunrise" {
t.Errorf("Location = %q", loc)
}
meta := readMeta(t, s, "sunrise")
if meta.Caption != `Tempelhof, "golden hour"` || meta.Date != "2026-06-15" {
t.Errorf("sidecar = %+v", meta)
}
}
func TestEditEmptyCaptionFallsBackToSlug(t *testing.T) {
s := newTestServer(t)
w := postEdit(s, "sunrise", url.Values{"caption": {" "}, "date": {"2026-06-14"}}, nil)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", w.Code)
}
if meta := readMeta(t, s, "sunrise"); meta.Caption != "sunrise" {
t.Errorf("caption = %q, want the slug", meta.Caption)
}
}
func TestEditRejectsBadInput(t *testing.T) {
valid := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}}
tests := []struct {
name string
slug string
form url.Values
headers map[string]string
want int
}{
{"unknown photo", "nope", valid, nil, http.StatusNotFound},
{"path traversal", "../sunrise", valid, nil, http.StatusNotFound},
{"malformed date", "sunrise", url.Values{"caption": {"changed"}, "date": {"15.06.2026"}}, nil, http.StatusBadRequest},
{"missing date", "sunrise", url.Values{"caption": {"changed"}}, nil, http.StatusBadRequest},
{"cross-site fetch", "sunrise", valid, map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden},
{"foreign origin", "sunrise", valid, map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestServer(t)
w := postEdit(s, tt.slug, tt.form, tt.headers)
if w.Code != tt.want {
t.Errorf("status = %d, want %d", w.Code, tt.want)
}
if meta := readMeta(t, s, "sunrise"); meta.Caption != "Tempelhof at sunrise" || meta.Date != "2026-06-14" {
t.Errorf("sidecar was changed: %+v", meta)
}
})
}
}
func TestEditAcceptsOwnOrigin(t *testing.T) {
form := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}}
tests := []struct {
name string
headers map[string]string
}{
{"same-origin fetch metadata", map[string]string{"Sec-Fetch-Site": "same-origin"}},
// Behind nginx the Host header is the upstream address, so only
// -base-url can vouch for the public origin.
{"origin equal to base URL", map[string]string{"Origin": "https://photos.example.com"}},
{"origin equal to host", map[string]string{"Origin": "http://example.com"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestServer(t)
if w := postEdit(s, "sunrise", form, tt.headers); w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body)
}
})
}
}
// twoPhotoServer returns a built site with "sunrise" plus "dusk", which has a
// day of its own, so deleting it must also take that day's page away.
func twoPhotoServer(t *testing.T) *Server {
t.Helper() t.Helper()
s := newTestServer(t) var m PhotoMeta
if _, err := toml.DecodeFile(filepath.Join(content, slug+".toml"), &m); err != nil {
var img bytes.Buffer t.Fatalf("decode %s: %v", slug, err)
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
t.Fatal(err)
} }
writeFile(t, filepath.Join(s.contentDir, "dusk.jpg"), img.Bytes()) return m
writeFile(t, filepath.Join(s.contentDir, "dusk.toml"), }
[]byte("caption = \"Dusk\"\ndate = \"2026-07-01\"\n"))
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 { if err := s.generator.Build(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
return s 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)
// photoFiles lists every file and directory that belongs to one photo. }
func photoFiles(s *Server, slug, day string) []string { }
return []string{ if _, err := os.Stat(filepath.Join(output, "thumbs/bar.jpg")); err != nil {
filepath.Join(s.contentDir, slug+".jpg"), t.Errorf("bar's thumb should survive: %v", err)
filepath.Join(s.contentDir, slug+".toml"),
filepath.Join(s.outputDir, "images", slug+".jpg"),
filepath.Join(s.outputDir, "thumbs", slug+".jpg"),
filepath.Join(s.outputDir, "medium", slug+".jpg"),
filepath.Join(s.outputDir, "og", slug+".jpg"),
filepath.Join(s.outputDir, "photo", slug),
filepath.Join(s.outputDir, "day", day),
} }
} }
func postDelete(s *Server, slug string, headers map[string]string) *httptest.ResponseRecorder { // TestMultipartBodies guards the regression where the manage handlers used
r := httptest.NewRequest("POST", "/manage/delete/"+slug, nil) // r.ParseForm (urlencoded only) and silently ignored the multipart bodies that
for k, v := range headers { // browsers send via FormData, returning 200 with deleted:0 / no change.
r.Header.Set(k, v) func TestMultipartBodies(t *testing.T) {
} s, content, _ := newTestServer(t)
r.SetPathValue("slug", slug)
w := httptest.NewRecorder()
s.handleDelete(w, r)
return w
}
func TestDeletePageAsksFirst(t *testing.T) { // Edit via multipart.
s := twoPhotoServer(t) rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/manage/delete/dusk", nil) s.handleEdit(rec, multipartForm(map[string]string{
r.SetPathValue("slug", "dusk") "slug": "foo", "caption": "Multipart", "date": "2021-02-03", "tags": "x,y",
w := httptest.NewRecorder() }))
s.handleDeletePage(w, r) 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 { // Delete via multipart actually moves the file.
t.Fatalf("status = %d, want 200", w.Code) 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"`) { if _, err := os.Stat(filepath.Join(content, "bar.png")); !os.IsNotExist(err) {
t.Errorf("confirmation page has no delete form") t.Error("bar.png should have moved to trash")
}
// Looking at the confirmation page must not delete anything.
for _, f := range photoFiles(s, "dusk", "2026-07-01") {
if _, err := os.Stat(f); err != nil {
t.Errorf("%s is gone after a GET: %v", f, err)
}
} }
} }
func TestDeleteRemovesPhotoEverywhere(t *testing.T) { func TestCSRFGuard(t *testing.T) {
s := twoPhotoServer(t) s, _, _ := newTestServer(t) // generator.baseURL == "http://x"
w := postDelete(s, "dusk", nil)
if w.Code != http.StatusSeeOther { // A cross-site Origin is refused before any mutation runs.
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body) r := form(url.Values{"slug": {"foo"}})
} r.Header.Set("Origin", "http://evil.example")
if loc := w.Header().Get("Location"); loc != "/manage?deleted=Dusk" { rec := httptest.NewRecorder()
t.Errorf("Location = %q", loc) s.handleDelete(rec, r)
} if rec.Code != http.StatusForbidden {
// The handler rebuilds in the background; build here to have a t.Errorf("cross-site Origin: code=%d, want 403", rec.Code)
// finished site to look at.
if err := s.generator.Build(); err != nil {
t.Fatal(err)
} }
for _, f := range photoFiles(s, "dusk", "2026-07-01") { // Sec-Fetch-Site: cross-site is likewise refused.
if _, err := os.Stat(f); !os.IsNotExist(err) { r = form(url.Values{"slug": {"foo"}})
t.Errorf("%s survived the delete", f) 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 { // Same-origin is allowed through (no slugs → deletes nothing, 200).
t.Errorf("the other photo lost %s: %v", f, err) 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)) // A matching Origin is allowed.
if err != nil { r = form(url.Values{})
t.Fatal(err) r.Header.Set("Origin", "http://x")
} rec = httptest.NewRecorder()
if strings.Contains(string(data), "dusk") { s.handleDelete(rec, r)
t.Errorf("%s still mentions the deleted photo", page) if rec.Code != http.StatusOK {
} t.Errorf("matching Origin: code=%d, want 200", rec.Code)
} }
} }
func TestDeleteRejectsBadInput(t *testing.T) { func TestHandleListPhotos(t *testing.T) {
tests := []struct { s, _, _ := newTestServer(t)
name string rec := httptest.NewRecorder()
slug string s.handleListPhotos(rec, httptest.NewRequest(http.MethodGet, "/api/photos", nil))
headers map[string]string if rec.Code != http.StatusOK {
want int t.Fatalf("list: code=%d", rec.Code)
}{
{"unknown photo", "nope", nil, http.StatusNotFound},
{"path traversal", "../dusk", nil, http.StatusNotFound},
{"cross-site fetch", "dusk", map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden},
{"foreign origin", "dusk", map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden},
} }
for _, tt := range tests { var photos []photoJSON
t.Run(tt.name, func(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &photos); err != nil {
s := twoPhotoServer(t) t.Fatalf("decode: %v", err)
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)
}
}
})
} }
} if len(photos) != 2 {
t.Fatalf("got %d photos, want 2", len(photos))
func TestManageRoutesNeedTheFlag(t *testing.T) { }
for _, manage := range []bool{false, true} { var foo *photoJSON
s := newTestServer(t) for i := range photos {
s.manage = manage if photos[i].Slug == "foo" {
mux := s.routes() foo = &photos[i]
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 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)
}
} }
+35 -17
View File
@@ -85,10 +85,22 @@ in {
defaultView = lib.mkOption { defaultView = lib.mkOption {
type = lib.types.enum [ "timeline" "grid" ]; type = lib.types.enum [ "timeline" "grid" ];
default = "timeline"; 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 = { nginx = {
enable = lib.mkEnableOption "nginx virtual host for photogallery"; enable = lib.mkEnableOption "nginx virtual host for photogallery";
@@ -99,12 +111,6 @@ in {
description = "Domain for the nginx virtual host."; 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 { enableACME = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = true; default = true;
@@ -157,7 +163,14 @@ in {
"--ml-family" cfg.mlFamily "--ml-family" cfg.mlFamily
"--page-size" (toString cfg.pageSize) "--page-size" (toString cfg.pageSize)
"--default-view" cfg.defaultView "--default-view" cfg.defaultView
] ++ lib.optional cfg.manage.enable "--manage"); ] ++ 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"; Restart = "on-failure";
RestartSec = "5s"; RestartSec = "5s";
@@ -187,17 +200,22 @@ in {
tryFiles = "$uri $uri/ =404"; 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" = { locations."/upload" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}"; proxyPass = "http://127.0.0.1:${toString cfg.port}";
basicAuth = "gallery"; extraConfig = "client_max_body_size 20M;";
basicAuthFile = cfg.nginx.htpasswdFile;
extraConfig = "client_max_body_size 20M;";
}; };
locations."/manage" = lib.mkIf cfg.manage.enable { locations."/manage" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}"; proxyPass = "http://127.0.0.1:${toString cfg.port}";
basicAuth = "gallery"; };
basicAuthFile = cfg.nginx.htpasswdFile;
locations."/api/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
extraConfig = "client_max_body_size 20M;";
}; };
}; };
}; };
+88 -23
View File
@@ -5,12 +5,14 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"golang.org/x/time/rate"
) )
type Server struct { type Server struct {
@@ -18,43 +20,98 @@ type Server struct {
contentDir string contentDir string
outputDir string outputDir string
serve bool serve bool
manage bool
generator *Generator 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{ return &Server{
port: port, port: port,
contentDir: contentDir, contentDir: contentDir,
outputDir: outputDir, outputDir: outputDir,
serve: serve, serve: serve,
manage: manage,
generator: g, 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 // guard applies HTTP Basic auth to h when an Authenticator is configured, and
// -manage, so a service without the flag has no edit or delete code path at // passes it through untouched otherwise (local --serve development).
// all and answers 404 there. func (s *Server) guard(h http.HandlerFunc) http.HandlerFunc {
func (s *Server) routes() *http.ServeMux { if s.auth == nil {
mux := http.NewServeMux() return h
mux.HandleFunc("GET /upload", s.handleUploadPage)
mux.HandleFunc("POST /upload", s.handleUpload)
if s.manage {
mux.HandleFunc("GET /manage", s.handleManagePage)
mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage)
mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit)
mux.HandleFunc("GET /manage/delete/{slug}", s.handleDeletePage)
mux.HandleFunc("POST /manage/delete/{slug}", s.handleDelete)
} }
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { return s.auth.basicAuth(s.authLimit, h)
fmt.Fprintln(w, "ok") }
})
return mux // 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 { 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 { if s.serve {
// Serve the generated site on the same port — for local development only. // Serve the generated site on the same port — for local development only.
@@ -84,6 +141,7 @@ func (s *Server) handleUploadPage(w http.ResponseWriter, r *http.Request) {
// image — the image file (jpg or png) // image — the image file (jpg or png)
// caption — human-readable caption (optional, defaults to filename) // caption — human-readable caption (optional, defaults to filename)
// date — YYYY-MM-DD (optional, defaults to today) // 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) { func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil { if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "bad request", http.StatusBadRequest) 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) 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)) slug := sanitiseSlug(strings.TrimSuffix(header.Filename, ext))
if slug == "" { if slug == "" {
slug = fmt.Sprintf("photo-%d", time.Now().UnixMilli()) 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") tomlPath := filepath.Join(s.contentDir, slug+".toml")
out, err := os.Create(imgPath) out, err := os.Create(imgPath)
@@ -138,7 +203,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
defer tf.Close() 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) http.Error(w, "could not write metadata", http.StatusInternalServerError)
return return
} }
+65
View File
@@ -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 <body data-slug="..."> (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);
}
})();
+101
View File
@@ -422,3 +422,104 @@ body {
.dlayout { flex-direction: column; } .dlayout { flex-direction: column; }
.dsidebar { width: 100%; border-left: none; border-top: 1px solid var(--line); } .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; }
+152
View File
@@ -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 <input>, 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);
});
})();
+97
View File
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Tag.Name}} · Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
<body>
<header class="profile-hdr">
<div class="profile-inner">
<div class="avatar-ring">
<span class="avatar-initials">{{.Initial}}</span>
</div>
<div class="pinfo">
<h1>{{.Author}}</h1>
<p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="{{.TimelineURL}}" class="rss-a">Timeline</a>
<a href="/albums/" class="rss-a">← Albums</a>
</div>
</div>
</header>
<main>
<div class="grid-wrap">
<section class="tl-day">
<h2 class="tl-date">{{.Tag.Name}}<span class="tl-count">{{.Count}}</span></h2>
<div class="pgrid">
{{range .Photos}}
<a href="/photo/{{.Slug}}/" class="ptile">
<img src="/{{.Thumb}}" alt="{{.Caption}}" loading="lazy">
</a>
{{end}}
</div>
</section>
{{if or .PrevURL .NextURL}}
<nav class="pager">
{{if .PrevURL}}<a class="pager-newer" href="{{.PrevURL}}">← Newer</a>{{end}}
{{if .NextURL}}<a class="pager-older" href="{{.NextURL}}" data-load-more>Older →</a>{{end}}
</nav>
{{end}}
</div>
</main>
<footer class="site-footer">
© 2000–{{.Year}} {{.Author}}. All rights reserved.
</footer>
<script>
(function () {
const btn = document.querySelector('[data-load-more]');
if (!btn) return;
const grid = document.querySelector('.pgrid');
let loading = false;
async function loadMore(url) {
if (loading) return;
loading = true;
try {
const res = await fetch(url);
if (!res.ok) return;
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('.pgrid > a').forEach(t => grid.appendChild(t));
const nextBtn = doc.querySelector('[data-load-more]');
if (nextBtn) btn.href = nextBtn.getAttribute('href');
else btn.remove();
} finally {
loading = false;
}
}
btn.addEventListener('click', (e) => {
e.preventDefault();
loadMore(btn.href);
});
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && document.body.contains(btn)) {
loadMore(btn.href);
}
}, { rootMargin: '400px' });
io.observe(btn);
})();
</script>
<script src="/static/session.js"></script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Albums · Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
<body>
<header class="profile-hdr">
<div class="profile-inner">
<div class="avatar-ring">
<span class="avatar-initials">{{.Initial}}</span>
</div>
<div class="pinfo">
<h1>{{.Author}}</h1>
<p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="{{.TimelineURL}}" class="rss-a">← Timeline</a>
</div>
</div>
</header>
<main>
<div class="grid-wrap">
<section class="tl-day">
<h2 class="tl-date">Albums<span class="tl-count">{{len .Albums}}</span></h2>
{{if .Albums}}
<div class="album-list">
{{range .Albums}}
<a href="/album/{{.Tag.Slug}}/" class="album-chip">
{{.Tag.Name}}<span class="album-chip-count">{{.Count}}</span>
</a>
{{end}}
</div>
{{else}}
<p class="album-empty">No albums yet — add tags to a photo to create one.</p>
{{end}}
</section>
</div>
</main>
<footer class="site-footer">
© 2000–{{.Year}} {{.Author}}. All rights reserved.
</footer>
<script src="/static/session.js"></script>
</body>
</html>
+1
View File
@@ -46,5 +46,6 @@
<footer class="site-footer"> <footer class="site-footer">
© 2000–{{.Year}} {{.Author}}. All rights reserved. © 2000–{{.Year}} {{.Author}}. All rights reserved.
</footer> </footer>
<script src="/static/session.js"></script>
</body> </body>
</html> </html>
-107
View File
@@ -1,107 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Delete — {{.Photo.Caption}}</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<style>
:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }
.delete-page {
max-width: 520px;
margin: 0 auto;
padding: 32px 20px;
}
.delete-page .back-btn {
color: var(--fg3);
text-decoration: none;
font-size: 15px;
}
.delete-page .back-btn:hover { color: var(--fg); }
.delete-page h2 {
font-family: var(--serif);
font-size: 22px;
font-weight: 600;
color: var(--fg);
margin: 16px 0 24px;
}
.delete-img {
display: block;
width: 100%;
max-height: 340px;
object-fit: contain;
background: var(--bg0);
border: 1px solid var(--bg2);
margin-bottom: 12px;
}
.delete-caption {
font-size: 18px;
color: var(--fg);
overflow-wrap: anywhere;
}
.delete-meta {
font-size: 12px;
color: var(--fg4);
font-family: monospace;
margin: 4px 0 24px;
overflow-wrap: anywhere;
}
.delete-warning {
font-size: 13px;
font-family: monospace;
line-height: 1.5;
padding: 10px 12px;
margin-bottom: 20px;
background: #2e1d1d;
color: #fb4934;
border: 1px solid #5a2828;
}
.delete-btn {
width: 100%;
background: #2e1d1d;
border: 1px solid #5a2828;
color: #fb4934;
font-family: var(--serif);
font-size: 16px;
font-weight: 600;
padding: 12px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.delete-btn:hover { background: #5a2828; color: var(--fg); }
.cancel-btn {
display: block;
margin-top: 12px;
padding: 12px;
text-align: center;
background: var(--bg1);
border: 1px solid var(--bg2);
color: var(--fg2);
font-size: 16px;
font-weight: 600;
text-decoration: none;
}
.cancel-btn:hover { background: var(--bg2); color: var(--fg); }
</style>
</head>
<body>
<div class="delete-page">
<a href="/manage" class="back-btn">← Manage</a>
<h2>Delete photo</h2>
<img class="delete-img" src="/{{.Photo.Medium}}" alt="">
<p class="delete-caption">{{.Photo.Caption}}</p>
<p class="delete-meta">{{.Photo.Date.Format "2006-01-02"}} · {{.Photo.Slug}}</p>
<p class="delete-warning">This removes the photo, its caption and every
page and image generated from it. It cannot be undone.</p>
<form method="post" action="/manage/delete/{{.Photo.Slug}}">
<button class="delete-btn" type="submit">Delete permanently</button>
</form>
<a href="/manage" class="cancel-btn">Cancel</a>
</div>
</body>
</html>
-110
View File
@@ -1,110 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Edit — {{.Photo.Caption}}</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<style>
:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }
.edit-page {
max-width: 520px;
margin: 0 auto;
padding: 32px 20px;
}
.edit-page .back-btn {
color: var(--fg3);
text-decoration: none;
font-size: 15px;
}
.edit-page .back-btn:hover { color: var(--fg); }
.edit-page h2 {
font-family: var(--serif);
font-size: 22px;
font-weight: 600;
color: var(--fg);
margin: 16px 0 24px;
}
.edit-img {
display: block;
width: 100%;
max-height: 340px;
object-fit: contain;
background: var(--bg0);
border: 1px solid var(--bg2);
margin-bottom: 8px;
}
.edit-slug {
font-size: 12px;
color: var(--fg4);
font-family: monospace;
margin-bottom: 24px;
overflow-wrap: anywhere;
}
.field { margin-bottom: 18px; }
.field label {
display: block;
font-size: 12px;
color: var(--fg4);
font-family: monospace;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.field input[type="text"],
.field input[type="date"] {
width: 100%;
background: var(--bg0);
border: 1px solid var(--bg2);
color: var(--fg);
font-size: 16px;
padding: 10px 12px;
outline: none;
transition: border-color 0.2s;
}
.field input[type="text"] { font-family: var(--serif); }
.field input[type="date"] { font-family: monospace; font-size: 14px; color-scheme: dark; }
.field input:focus { border-color: var(--fg4); }
.submit-btn {
width: 100%;
background: var(--bg1);
border: 1px solid var(--bg2);
color: var(--fg2);
font-family: var(--serif);
font-size: 16px;
font-weight: 600;
padding: 12px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.submit-btn:hover { background: var(--bg2); color: var(--fg); }
</style>
</head>
<body>
<div class="edit-page">
<a href="/manage" class="back-btn">← Manage</a>
<h2>Edit photo</h2>
<img class="edit-img" src="/{{.Photo.Medium}}" alt="">
<p class="edit-slug">{{.Photo.Slug}}</p>
<!-- A plain form post, no script: the server answers with a redirect
back to /manage, so a reload never resubmits the edit. -->
<form method="post" action="/manage/edit/{{.Photo.Slug}}">
<div class="field">
<label for="caption">Caption</label>
<input type="text" id="caption" name="caption" value="{{.Photo.Caption}}" autocomplete="off" autofocus>
</div>
<div class="field">
<label for="date">Date</label>
<input type="date" id="date" name="date" value="{{.Photo.Date.Format "2006-01-02"}}" required>
</div>
<button class="submit-btn" type="submit">Save changes</button>
</form>
</div>
</body>
</html>
+3 -1
View File
@@ -24,13 +24,14 @@
<p class="pcount"><strong>{{.Total}}</strong> posts</p> <p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p> <p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="{{.TimelineURL}}" class="rss-a">Timeline</a> <a href="{{.TimelineURL}}" class="rss-a">Timeline</a>
<a href="/albums/" class="rss-a">Albums</a>
<a href="/feed.xml" class="rss-a"> <a href="/feed.xml" class="rss-a">
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<circle cx="2.5" cy="11.5" r="1.5"/> <circle cx="2.5" cy="11.5" r="1.5"/>
<path d="M1.5 7.5A5 5 0 0 1 6.5 12.5H8A6.5 6.5 0 0 0 1.5 6V7.5z"/> <path d="M1.5 7.5A5 5 0 0 1 6.5 12.5H8A6.5 6.5 0 0 0 1.5 6V7.5z"/>
<path d="M1.5 4A8.5 8.5 0 0 1 10 12.5h1.5A10 10 0 0 0 1.5 2.5V4z"/> <path d="M1.5 4A8.5 8.5 0 0 1 10 12.5h1.5A10 10 0 0 0 1.5 2.5V4z"/>
</svg> </svg>
RSS feed Feed
</a> </a>
<a href="https://git.inflo.ws/aashiks/photog" class="rss-a" target="_blank" rel="noopener"> <a href="https://git.inflo.ws/aashiks/photog" class="rss-a" target="_blank" rel="noopener">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
@@ -102,5 +103,6 @@
io.observe(btn); io.observe(btn);
})(); })();
</script> </script>
<script src="/static/session.js"></script>
</body> </body>
</html> </html>
+342 -93
View File
@@ -3,115 +3,364 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Manage — Photo Gallery</title> <title>Manage — Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style> <style>
:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; } .manage-page { max-width: 760px; margin: 0 auto; padding: 28px 20px 80px; }
.manage-page { .manage-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
max-width: 720px; .manage-head h2 { font-family: var(--serif); font-size: 22px; font-weight: 600; color: var(--fg); }
margin: 0 auto; .manage-head a { font-family: monospace; font-size: 13px; color: var(--fg4); text-decoration: none; }
padding: 32px 20px; .manage-head a:hover { color: var(--fg2); }
#count { font-family: monospace; font-size: 12px; color: var(--fg4); margin-bottom: 20px; }
/* Bulk action bar, sticky at the top once posts are selected. */
.bulkbar {
position: sticky; top: 0; z-index: 5;
display: none; flex-wrap: wrap; align-items: center; gap: 10px;
background: var(--bg1); border: 1px solid var(--bg2);
padding: 12px 14px; margin-bottom: 20px;
} }
.manage-page .back-btn { .bulkbar.show { display: flex; }
color: var(--fg3); .bulkbar .sel { font-family: monospace; font-size: 12px; color: var(--fg2); margin-right: auto; }
text-decoration: none; .bulkbar input[type="text"] {
font-size: 15px; background: var(--bg0); border: 1px solid var(--bg2); color: var(--fg);
font-family: var(--serif); font-size: 14px; padding: 7px 9px; width: 150px; outline: none;
} }
.manage-page .back-btn:hover { color: var(--fg); } .bulkbar input:focus { border-color: var(--fg4); }
.manage-page h2 {
font-family: var(--serif); .btn {
font-size: 22px; background: var(--bg1); border: 1px solid var(--bg2); color: var(--fg2);
font-weight: 600; font-family: var(--serif); font-size: 14px; padding: 7px 12px; cursor: pointer;
color: var(--fg); transition: background .2s, color .2s;
margin: 16px 0 6px;
} }
.manage-count { .btn:hover:not(:disabled) { background: var(--bg2); color: var(--fg); }
font-size: 12px; .btn:disabled { opacity: .35; cursor: not-allowed; }
color: var(--fg4); .btn.danger { color: #fb4934; border-color: #5a2828; }
font-family: monospace; .btn.danger:hover:not(:disabled) { background: #2e1d1d; color: #fb4934; }
letter-spacing: 0.04em;
margin-bottom: 24px; .row {
display: grid; grid-template-columns: 22px 72px 1fr auto; gap: 14px;
align-items: start; padding: 16px 0; border-top: 1px solid var(--bg1);
} }
.saved { .row input[type="checkbox"] { margin-top: 28px; width: 16px; height: 16px; accent-color: var(--aqua, #8ec07c); }
margin-bottom: 20px; .row .thumb { width: 72px; height: 72px; object-fit: cover; background: var(--bg1); display: block; }
font-size: 13px; .fields { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
font-family: monospace; .fields label { font-size: 11px; color: var(--fg4); font-family: monospace; text-transform: uppercase; letter-spacing: .06em; }
padding: 10px 12px; .fields input {
background: #1d2e1d; width: 100%; background: var(--bg0); border: 1px solid var(--bg2); color: var(--fg);
color: #b8bb26; font-size: 15px; padding: 8px 10px; outline: none; box-sizing: border-box;
border: 1px solid #344d28;
} }
.mlist { list-style: none; } .fields input[type="text"] { font-family: var(--serif); }
.mrow { .fields input[type="date"] { font-family: monospace; font-size: 13px; color-scheme: dark; width: auto; }
display: flex; .fields input:focus { border-color: var(--fg4); }
align-items: center; .rowactions { display: flex; flex-direction: column; gap: 8px; }
gap: 16px;
padding: 12px 0; @media (max-width: 560px) {
border-top: 1px solid var(--line); .row { grid-template-columns: 22px 56px 1fr; }
.row .rowactions { grid-column: 2 / 4; flex-direction: row; }
.row input[type="checkbox"] { margin-top: 20px; }
} }
.mrow img {
width: 72px; .trashpanel { display: none; border: 1px solid var(--bg2); background: var(--bg0); padding: 14px; margin-bottom: 24px; }
height: 72px; .trashpanel.show { display: block; }
object-fit: cover; .trashhead { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
background: var(--bg0); .trashhead strong { font-family: var(--serif); color: var(--fg); }
flex: none; .trashhead #trash-count { font-family: monospace; font-size: 12px; color: var(--fg4); margin-right: auto; }
.trashrow { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--bg1); }
.trashrow .meta { margin-right: auto; min-width: 0; }
.trashrow .slug { font-family: var(--serif); color: var(--fg2); }
.trashrow .when { font-family: monospace; font-size: 11px; color: var(--fg4); }
#trash-empty-msg { color: var(--fg4); font-family: var(--serif); padding: 8px 0; }
#toast {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
display: none; font-family: monospace; font-size: 13px; padding: 10px 16px; max-width: 90%;
} }
.minfo { flex: 1; min-width: 0; } #toast.ok { display: block; background: #1d2e1d; color: #b8bb26; border: 1px solid #344d28; }
.mcaption { #toast.err { display: block; background: #2e1d1d; color: #fb4934; border: 1px solid #5a2828; }
font-size: 17px; #empty { color: var(--fg4); font-family: var(--serif); padding: 40px 0; }
color: var(--fg); .row-hl { outline: 2px solid var(--aqua, #8ec07c); outline-offset: 4px; transition: outline-color .4s; }
overflow-wrap: anywhere;
}
.mdate {
font-size: 12px;
color: var(--fg4);
font-family: monospace;
margin-top: 4px;
}
.mactions {
flex: none;
display: flex;
gap: 14px;
font-size: 15px;
}
.mactions a { color: var(--fg3); text-decoration: none; }
.mactions a:hover { color: var(--fg); }
.mactions .medit { color: var(--aqua); }
.mactions .mdelete { color: #fb4934; }
.mempty { color: var(--fg4); font-size: 15px; }
</style> </style>
</head> </head>
<body> <body>
<div class="manage-page"> <div class="manage-page">
<a href="/" class="back-btn">← Gallery</a> <div class="manage-head">
<h2>Manage photos</h2> <h2>Manage posts</h2>
<p class="manage-count">{{len .Photos}} {{if eq (len .Photos) 1}}photo{{else}}photos{{end}}</p> <span><a href="/upload">+ Upload</a> &nbsp; <a href="#" id="trash-toggle">Trash</a> &nbsp; <a href="/">← Gallery</a></span>
</div>
<div id="count">Loading…</div>
{{if .Saved}}<p class="saved">✓ saved “{{.Saved}}”</p>{{end}} <div class="trashpanel" id="trashpanel">
{{if .Deleted}}<p class="saved">✓ deleted “{{.Deleted}}”</p>{{end}} <div class="trashhead">
<strong>Trash</strong>
<span id="trash-count"></span>
<button class="btn danger" id="btn-empty-all">Empty trash</button>
</div>
<div id="trashlist"></div>
<div id="trash-empty-msg" style="display:none">Trash is empty.</div>
</div>
{{if .Photos}} <div class="bulkbar" id="bulkbar">
<ul class="mlist"> <span class="sel" id="sel">0 selected</span>
{{range .Photos}} <input type="text" id="bulk-add" placeholder="add tags" autocomplete="off" data-tagcomplete>
<li class="mrow"> <button class="btn" id="btn-add">Add</button>
<img src="/{{.Thumb}}" alt="" loading="lazy"> <input type="text" id="bulk-remove" placeholder="remove tags" autocomplete="off" data-tagcomplete>
<div class="minfo"> <button class="btn" id="btn-remove">Remove</button>
<p class="mcaption">{{.Caption}}</p> <button class="btn danger" id="btn-del-sel">Delete selected</button>
<p class="mdate">{{.Date.Format "2006-01-02"}}</p> </div>
</div>
<div class="mactions"> <div id="list"></div>
<a href="/photo/{{.Slug}}/">View</a> <div id="empty" style="display:none">No posts yet.</div>
<a href="/manage/edit/{{.Slug}}" class="medit">Edit</a>
<a href="/manage/delete/{{.Slug}}" class="mdelete">Delete</a>
</div>
</li>
{{end}}
</ul>
{{else}}
<p class="mempty">No photos yet.</p>
{{end}}
</div> </div>
<div id="toast"></div>
<script src="/static/tagcomplete.js"></script>
<script>
const list = document.getElementById('list');
const countEl = document.getElementById('count');
const emptyEl = document.getElementById('empty');
const bulkbar = document.getElementById('bulkbar');
const selEl = document.getElementById('sel');
const toast = document.getElementById('toast');
let toastTimer;
function flash(msg, ok) {
toast.textContent = msg;
toast.className = ok ? 'ok' : 'err';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.className = ''; }, 3500);
}
function selectedSlugs() {
return [...list.querySelectorAll('input.pick:checked')].map(c => c.dataset.slug);
}
function refreshBulk() {
const n = selectedSlugs().length;
selEl.textContent = n + ' selected';
bulkbar.classList.toggle('show', n > 0);
}
function row(p) {
const el = document.createElement('div');
el.className = 'row';
el.dataset.slug = p.slug;
el.innerHTML = `
<input type="checkbox" class="pick" data-slug="${p.slug}" aria-label="Select">
<a href="${p.url}" target="_blank" rel="noopener"><img class="thumb" src="${p.thumb}" alt=""></a>
<div class="fields">
<label>Caption</label>
<input type="text" class="f-caption" value="">
<label>Date</label>
<input type="date" class="f-date" value="${p.date}">
<label>Tags (comma-separated)</label>
<input type="text" class="f-tags" value="">
</div>
<div class="rowactions">
<button class="btn f-save">Save</button>
<button class="btn danger f-del">Delete</button>
</div>`;
// Set values via property to avoid HTML-escaping issues in attributes.
el.querySelector('.f-caption').value = p.caption;
el.querySelector('.f-tags').value = (p.tags || []).join(', ');
if (window.TagComplete) window.TagComplete.attach(el.querySelector('.f-tags'));
el.querySelector('.pick').addEventListener('change', refreshBulk);
el.querySelector('.f-save').addEventListener('click', () => save(el, p.slug));
el.querySelector('.f-del').addEventListener('click', () => delOne(el, p.slug));
return el;
}
async function load() {
try {
const res = await fetch('/api/photos', { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const photos = await res.json();
list.innerHTML = '';
photos.forEach(p => list.appendChild(row(p)));
countEl.textContent = photos.length + (photos.length === 1 ? ' post' : ' posts');
emptyEl.style.display = photos.length ? 'none' : 'block';
refreshBulk();
maybeFocusEdit();
} catch (err) {
countEl.textContent = '';
flash('Could not load posts: ' + err.message, false);
}
}
// Deep link from a photo page's "Edit this": /manage?edit=<slug> scrolls to
// that row, focuses its caption and highlights it briefly.
function maybeFocusEdit() {
const slug = new URLSearchParams(location.search).get('edit');
if (!slug) return;
const row = [...list.querySelectorAll('.row')].find(r => r.dataset.slug === slug);
if (!row) return;
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
const cap = row.querySelector('.f-caption');
if (cap) cap.focus();
row.classList.add('row-hl');
setTimeout(() => row.classList.remove('row-hl'), 2000);
}
async function post(url, body) {
const res = await fetch(url, { method: 'POST', body });
if (!res.ok) throw new Error((await res.text()).trim() || ('HTTP ' + res.status));
return res;
}
async function save(el, slug) {
const form = new FormData();
form.append('slug', slug);
form.append('caption', el.querySelector('.f-caption').value);
form.append('date', el.querySelector('.f-date').value);
form.append('tags', el.querySelector('.f-tags').value);
try {
await post('/api/edit', form);
if (window.TagComplete) window.TagComplete.invalidate();
flash('Saved ' + slug, true);
} catch (err) { flash('Save failed: ' + err.message, false); }
}
async function delOne(el, slug) {
if (!confirm('Move "' + slug + '" to trash?')) return;
const form = new FormData();
form.append('slug', slug);
try {
const res = await post('/api/delete', form);
const j = await res.json().catch(() => ({}));
if (!j.deleted) { flash('Not deleted — "' + slug + '" not found on the server', false); return; }
el.remove();
flash('Moved ' + slug + ' to trash', true);
refreshBulk();
updateCount();
} catch (err) { flash('Delete failed: ' + err.message, false); }
}
function updateCount() {
const n = list.querySelectorAll('.row').length;
countEl.textContent = n + (n === 1 ? ' post' : ' posts');
emptyEl.style.display = n ? 'none' : 'block';
}
document.getElementById('btn-del-sel').addEventListener('click', async () => {
const slugs = selectedSlugs();
if (!slugs.length) return;
if (!confirm('Move ' + slugs.length + ' post(s) to trash?')) return;
const form = new FormData();
form.append('slugs', slugs.join(','));
try {
const res = await post('/api/delete', form);
const j = await res.json().catch(() => ({}));
if (!j.deleted) { flash('Nothing deleted — slugs not found on the server', false); return; }
flash('Moved ' + j.deleted + ' to trash', true);
await load();
} catch (err) { flash('Delete failed: ' + err.message, false); }
});
async function bulkTags(field, label) {
const slugs = selectedSlugs();
const val = document.getElementById(field).value.trim();
if (!slugs.length || !val) return;
const form = new FormData();
form.append('slugs', slugs.join(','));
form.append(label, val);
try {
const res = await post('/api/tags', form);
const j = await res.json().catch(() => ({}));
if (!j.updated) { flash('No posts updated', false); return; }
if (window.TagComplete) window.TagComplete.invalidate();
document.getElementById(field).value = '';
flash('Updated tags on ' + j.updated + ' post(s)', true);
await load();
} catch (err) { flash('Tag update failed: ' + err.message, false); }
}
document.getElementById('btn-add').addEventListener('click', () => bulkTags('bulk-add', 'add'));
document.getElementById('btn-remove').addEventListener('click', () => bulkTags('bulk-remove', 'remove'));
// ── Trash ────────────────────────────────────────────────────────────────
const trashpanel = document.getElementById('trashpanel');
const trashlist = document.getElementById('trashlist');
const trashCount = document.getElementById('trash-count');
const trashEmpty = document.getElementById('trash-empty-msg');
function trashRow(it) {
const el = document.createElement('div');
el.className = 'trashrow';
const parts = [];
if (it.image) parts.push('image');
if (it.hasMeta) parts.push('caption/tags');
el.innerHTML = `
<div class="meta">
<div class="slug"></div>
<div class="when">deleted ${it.deletedAt} · ${parts.join(' + ') || 'empty'}</div>
</div>
<button class="btn t-restore">Restore</button>
<button class="btn danger t-del">Delete forever</button>`;
el.querySelector('.slug').textContent = it.slug;
el.querySelector('.t-restore').addEventListener('click', () => restore(it));
el.querySelector('.t-del').addEventListener('click', () => deleteForever(it));
return el;
}
async function loadTrash() {
try {
const res = await fetch('/api/trash', { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const items = await res.json();
trashlist.innerHTML = '';
items.forEach(it => trashlist.appendChild(trashRow(it)));
trashCount.textContent = items.length + (items.length === 1 ? ' item' : ' items');
trashEmpty.style.display = items.length ? 'none' : 'block';
} catch (err) {
flash('Could not load trash: ' + err.message, false);
}
}
async function restore(it) {
const form = new FormData();
form.append('id', it.id);
try {
await post('/api/restore', form);
flash('Restored ' + it.slug, true);
await Promise.all([load(), loadTrash()]);
} catch (err) { flash('Restore failed: ' + err.message, false); }
}
async function deleteForever(it) {
if (!confirm('Permanently delete "' + it.slug + '"? This cannot be undone.')) return;
const form = new FormData();
form.append('id', it.id);
try {
await post('/api/trash/empty', form);
flash('Deleted ' + it.slug + ' forever', true);
await loadTrash();
} catch (err) { flash('Delete failed: ' + err.message, false); }
}
document.getElementById('trash-toggle').addEventListener('click', (e) => {
e.preventDefault();
const showing = trashpanel.classList.toggle('show');
if (showing) loadTrash();
});
document.getElementById('btn-empty-all').addEventListener('click', async () => {
if (!confirm('Permanently delete everything in the trash? This cannot be undone.')) return;
const form = new FormData();
form.append('all', '1');
try {
await post('/api/trash/empty', form);
flash('Trash emptied', true);
await loadTrash();
} catch (err) { flash('Empty failed: ' + err.message, false); }
});
load();
</script>
<script src="/static/session.js"></script>
</body> </body>
</html> </html>
+7 -1
View File
@@ -35,7 +35,7 @@
{{if .Older}}<link rel="prefetch" href="/photo/{{.Older.Slug}}/"> {{if .Older}}<link rel="prefetch" href="/photo/{{.Older.Slug}}/">
<link rel="prefetch" as="image" href="/{{.Older.Medium}}">{{end}} <link rel="prefetch" as="image" href="/{{.Older.Medium}}">{{end}}
</head> </head>
<body> <body data-slug="{{.Photo.Slug}}">
<div class="photo-page"> <div class="photo-page">
<nav class="photo-nav"> <nav class="photo-nav">
<a href="/" class="back-btn">← Back</a> <a href="/" class="back-btn">← Back</a>
@@ -58,6 +58,11 @@
</div> </div>
<p class="dcaption">{{.Photo.Caption}}</p> <p class="dcaption">{{.Photo.Caption}}</p>
<p class="ddate">{{.Photo.Date.Format "Jan 02, 2006"}}</p> <p class="ddate">{{.Photo.Date.Format "Jan 02, 2006"}}</p>
{{if .Photo.Tags}}
<div class="dtags">
{{range .Photo.Tags}}<a href="/album/{{.Slug}}/" class="tag">{{.Name}}</a>{{end}}
</div>
{{end}}
<p class="dfull"><a href="/{{.Photo.File}}" download>↓ Full size</a></p> <p class="dfull"><a href="/{{.Photo.File}}" download>↓ Full size</a></p>
</div> </div>
</div> </div>
@@ -68,5 +73,6 @@
</footer> </footer>
<script src="/static/photo-nav.js" defer></script> <script src="/static/photo-nav.js" defer></script>
<script src="/static/session.js"></script>
</body> </body>
</html> </html>
+3 -1
View File
@@ -24,13 +24,14 @@
<p class="pcount"><strong>{{.Total}}</strong> posts</p> <p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p> <p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="{{.GridURL}}" class="rss-a">Grid</a> <a href="{{.GridURL}}" class="rss-a">Grid</a>
<a href="/albums/" class="rss-a">Albums</a>
<a href="/feed.xml" class="rss-a"> <a href="/feed.xml" class="rss-a">
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<circle cx="2.5" cy="11.5" r="1.5"/> <circle cx="2.5" cy="11.5" r="1.5"/>
<path d="M1.5 7.5A5 5 0 0 1 6.5 12.5H8A6.5 6.5 0 0 0 1.5 6V7.5z"/> <path d="M1.5 7.5A5 5 0 0 1 6.5 12.5H8A6.5 6.5 0 0 0 1.5 6V7.5z"/>
<path d="M1.5 4A8.5 8.5 0 0 1 10 12.5h1.5A10 10 0 0 0 1.5 2.5V4z"/> <path d="M1.5 4A8.5 8.5 0 0 1 10 12.5h1.5A10 10 0 0 0 1.5 2.5V4z"/>
</svg> </svg>
RSS feed Feed
</a> </a>
<a href="https://git.inflo.ws/aashiks/photog" class="rss-a" target="_blank" rel="noopener"> <a href="https://git.inflo.ws/aashiks/photog" class="rss-a" target="_blank" rel="noopener">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
@@ -110,5 +111,6 @@
io.observe(btn); io.observe(btn);
})(); })();
</script> </script>
<script src="/static/session.js"></script>
</body> </body>
</html> </html>
+223 -207
View File
@@ -10,171 +10,246 @@
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32"> <link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png"> <link rel="apple-touch-icon" href="/static/web-192.png">
<style> <style>
.upload-page { .upload-page { max-width: 640px; margin: 0 auto; padding: 32px 20px 80px; }
max-width: 520px;
margin: 0 auto;
padding: 32px 20px;
}
.upload-page h2 { .upload-page h2 {
font-family: var(--serif); font-family: var(--serif); font-size: 22px; font-weight: 600;
font-size: 22px; color: var(--fg); margin-bottom: 20px;
font-weight: 600;
color: var(--fg);
margin-bottom: 28px;
} }
.drop-zone { .pick-btn, .submit-btn {
border: 1px dashed var(--bg2); width: 100%; background: var(--bg1); border: 1px solid var(--bg2);
background: var(--bg0); color: var(--fg2); font-family: var(--serif); font-size: 16px; font-weight: 600;
width: 100%; padding: 12px; cursor: pointer; transition: background .2s, color .2s;
aspect-ratio: 1;
max-height: 340px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
margin-bottom: 24px;
position: relative;
overflow: hidden;
transition: border-color 0.2s;
} }
.drop-zone:hover { border-color: var(--fg4); } .pick-btn:hover:not(:disabled), .submit-btn:hover:not(:disabled) { background: var(--bg2); color: var(--fg); }
.drop-zone.has-image { border-style: solid; border-color: var(--bg2); } .submit-btn:disabled { opacity: .35; cursor: not-allowed; }
.drop-zone input[type="file"] { .pick-btn { margin-bottom: 20px; }
position: absolute; #file-input { display: none; }
inset: 0;
opacity: 0; #empty {
cursor: pointer; display: flex; align-items: center; justify-content: center;
width: 100%; height: 140px; background: var(--bg0); color: var(--fg4);
height: 100%; font-family: var(--serif); margin-bottom: 20px;
} }
.drop-prompt {
display: flex; .card { display: grid; grid-template-columns: 84px 1fr auto; gap: 14px; align-items: start; padding: 14px 0; border-top: 1px solid var(--bg1); }
flex-direction: column; .card .thumb { width: 84px; height: 84px; background: var(--bg1); object-fit: cover; display: block; }
align-items: center; .card .fields { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
gap: 10px; .card input {
color: var(--fg4); width: 100%; box-sizing: border-box; background: var(--bg0);
pointer-events: none; border: 1px solid var(--bg2); color: var(--fg); font-size: 15px; padding: 8px 10px; outline: none;
} }
.drop-prompt p { font-size: 14px; font-family: var(--serif); } .card input[type="text"] { font-family: var(--serif); }
.drop-prompt small { font-size: 12px; font-family: monospace; } .card input[type="date"] { font-family: monospace; font-size: 13px; color-scheme: dark; width: auto; }
#preview { .card input:focus { border-color: var(--fg4); }
display: none; .card input:disabled { opacity: .5; }
position: absolute; .c-remove {
inset: 0; background: none; border: none; color: var(--fg4); font-size: 16px; cursor: pointer; padding: 4px;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
} }
.change-hint { .c-status { font-family: monospace; font-size: 12px; min-height: 16px; }
display: none; .c-status.ok { color: #b8bb26; }
position: absolute; .c-status.err { color: #fb4934; }
inset: auto 0 0 0; .c-status.up { color: var(--fg4); }
padding: 6px 10px; .c-status a { color: #8ec07c; }
background: rgba(29, 32, 33, 0.75);
color: var(--fg2); #bar { margin-top: 20px; }
font-family: monospace; #summary { margin-top: 12px; font-family: monospace; font-size: 13px; color: var(--fg4); }
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
text-align: center;
pointer-events: none;
}
.drop-zone.has-image .change-hint { display: block; }
.field { margin-bottom: 18px; }
.field label {
display: block;
font-size: 12px;
color: var(--fg4);
font-family: monospace;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.field input[type="text"],
.field input[type="date"] {
width: 100%;
background: var(--bg0);
border: 1px solid var(--bg2);
color: var(--fg);
font-size: 16px;
padding: 10px 12px;
outline: none;
transition: border-color 0.2s;
}
.field input[type="text"] { font-family: var(--serif); }
.field input[type="date"] { font-family: monospace; font-size: 14px; color-scheme: dark; }
.field input:focus { border-color: var(--fg4); }
.submit-btn {
width: 100%;
background: var(--bg1);
border: 1px solid var(--bg2);
color: var(--fg2);
font-family: var(--serif);
font-size: 16px;
font-weight: 600;
padding: 12px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.submit-btn:hover:not(:disabled) { background: var(--bg2); color: var(--fg); }
.submit-btn:disabled { opacity: 0.35; cursor: not-allowed; }
#feedback {
display: none;
margin-top: 14px;
font-size: 13px;
font-family: monospace;
padding: 10px 12px;
}
#feedback.ok { display: block; background: #1d2e1d; color: #b8bb26; border: 1px solid #344d28; }
#feedback.err { display: block; background: #2e1d1d; color: #fb4934; border: 1px solid #5a2828; }
</style> </style>
</head> </head>
<body> <body>
<div class="upload-page"> <div class="upload-page">
<a href="/" class="back-btn">← Gallery</a> <a href="/" class="back-btn">← Gallery</a>
<h2>Upload photo</h2> <h2>Upload photos</h2>
<div class="drop-zone" id="drop-zone"> <input type="file" id="file-input" accept=".jpg,.jpeg,.png" multiple>
<input type="file" id="file-input" accept=".jpg,.jpeg,.png" aria-label="Choose photo"> <button class="pick-btn" id="pick-btn">Choose photos</button>
<div class="drop-prompt" id="prompt">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true"> <div id="empty">No photos selected</div>
<rect x="3" y="3" width="18" height="18" rx="1"/> <div id="list"></div>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/> <div id="bar" style="display:none">
</svg> <button class="submit-btn" id="post-btn">Post photos</button>
<p>Click to choose a photo</p> <div id="summary"></div>
<small>jpg or png</small>
</div>
<img id="preview" alt="Preview">
<div class="change-hint">Click to change</div>
</div> </div>
<div class="field">
<label for="caption">Caption</label>
<input type="text" id="caption" placeholder="Describe the photo" autocomplete="off">
</div>
<div class="field">
<label for="date">Date</label>
<input type="date" id="date">
</div>
<button class="submit-btn" id="submit-btn" disabled>Upload photo</button>
<div id="feedback"></div>
</div> </div>
<script src="/static/tagcomplete.js"></script>
<script> <script>
const fileInput = document.getElementById('file-input'); const fileInput = document.getElementById('file-input');
const preview = document.getElementById('preview'); const pickBtn = document.getElementById('pick-btn');
const prompt = document.getElementById('prompt'); const list = document.getElementById('list');
const dropZone = document.getElementById('drop-zone'); const emptyEl = document.getElementById('empty');
const submitBtn = document.getElementById('submit-btn'); const bar = document.getElementById('bar');
const feedback = document.getElementById('feedback'); const postBtn = document.getElementById('post-btn');
const dateInput = document.getElementById('date'); const summary = document.getElementById('summary');
dateInput.value = new Date().toISOString().slice(0, 10); // One entry per chosen photo. status: pending | uploading | ok | error.
let items = [];
let seq = 0;
let posting = false;
pickBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
for (const file of fileInput.files) addItem(file);
fileInput.value = ''; // allow re-choosing the same file
refresh();
});
// Mirror of the server's sanitiseSlug, so the client can predict the slug a
// filename yields and keep two files in one batch from colliding.
function slugify(name) {
let out = '';
for (const ch of name.toLowerCase()) {
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch === '-') out += ch;
else if (ch === ' ' || ch === '_') out += '-';
}
return out;
}
// A slug unique among the current cards. The server derives the slug from
// the uploaded filename, so two photos named the same would otherwise
// overwrite each other; the second (third, …) gets -2, -3, …
function uniqueSlug(filename) {
const stem = filename.replace(/\.[^.]+$/, '');
const base = slugify(stem) || 'photo';
let candidate = base;
let n = 2;
while (items.some((i) => i.slug === candidate)) candidate = base + '-' + (n++);
return candidate;
}
function addItem(file) {
const ext = (file.name.match(/\.[^.]+$/)?.[0] || '.jpg').toLowerCase();
const item = { id: ++seq, file, ext, slug: '', status: 'pending', el: null };
item.slug = uniqueSlug(file.name); // before push, so it compares against the others
items.push(item);
item.el = buildCard(item);
list.appendChild(item.el);
// Preview.
const url = URL.createObjectURL(file);
const img = item.el.querySelector('.thumb');
img.onload = () => URL.revokeObjectURL(url);
img.src = url;
// Prefill date from EXIF, else file mtime.
prefillDate(item, file);
// Tag autocomplete on this card's tags field.
if (window.TagComplete) window.TagComplete.attach(item.el.querySelector('.c-tags'));
}
function buildCard(item) {
const el = document.createElement('div');
el.className = 'card';
el.innerHTML = `
<img class="thumb" alt="">
<div class="fields">
<input type="text" class="c-caption" placeholder="Caption" autocomplete="off">
<input type="date" class="c-date">
<input type="text" class="c-tags" placeholder="tags, comma-separated" autocomplete="off">
<div class="c-status"></div>
</div>
<button class="c-remove" title="Remove">✕</button>`;
el.querySelector('.c-remove').addEventListener('click', () => removeItem(item.id));
return el;
}
async function prefillDate(item, file) {
let d = null;
try { d = await readExifDate(file); } catch (_) {}
if (!d && file.lastModified) d = new Date(file.lastModified).toISOString().slice(0, 10);
if (!d) d = new Date().toISOString().slice(0, 10);
item.el.querySelector('.c-date').value = d;
}
function removeItem(id) {
const item = items.find((i) => i.id === id);
if (!item || item.status === 'uploading') return;
item.el.remove();
items = items.filter((i) => i.id !== id);
refresh();
}
function refresh() {
const has = items.length > 0;
emptyEl.style.display = has ? 'none' : 'flex';
bar.style.display = has ? 'block' : 'none';
pickBtn.textContent = has ? 'Add more photos' : 'Choose photos';
updateButton();
}
function pending() { return items.filter((i) => i.status !== 'ok'); }
function updateButton() {
const n = pending().length;
postBtn.disabled = posting || n === 0;
postBtn.textContent = posting
? 'Posting…'
: n === 0 ? 'All posted' : (n === 1 ? 'Post 1 photo' : `Post ${n} photos`);
}
function setStatus(item, cls, html) {
const s = item.el.querySelector('.c-status');
s.className = 'c-status ' + cls;
s.innerHTML = html;
}
function setDisabled(item, disabled) {
item.el.querySelectorAll('input').forEach((i) => (i.disabled = disabled));
item.el.querySelector('.c-remove').disabled = disabled;
}
async function postOne(item) {
item.status = 'uploading';
setDisabled(item, true);
setStatus(item, 'up', 'Uploading…');
const form = new FormData();
// Send under the deduped slug so the server (which slugs from the
// filename) can't overwrite another photo in the same batch.
form.append('image', item.file, item.slug + item.ext);
form.append('caption', item.el.querySelector('.c-caption').value);
form.append('date', item.el.querySelector('.c-date').value);
form.append('tags', item.el.querySelector('.c-tags').value);
try {
const res = await fetch('/upload', { method: 'POST', body: form });
const text = (await res.text()).trim();
if (res.ok) {
item.status = 'ok';
const slug = text.replace(/^uploaded\s+/, '').replace(/\.[^.]+$/, '');
setStatus(item, 'ok', '✓ posted · <a href="/photo/' + encodeURIComponent(slug) + '/" target="_blank" rel="noopener">view</a>');
} else {
item.status = 'error';
setDisabled(item, false);
setStatus(item, 'err', '✗ ' + (text || res.status));
}
} catch (err) {
item.status = 'error';
setDisabled(item, false);
setStatus(item, 'err', '✗ ' + err.message);
}
}
postBtn.addEventListener('click', async () => {
if (posting) return;
posting = true;
updateButton();
if (window.TagComplete) window.TagComplete.invalidate();
// Sequential: gentler on mobile connections, and the rebuild is debounced
// server-side anyway.
const todo = pending();
let done = 0;
for (const item of todo) {
await postOne(item);
done++;
summary.textContent = `${done}/${todo.length} processed`;
}
posting = false;
const ok = items.filter((i) => i.status === 'ok').length;
const failed = items.filter((i) => i.status === 'error').length;
summary.textContent = `${ok} posted` + (failed ? `, ${failed} failed (press Post to retry)` : '');
updateButton();
});
// Read DateTimeOriginal from a JPEG's EXIF APP1 segment. Returns "YYYY-MM-DD" // Read DateTimeOriginal from a JPEG's EXIF APP1 segment. Returns "YYYY-MM-DD"
// or null if the file isn't a JPEG, has no EXIF, or lacks the tag. // or null if the file isn't a JPEG, has no EXIF, or lacks the tag.
@@ -222,66 +297,7 @@
} }
return null; return null;
} }
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
preview.onload = () => URL.revokeObjectURL(url);
preview.src = url;
preview.style.display = 'block';
prompt.style.display = 'none';
dropZone.classList.add('has-image');
submitBtn.disabled = false;
feedback.className = '';
feedback.style.display = 'none';
let d = null;
try { d = await readExifDate(file); } catch (_) {}
if (!d && file.lastModified) {
d = new Date(file.lastModified).toISOString().slice(0, 10);
}
if (d) dateInput.value = d;
});
submitBtn.addEventListener('click', async () => {
const file = fileInput.files[0];
if (!file) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Uploading…';
feedback.className = '';
const form = new FormData();
form.append('image', file);
form.append('caption', document.getElementById('caption').value);
form.append('date', dateInput.value);
try {
const res = await fetch('/upload', { method: 'POST', body: form });
const text = (await res.text()).trim();
if (res.ok) {
feedback.className = 'ok';
feedback.textContent = '✓ ' + text;
fileInput.value = '';
preview.style.display = 'none';
prompt.style.display = 'flex';
dropZone.classList.remove('has-image');
document.getElementById('caption').value = '';
submitBtn.disabled = true;
} else {
feedback.className = 'err';
feedback.textContent = '✗ ' + text;
submitBtn.disabled = false;
}
} catch (err) {
feedback.className = 'err';
feedback.textContent = '✗ ' + err.message;
submitBtn.disabled = false;
}
submitBtn.textContent = 'Upload photo';
});
</script> </script>
<script src="/static/session.js"></script>
</body> </body>
</html> </html>
+206
View File
@@ -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"` // "<slug>.<stamp>", 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 "<slug>.<stamp><ext>" 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) // "<slug>.<stamp>"
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 "<slug>.<stamp>" 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 // "<slug>.<stamp>"
// "<slug>.<stamp>.png" → "<slug>.png"; "<slug>.<stamp>.toml" → "<slug>.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})
}
+139
View File
@@ -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)
}
}
}
+8 -1
View File
@@ -30,6 +30,12 @@ func NewWatcher(dir string, g *Generator) (*Watcher, error) {
// Watch blocks, watching for changes and triggering rebuilds. // Watch blocks, watching for changes and triggering rebuilds.
// Rapid events (e.g. scp writing an image then its sidecar) are debounced // 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. // 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() { func (w *Watcher) Watch() {
var timer *time.Timer var timer *time.Timer
rebuild := func() { rebuild := func() {
@@ -47,7 +53,8 @@ func (w *Watcher) Watch() {
if !isRelevant(event.Name) { if !isRelevant(event.Name) {
continue 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 { if timer != nil {
timer.Stop() timer.Stop()
} }