Add GET /api/session, an unguarded probe that reports whether the request
carries valid credentials. It returns 200 with {"authenticated":bool} and never
sends WWW-Authenticate, so the static public pages can check login state without
triggering the browser's Basic-auth dialog. To keep it from being a
password-guessing oracle that bypasses the /upload limiter, a request with no
credentials is answered cheaply as false, while wrong credentials go through the
same failed-attempt limiter (429 once drained).
static/session.js, loaded by every page template, calls it and, only when
authenticated, builds a fixed toolbar: Upload / Manage links, an "Edit this"
link on photo pages (via <body data-slug>, deep-linking to /manage?edit=<slug>,
which scrolls to and focuses that row), a signed-in indicator, and a log-out
control. Logged-out visitors see nothing and get no dialog.
Log out is best-effort: HTTP Basic has no reliable sign-out (the browser caches
credentials until the window closes), so it overwrites the cached credential and
reloads, which works in some browsers but not all. A dependable logout would
need cookie sessions.
269 lines
15 KiB
Markdown
269 lines
15 KiB
Markdown
# photogallery
|
||
|
||
Personal static photo gallery with Atom feed and upload API. Single-user.
|
||
No database, no framework — just a Go binary that watches a directory and regenerates a static site.
|
||
An Android app in `android/` posts to the same upload API.
|
||
|
||
## Architecture
|
||
|
||
```
|
||
content/ # images + .toml sidecars (source of truth)
|
||
public/ # generated output (served by nginx / --serve flag)
|
||
templates/ # embedded HTML templates
|
||
static/ # embedded CSS + JS
|
||
android/ # Kotlin/Compose uploader app, own flake
|
||
```
|
||
|
||
The binary does three things at once:
|
||
- Watches `content/` via fsnotify and rebuilds on any change
|
||
- Serves a multipart upload API at `POST /upload`
|
||
- In dev mode (`--serve`), also serves `public/` as static files
|
||
|
||
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart),
|
||
`GET /manage` (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 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.
|
||
|
||
## Auth
|
||
|
||
`-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.
|
||
|
||
`GET /api/session` is a deliberately **unguarded** probe (`handleSession`): it
|
||
reads `r.BasicAuth()`, returns `200 {"authenticated":bool}` and never sends
|
||
`WWW-Authenticate`, so a public page can ask without triggering the browser's
|
||
login dialog. To stop it becoming a password oracle that sidesteps the limiter,
|
||
a request with no credentials is answered cheaply as `false`, while one with
|
||
*wrong* credentials runs through the same failed-attempt limiter (429 once
|
||
drained). `static/session.js` (loaded by every page template) calls it and, when
|
||
authenticated, builds a fixed toolbar with Upload / Manage / "Edit this"
|
||
(`/manage?edit=<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.
|
||
- `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
|
||
|
||
- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`, `golang.org/x/crypto` (bcrypt), `golang.org/x/time` (rate)
|
||
- **Templates** — `html/template`, embedded via `embed.FS`
|
||
- **Images** — four renditions per photo: original copy, 600×600 centre-cropped square thumbnail, a 1600px-longest-side "medium" for the detail page, and a 1200px "og" for link previews. Catmull-Rom resize. Outputs are skipped when newer than the source.
|
||
- **Feed** — Atom (`encoding/xml`)
|
||
- **NixOS** — `flake.nix` + `module.nix`, exports `nixosModules.default`
|
||
|
||
## Sidecar format
|
||
|
||
```toml
|
||
caption = "Tempelhof at sunrise"
|
||
date = "2026-06-14"
|
||
tags = ["berlin", "sunrise"]
|
||
```
|
||
|
||
Missing sidecar → filename as caption, today as date. Photos sort newest first by that date.
|
||
|
||
`tags` is optional, free-text, and defines albums (see below). Tags are
|
||
normalized to lowercase on load/upload (`parseTags` in `albums.go`); a tag may
|
||
contain spaces (`"berlin trip"`), and its URL slug is derived with `sanitiseSlug`.
|
||
|
||
## Design
|
||
|
||
- Gruvbox dark hard colour scheme
|
||
- Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url`
|
||
- Manjari for Malayalam text (bio line), via `-ml-family`
|
||
- Two views of the same photos, both 3-column square grid → 2-column on mobile, both with infinite-scroll load-more. Page size is configurable via `-page-size` (default 60; NixOS `services.photogallery.pageSize`). 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** (the default root): photos grouped by calendar day — one ISO date heading (`2026-06-14`) + count per day, then the square grid. `groupByDay` collapses the newest-first list into consecutive same-day runs; `chunkDayGroups` packs whole days into pages targeting `pageSize`, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this).
|
||
- **Grid** is the flat square grid. At `/grid/` by default, or at the root when `-default-view grid`.
|
||
- Each timeline date heading links to a **single-day page** (`/day/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug.
|
||
- 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
|
||
|
||
Photo pages carry Open Graph + Twitter-card meta tags so a shared link unfurls
|
||
in WhatsApp, Telegram, etc. `generateOG` writes a dedicated `public/og/`
|
||
rendition — 1200px longest side, quality laddered down until under ~250 KB,
|
||
because WhatsApp drops the preview above ~300 KB and the medium rendition can
|
||
exceed that. The `og:image` URL is absolute (`{{.BaseURL}}/...`); both crawlers
|
||
ignore relative image paths, and previews only work against the deployed
|
||
`-base-url`, never `http://localhost`. `og:image:width`/`height` come from
|
||
`DecodeConfig` on the OG file, read even when the file itself was skipped as up
|
||
to date. Both platforms cache per URL — after a deploy, re-scrape via
|
||
`@webpagebot` (Telegram) or Facebook's sharing debugger (WhatsApp).
|
||
|
||
### Photo page navigation
|
||
|
||
`static/photo-nav.js` handles arrow keys and touch swipe. Both read their
|
||
destinations from the `[data-nav="newer"]` / `[data-nav="older"]` anchors the
|
||
template renders, so a photo with no sibling in that direction has nothing to
|
||
find and the gesture rubber-bands. If the script fails to load the links still
|
||
work.
|
||
|
||
Swipe left → older, right → newer, matching ArrowRight/ArrowLeft. Constraints
|
||
worth knowing before touching it:
|
||
|
||
- Swipes starting within 24px of a screen edge are ignored — that strip is the browser's own back-gesture.
|
||
- Only `pointerType === "touch"`, so desktop drag-to-save is untouched.
|
||
- Axis locks once after 10px so vertical drags stay scrolls; `touch-action: pan-y` tells the compositor the same.
|
||
- The image is wrapped in an `<a>` to the full-size original, so the click trailing a swipe is suppressed or it opens a new tab.
|
||
- `EXIT_MS` in the JS must stay in sync with the transition duration in `style.css`.
|
||
|
||
Anything added under `static/` is embedded and copied to `public/static/`
|
||
automatically — `assets.go` embeds the whole directory and `copyStatic()` walks
|
||
every entry, so new CSS/JS needs no generator change.
|
||
|
||
## Running locally
|
||
|
||
```bash
|
||
nix develop
|
||
go mod tidy
|
||
go run . --serve
|
||
# gallery: http://localhost:8080
|
||
# upload: http://localhost:8080/upload
|
||
```
|
||
|
||
## 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.
|
||
|
||
Its own flake, deliberately separate from the root one so NixOS consumers of `nixosModules.default` don't pull the Android SDK into their lock:
|
||
|
||
```bash
|
||
cd android
|
||
nix develop
|
||
./gradlew assembleRelease # app/build/outputs/apk/release/app-release.apk
|
||
```
|
||
|
||
The server needs no changes to serve it — every gap (slug collisions, EXIF, HEIC, size caps) is closed client-side. Release builds need `android/keystore.properties`; without it the signing config is skipped and only debug builds work.
|
||
|
||
## NixOS module usage
|
||
|
||
```nix
|
||
inputs.photogallery.url = "github:youruser/photogallery";
|
||
# in modules:
|
||
inputs.photogallery.nixosModules.default
|
||
# in configuration.nix:
|
||
services.photogallery = {
|
||
enable = true;
|
||
baseURL = "https://photos.yourdomain.tld";
|
||
# bcrypt htpasswd (`htpasswd -B`); handed to the service via LoadCredential,
|
||
# so a root-owned 0400 secret is fine. Omit to leave /upload open.
|
||
htpasswdFile = "/run/secrets/photogallery-htpasswd";
|
||
nginx = {
|
||
enable = true;
|
||
domain = "photos.yourdomain.tld";
|
||
};
|
||
};
|
||
```
|
||
|
||
## Bootstrap (first nix build)
|
||
|
||
```bash
|
||
go mod tidy # generates go.sum — commit it
|
||
nix build # fails, prints correct vendorHash
|
||
# paste hash into flake.nix
|
||
nix build # succeeds
|
||
```
|
||
|
||
## Key files
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `main.go` | Flags, wires generator + watcher + server |
|
||
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
|
||
| `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` |
|
||
| `server.go` | Upload API + optional static file serving |
|
||
| `auth.go` | htpasswd (bcrypt) HTTP Basic auth + failed-attempt rate limit for `/upload` |
|
||
| `manage.go` | Edit/delete API (`/api/photos`, `/api/edit`, `/api/tags`, `/api/delete`) + trash move + atomic sidecar writes |
|
||
| `trash.go` | Trash API: list (`/api/trash`), `/api/restore`, `/api/trash/empty` (hard delete) |
|
||
| `templates/manage.html` | Auth-gated edit/delete web UI at `/manage`, with a Trash panel |
|
||
| `watcher.go` | fsnotify watcher with 500ms debounce |
|
||
| `assets.go` | `//go:embed` for templates and static |
|
||
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
|
||
| `templates/grid.html` | Flat square grid at `/grid/`, paginated |
|
||
| `templates/day.html` | Single-day page at `/day/<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 |
|
||
| `static/photo-nav.js` | Arrow-key and swipe navigation |
|
||
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
|
||
| `flake.nix` | `buildGoModule` package + devShell + nixosModules |
|
||
| `android/flake.nix` | Android SDK dev shell (separate from the root flake) |
|
||
|
||
## Gotchas
|
||
|
||
- `.gitignore` patterns here must be anchored. An unanchored `photogallery` also matches the Kotlin package directory `android/app/src/main/java/ws/inflo/photogallery/` and silently swallows every source file in it.
|
||
|
||
## What's not done yet
|
||
|
||
- ActivityPub federation (planned later — start with RSS/Atom)
|
||
- Edit/delete/trash in the Android and Qt apps (web `/manage` only so far; the `/api/` endpoints are there for the apps to adopt)
|
||
- Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes)
|
||
- The Android app has never been run on real hardware
|
||
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
|