Files
photog/CLAUDE.md
T
puttaalu 2143b95657 Fix Android manage thumbnails and multi-select picker
Thumbnails were blank on the manage screen because the app fetched them from
the static /thumbs/ path, which a front-door botwall on `location /` blocks for
a non-browser client even though /api/ (proxied to the app) works. Add an authed
GET /api/thumb/{slug} that serves the thumbnail JPEG from public/thumbs/, and
have the app fetch from there. Also show the thumbnail in the edit dialog, not
just the list rows.

Construct the photo picker with an explicit maxItems so it reliably opens in
multi-select mode; the no-arg PickMultipleVisualMedia default has been seen to
present single-select on some devices.
2026-10-19 15:33:25 +02:00

16 KiB
Raw Blame History

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 — 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 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.
  • 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 ids (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 ids 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

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.

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

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 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:

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

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)

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 in the Qt desktop app (web /manage and the Android app have it; the /api/ endpoints are there for Qt to adopt). Restoring from trash is still web-only on all clients.
  • Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes)
  • The Android app has never been run on real hardware
  • Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part