A new Manage screen (reached from the Post screen) lists published posts with
thumbnails and lets you edit a post's caption, date and tags or delete it to the
server's trash, over the same /api/photos, /api/edit and /api/delete endpoints
the web /manage uses.
UploadClient gains listPhotos/editPost/deletePost/fetchThumbnail, parsing JSON
with the built-in org.json (no new dependency) and posting edits/deletes as
urlencoded forms. The app sends no Origin or Sec-Fetch-Site header, so it passes
the server's same-origin CSRF check as a non-browser client; Basic auth is
already attached on every request. UploadViewModel holds the manage state,
loads the list, fetches thumbnails in the background, and applies edits/deletes
optimistically.
Add GET /api/session, an unguarded probe that reports whether the request
carries valid credentials. It returns 200 with {"authenticated":bool} and never
sends WWW-Authenticate, so the static public pages can check login state without
triggering the browser's Basic-auth dialog. To keep it from being a
password-guessing oracle that bypasses the /upload limiter, a request with no
credentials is answered cheaply as false, while wrong credentials go through the
same failed-attempt limiter (429 once drained).
static/session.js, loaded by every page template, calls it and, only when
authenticated, builds a fixed toolbar: Upload / Manage links, an "Edit this"
link on photo pages (via <body data-slug>, deep-linking to /manage?edit=<slug>,
which scrolls to and focuses that row), a signed-in indicator, and a log-out
control. Logged-out visitors see nothing and get no dialog.
Log out is best-effort: HTTP Basic has no reliable sign-out (the browser caches
credentials until the window closes), so it overwrites the cached credential and
reloads, which works in some browsers but not all. A dependable logout would
need cookie sessions.
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
A new auth-gated web UI at /manage lists every post for inline editing of
caption, date and tags, with per-row and bulk delete and bulk tag add/remove.
It is backed by a small JSON API (all behind the same htpasswd Basic auth as
/upload, and unlinked from the public site):
GET /api/photos list posts (newest first)
POST /api/edit rewrite one sidecar (caption, date, tags)
POST /api/tags bulk add/remove tags across posts
POST /api/delete move posts to content/.trash/ (reversible)
GET /api/trash list trashed items
POST /api/restore move a trashed item back (409 on live-slug clash)
POST /api/trash/empty hard delete trashed items (id… or all=1)
Design notes:
- Editing only rewrites the .toml; the slug (and URL) never changes.
- Delete is reversible: image + sidecar move to content/.trash/ with a
timestamp suffix, excluded from the site (loadPhotos skips dirs). The only
hard delete is emptying the trash.
- Build now prunes: it drops the derived HTML trees (photo/ day/ album/ page/
grid/page/) and regenerates them, and pruneRenditions removes orphaned
images/thumbs/medium/og. Without this a deleted post would linger at its old
URL and on stale day/album pages; this also cleans up after a hand-removed
sidecar.
- Sidecars are written atomically (temp + rename) so a concurrent build never
observes a half-written file.
- Slugs and trash ids are validated as bare filenames, closing path traversal
and dotfile access.
- Every state-changing POST passes a sameOrigin (CSRF) check, since Basic-auth
credentials are replayed by the browser on every request to the host. It
honours Sec-Fetch-Site, falls back to Origin vs base-url/Host, and allows
header-less non-browser clients. Ported from the independent /manage on
plex/trunk-ng.
module.nix proxies /manage and /api/ to the app (nginx otherwise serves them
as missing static files). The apps are unchanged; the API is there for them to
adopt edit/delete later.
Add HTTP Basic auth on /upload, reading an Apache htpasswd file (bcrypt
only, `htpasswd -B`) via a new -htpasswd flag. This lets nginx drop to a
plain TLS-terminating reverse proxy with no auth of its own; the existing
bcrypt htpasswd file is reused unchanged.
auth.go:
- LoadHtpasswd parses user:hash lines, keeps bcrypt entries ($2a/$2b/$2y,
so Apache's $2y$ works), skips MD5/SHA/crypt with a warning.
- Unknown users still pay for one bcrypt comparison against a dummy hash,
so response timing can't enumerate valid usernames.
- Failed attempts draw from a token-bucket limiter (x/time/rate, ~1/s,
burst 10) returning 429 once drained; successful and bulk uploads never
touch it.
server.go guards both /upload routes (nil authenticator = passthrough for
local --serve). /health stays open. Empty -htpasswd disables auth and logs
a warning.
module.nix: new top-level services.photogallery.htpasswdFile, delivered to
the service via systemd LoadCredential (so a root-owned 0400 secret works).
Removed nginx.htpasswdFile and basic_auth from the vhost.
Both uploader apps already send Basic credentials, so no client change.
Deps: golang.org/x/crypto/bcrypt, golang.org/x/time/rate (vendorHash bump).
An album is a tag. Photos carry lowercase free-text tags in their .toml
sidecar (`tags = [...]`), parallel to the existing day grouping — no
database, all derived at build time.
- albums.go: parseTags (lowercase/trim/dedupe, slug via sanitiseSlug),
groupByTag mirroring groupByDay, and rendering for album pages, the
index, and the JSON API. Slug collisions between distinct names are
suffixed and logged.
- Routes: /album/<slug>/ (paginated square grid per tag), /albums/ (index
with counts), /tags.json (machine-readable tag list, served statically
like feed.xml).
- POST /upload gains a `tags` field written into the sidecar; the web
upload form gains a tags input.
- Tags shown on each photo page linking to their album; an Albums link in
the timeline/grid header.
Shared links now unfurl with an image in WhatsApp, Telegram and anything
else that reads Open Graph, instead of showing the bare URL.
A dedicated og/ rendition is generated per photo: 1200px longest side,
quality laddered down until it fits under ~250 KB. WhatsApp drops the
preview entirely once the image passes ~300 KB, and the medium rendition
can exceed that, so it is not reused. Dimensions for og:image:width and
og:image:height are read back from the encoded file with DecodeConfig, so
they are emitted even when the rendition itself was skipped as up to date.
The og:image and og:url values are absolute via BaseURL; both crawlers
ignore relative image paths. Previews therefore only work against the
deployed base URL, not http://localhost.
Each timeline date heading now links to /day/<YYYY-MM-DD>/, a page
holding just that day's photos with a back link to the timeline.
renderDayPages writes one per groupByDay run; the ISO label doubles
as the URL slug.
The day-grouped timeline is now the site root (/, /page/N/); the flat
square grid moves to /grid/ (/grid/page/N/). Both are linked from the
profile header, separated by a divider.
Timeline pages pack whole day-groups until they reach the page size,
never splitting a day across a page boundary, so appended pages never
repeat a date heading — the infinite-scroll handler relies on this.
Page size is configurable via -page-size (default 60), threaded through
the Generator and exposed as services.photogallery.pageSize in the
NixOS module.
New /timeline/ page renders photos grouped into consecutive same-day
runs (ISO date heading plus per-day count), reusing the square grid.
groupByDay collapses the newest-first photo list; the page is not
paginated. Linked from the profile header.
Also corrects the serif font, which is Crimson Pro rather than Vollkorn,
and notes that .gitignore patterns here have to be anchored or they eat
the Kotlin package directory.