Brings the Qt uploader to parity with the web /manage page and the Android
app. Adds a stacked Manage page (reached from the post page) that lists
posts via GET /api/photos with thumbnails from GET /api/thumb/<slug>, edits
caption/date/tags in a QDialog via POST /api/edit, and deletes to trash via
POST /api/delete with a QMessageBox confirm. As a non-browser client it sends
no Origin and so passes the server's same-origin CSRF check.
Tag autocomplete now backs every tags field (post cards and the edit dialog)
via a per-token TagCompleter (QCompleter subclass, MatchContains) fed from
the unguarded GET /api/tags.json.
New files: managepostwidget.{h,cpp}, tagcompleter.{h,cpp}; network methods
and a RemotePhoto struct added to uploadclient. Built and linked clean; not
yet runtime-tested on a real display.
17 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/. Unguarded: thumbnails are the same public data as the static/thumbs/, so nothing is protected, and keeping it off the auth path means it can never 401 and so can never trip the fail2ban auth jail over a per-row thumbnail load. Non-browser clients (the Android app) use it 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.GET /api/tags.json— serves the generatedpublic/tags.jsonfor the Android app's tag autocomplete. Unguarded (like/api/session): the tag list is the same public data as the static/tags.json, so nothing is protected, and keeping it off the auth path means it can never return 401 — otherwise an nginx auth-fail jail (fail2ban) would ban the app over a mere autocomplete poll. It lives under/api/only so nginx proxies it past a botwall; browsers read the static/tags.json.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> (unguarded public data, app-served) rather than the static /thumbs/ path, so a botwall on location / doesn't blank them. Both tag fields (post and edit) autocomplete from the known tag list, fetched from /api/tags.json (unguarded public data, proxied past the botwall); suggestions filter on the token after the last comma and tapping one completes it. 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)
- Restoring from trash is still web-only: the
/managepage has it, but neither the Android nor the Qt desktop app exposes/api/restoreor/api/trashyet. (Edit/delete and tag autocomplete now exist in all three 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