# 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 ` 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=`, 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`, `deletedAt`, `image`, `hasMeta`), newest first. - `POST /api/restore` — move a trashed item (`id`) back to `content/`, stripping the timestamp. Refuses with `409` if a live post already holds that slug. - `POST /api/trash/empty` — **hard delete** trashed items: specific `id`s (repeated), or everything with `all=1`. The only non-reversible delete in the system. The manage page has a Trash panel (header toggle) for restore / delete-forever / empty. `trash.go` handles these; filenames parse as `.` (slug is dot-free and the stamp is a fixed `20060102-150405`, so parts peel off unambiguously), and `id`s are validated the same way to block traversal. Each non-trash mutation triggers the same async rebuild as upload. Sidecars are written atomically (temp + rename) so a concurrent build never sees a half-written file. Slugs are validated (`resolveSlug`: bare filename only, must name an existing content image) which closes path traversal and dotfile access. Every state-changing POST (`edit`, `delete`, `tags`, `restore`, `trash/empty`) first passes `sameOrigin` (`server.go`): because Basic-auth credentials are replayed by the browser on every request to the host, a cross-site page could otherwise drive a logged-in user into a delete (CSRF). The check honours `Sec-Fetch-Site`, falls back to comparing `Origin` against `-base-url`/`Host`, and allows header-less non-browser clients (curl). Cross-site → `403`. (Ported from the independent `/manage` on `plex/trunk-ng`.) **Delete relies on `Build`'s prune step** (`generator.go`): a rebuild drops the derived HTML trees (`photo/ day/ album/ page/ grid/page/`) and regenerates them, and `pruneRenditions` removes `images|thumbs|medium|og` files with no backing photo. Without this a deleted post would linger at its old URL and in stale day/album pages. This also cleans up after a sidecar removed by hand. ## Stack - **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`, `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 `//`. `viewBase`/`pageURL` (`generator.go`) derive every page path, pager link and the Timeline↔Grid cross-links (`TimelineURL`/`GridURL` in the template data) from that one setting; `Build` removes both `timeline/` and `grid/` each run so a flipped default leaves no stale root behind. - **Timeline** (the default root): photos grouped by calendar day — one ISO date heading (`2026-06-14`) + count per day, then the square grid. `groupByDay` collapses the newest-first list into consecutive same-day runs; `chunkDayGroups` packs whole days into pages targeting `pageSize`, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this). - **Grid** is the flat square grid. At `/grid/` by default, or at the root when `-default-view grid`. - Each timeline date heading links to a **single-day page** (`/day//`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug. - Single photo page with sidebar and newer/older navigation ### Albums (tags) A second grouping axis, parallel to days: an album is just a tag (`albums.go`). `groupByTag` mirrors `groupByDay`, and `renderAlbumPages` writes a paginated square-grid album per tag at `/album//` (like the grid view). `/albums/` is the index of every tag with counts; `/tags.json` is the machine-readable tag list (`[{tag, slug, count}]`, served statically like `feed.xml`). Tags show on each photo page and link to their album; an **Albums** link sits in the timeline/grid header. Tags are assigned at upload via the `tags` field on `POST /upload` (comma-separated) or by editing a sidecar's `tags`. All derived at build time — no database. ### Link previews Photo pages carry Open Graph + Twitter-card meta tags so a shared link unfurls 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 `` 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//` | | `templates/album.html` | Album (tag) page at `/album//`, paginated | | `templates/albums.html` | Album index at `/albums/` | | `templates/photo.html` | Single photo page, neighbour links and prefetch | | `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