/manage lists every photo; /manage/edit/{slug} rewrites the photo's
.toml sidecar (caption, date), /manage/delete/{slug} asks for
confirmation and then removes the image, its sidecar and everything
generated from them. Builds now drop day pages of days without photos
and pager pages past the last one, so a delete or a date change leaves
nothing behind.
The pages are off by default: the routes are only registered with
-manage, so an install that does not opt in has no such code path and
answers 404. NixOS gets services.photogallery.manage.enable, which
also adds the nginx location only when on. Like /upload, the pages
carry no auth of their own and are not linked from the public site.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
10 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), 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).
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.
Managing photos
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:
- An edit only rewrites the
.tomlsidecar. Any key other thancaptionanddatein a hand-written sidecar is dropped, same as on upload. - Slugs from the URL are matched against the photos actually in
content/, never joined into a path. sameOrigincompares theOriginheader against-base-url, because behind nginx theHostheader is the upstream address. A wrong-base-urltherefore breaks saving in browsers that send noSec-Fetch-Site.- Delete is permanent and takes two steps:
GET /manage/delete/{slug}only shows a confirmation page, thePOSTdoes the work.Generator.Deleteremoves the image and sidecar fromcontent/first, then the four renditions andpublic/photo/<slug>/, all under the build mutex. There is no trash folder — recovery means restoring from a backup. - Listing pages are put right by the rebuild that follows, which also prunes what a delete or a date change leaves behind:
renderDayPagesdropspublic/day/<date>/for days without photos, andprunePagesdrops pager pages past the last one. Renditions of photos removed fromcontent/by hand (not via/manage) are still not pruned.
Stack
- Go — stdlib +
BurntSushi/toml,fsnotify/fsnotify,golang.org/x/image - 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"
Missing sidecar → filename as caption, today as date. Photos sort newest first by that date.
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):- 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.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 (
/grid/,/grid/page/2/, …) is the flat square grid, linked from the profile header. - 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 is the site root by default (
- Which view owns the site root is set by
-default-view timeline|grid(NixOSservices.photogallery.defaultView). The other view moves under/<name>/: with-default-view gridthe grid is/and the timeline is/timeline/.viewBaseandpageURLingenerator.goderive every page path and pager link from that, and the templates take the cross-links asGridURL/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
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 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:
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";
nginx = {
enable = true;
domain = "photos.yourdomain.tld";
htpasswdFile = "/etc/nginx/.htpasswd-gallery";
};
};
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 |
server.go |
Upload API + optional static file serving |
manage.go |
/manage list and edit handlers |
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/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 |
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)
- Multi-image upload
- Pruning renditions and photo pages of photos deleted from
content/by hand (delete via/managecleans up after itself) - Any auth beyond nginx htpasswd
- 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