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.
16 KiB
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 servespublic/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 frompublic/thumbs/, behind auth. Non-browser clients (the Android app) use this instead of the static/thumbs/<slug>.jpgpath, because a front-door botwall onlocation /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 (slugsCSV +add/removeCSV), leaving each post's caption and date intact.POST /api/delete— move posts (slugrepeated, orslugsCSV) tocontent/.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 tocontent/, stripping the timestamp. Refuses with409if a live post already holds that slug.POST /api/trash/empty— hard delete trashed items: specificids (repeated), or everything withall=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 viaembed.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, exportsnixosModules.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; NixOSservices.photogallery.pageSize). Which view sits at the site root is chosen by-default-view(timelineorgrid, defaulttimeline; NixOSservices.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/GridURLin the template data) from that one setting;Buildremoves bothtimeline/andgrid/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.groupByDaycollapses the newest-first list into consecutive same-day runs;chunkDayGroupspacks whole days into pages targetingpageSize, 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.renderDayPageswrites one pergroupByDayrun; the ISO label doubles as the URL slug.
- Timeline (the default root): photos grouped by calendar day — one ISO date heading (
- 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-ytells 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_MSin the JS must stay in sync with the transition duration instyle.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
.gitignorepatterns here must be anchored. An unanchoredphotogalleryalso matches the Kotlin package directoryandroid/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
/manageand 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