Files
photog/CLAUDE.md
T
puttaalu 8fa1aa4cd4 Add -default-view to choose timeline or grid as the site root
The view named by -default-view (timeline, the default, or grid) is published
at the site root; the other is served under /timeline/ or /grid/. viewBase and
pageURL derive every page path, pager link and the Timeline<->Grid cross-link
from the single setting, and Build removes both timeline/ and grid/ on each run
so flipping the default never leaves a stale root behind.

Reimplemented on trunk (which has albums and the /manage feature) rather than
cherry-picked, since the original was written against an earlier base. Credit
for the edit/delete and default-view ideas goes to Santhosh Raju, now noted in
a README Contributors section.

- main.go: -default-view flag, validated (fatal on anything but timeline/grid)
- module.nix: services.photogallery.defaultView enum option
- templates: timeline/grid/day/album/albums cross-links use TimelineURL/GridURL
- tests: timeline-root, grid-root, and switch-cleanup
2026-10-18 22:06:46 +02:00

14 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), 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.

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 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 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";
  # 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/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