og:description and twitter:description now append the photo's tags after the
author and date (" · berlin, sunrise") when present, so a shared link unfurls
with its tags. Falls back to the plain author/date line when a photo is
untagged. Template-only change; tags are HTML-escaped by html/template.
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.
Both tag fields (post and edit) now suggest existing tags as you type,
matching the web upload/manage forms. Suggestions come from a new
GET /api/tags.json that serves the generated public/tags.json. It lives under
/api/ so nginx proxies it to the app, reaching non-browser clients even when a
front-door botwall gates the static site (the same approach as /api/thumb/).
/api/tags.json and /api/thumb/{slug} are both left unguarded: they serve the
same public data as the static site (/tags.json and /thumbs/), so there is
nothing to protect, and keeping them off the auth path means they can never
return 401 — otherwise an nginx auth-fail jail (fail2ban) would ban the app
over an autocomplete poll or a per-row thumbnail load.
A reusable TagField composable filters on the token after the last comma and
completes it on tap; fetchTags() returns an empty list on any failure, so the
field degrades to a plain tags input when the endpoint is absent.
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.
The server derives a photo's slug from the uploaded filename, so two photos with
the same name in one batch would overwrite each other. Each card now gets a slug
that is unique among the current cards (second same-named photo becomes -2, -3,
…), computed with a client mirror of the server's sanitiseSlug and sent as the
multipart filename. The slug is assigned once when a photo is added, so it stays
stable across retries.
Kept client-side on purpose: a server-side "slug exists -> suffix" would turn
the apps' idempotent re-upload retries (same filename overwrites the same post)
into duplicates.
The /upload page now takes several photos at once, mirroring the Android and Qt
apps: a card per photo with its own preview, EXIF-prefilled date, caption and
tag-autocompleting tags field, a remove button, and per-photo status. "Post
photos" uploads them sequentially with an x/N progress line; failures can be
retried by pressing Post again, successes link straight to the new photo page.
No server change — each photo is still a separate POST /upload, exactly as the
apps' per-photo queue does it, so slugs come from filenames as before.
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.
Bring the README in line with the gallery as it stands: add a Features section
(views and default-view, albums/tags, day pages, link previews, manage/trash,
tag autocomplete, app-side auth, the signed-in indicator, and the Android and
Qt desktop apps). Correct the auth story (bcrypt htpasswd in the app, nginx
only terminates TLS and proxies), move htpasswdFile to the top-level module
option, document defaultView/pageSize/tags, and add an Apps section.
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.
A small shared widget (static/tagcomplete.js) completes the tag after the last
comma from the existing tag list, keeping earlier tags intact. It is wired to
every comma-separated tag input: the upload form, and the manage page's per-row
edit field and bulk add/remove fields.
Suggestions come from /tags.json, which the generator already writes on every
build and nginx serves statically, so no server change is needed; the list is
fetched once, cached, and invalidated after an edit that may add a tag. Keyboard
navigation (up/down, enter/tab, escape) and click selection are supported.
The /manage page posts with FormData, i.e. multipart/form-data, but the edit,
delete, tags, restore and empty-trash handlers parsed with r.ParseForm, which
only reads urlencoded bodies. So every field came back empty: delete returned
200 with deleted:0 and moved nothing, edits saved nothing — while curl -d
(urlencoded) worked, which is why tests and manual checks missed it.
Parse with ParseMultipartForm (tolerating ErrNotMultipart so urlencoded still
works), via a shared parseForm helper used by all manage handlers. handleUpload
already did this. Also stop the UI reporting deleted:0 / updated:0 as success,
and add a multipart regression test.
The watcher only reacted to Create/Write/Remove. A /manage delete moves the
image and sidecar into content/.trash/, which on Linux arrives as a Rename
(inotify IN_MOVED_FROM), not a Remove — so the watcher never fired and the
delete left the published site unchanged (uploads worked because they Create
and Write). Watch Rename too, which also covers removing a photo with a manual
mv. The delete handler still triggers its own immediate rebuild; this makes the
watcher path agree with it.
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).
Each photo card gains a free-text tags input, sent as a raw
comma-separated `tags` form field on upload. The server normalizes it
(parseTags), so the clients stay dumb — no tag parsing on-device.
- Android: PickedItem.tags + setTags; threaded through UploadQueue →
UploadWorker → UploadClient's multipart.
- Desktop: a tags QLineEdit per PhotoItemWidget; UploadClient.upload sends
it.
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.
/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>
The chosen view is published at /, the other under /timeline/ or
/grid/. Page paths, pager links and the cross-links between the views
are derived from the setting. The directory named after the default
view is removed on build, since it can only be a leftover from before
a switch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The CMake target stays 'uploader' but the output binary is now 'photog',
matching the package and app, so 'nix run' and the desktop entry resolve
it (nix run looked for bin/photog and failed on bin/uploader). Also set
meta.mainProgram.
Wayland is Linux-only and refuses to evaluate on darwin, so qtwayland is
now added only on Linux (macOS uses the Cocoa QPA plugin in qtbase). The
package, dev shell, and plugin search path all build from one platform-
guarded module list. Evaluates on aarch64-darwin; Linux is unchanged.
- Web: favicon.ico + png icons under static/, served from the site root
and linked from every page head.
- Android: the artwork becomes the launcher icon — the 432px adaptive
foreground over the gruvbox background, replacing the placeholder vector
drawables.
- Desktop: the icon is the Qt window/taskbar icon (embedded via app.qrc),
and the app is now an installable package — a wrapped binary, a
photog.desktop entry, and hicolor icons — so `nix profile install
./desktop` puts "Photog" in the application menu.
Mirror the Android bulk feature in the Qt uploader: choose several photos
at once, each a card with its own caption, date, and upload status, and
post the batch.
- Per-photo cards (PhotoItemWidget) in a scrollable list; retry re-posts
only the failures.
- One upload per photo with a per-item UUID slug suffix so a batch posted
in the same second cannot collide.
- Image prep runs off the UI thread via QtConcurrent so the window stays
responsive.
- A "Simultaneous uploads" setting (1–8, default 3) caps how many photos
are prepared/uploaded at once; the rest queue and fill freed slots.
Pick or share several photos at once and post them in one batch, still
against the existing single-image /upload endpoint — no server change.
- Unify the post screen on a list model (PickedItem): a single-photo post
or share is just a batch of one, so there is one code path.
- Multi-select via PickMultipleVisualMedia and multi-share via
ACTION_SEND_MULTIPLE (manifest filter added).
- Per-photo caption and EXIF-prefilled date; each card shows its own
upload status, with a summary and retry-only-failures on re-post.
- Enqueue one WorkManager worker per photo, reusing the durable retry
queue. UploadQueue now appends a per-item suffix to the slug so a burst
enqueued in the same second cannot collide and be dropped by
enqueueUniqueWork.
A native Qt6/C++ desktop client for the gallery's POST /upload API, in
desktop/ with its own flake (kept separate from the Go server module the
same way android/ is). Single-photo parity with the Android app:
- multipart upload with preemptive HTTP Basic auth and retry-aware outcome
classification (uploadclient)
- JPEG/PNG metadata stripped byte-for-byte to drop embedded GPS, EXIF
orientation baked in, and a quality/scale valve for the 20M nginx cap;
HEIC decodes via the qtimageformats plugin (imageprep)
- base URL / username in QSettings, password in the system keychain via
qtkeychain; https required and a trailing /upload stripped (settings)
- settings and post screens in a QStackedWidget (mainwindow)
Native Wayland or xcb is selected at runtime. Bulk upload, EXIF
capture-date prefill, and moving image prep off the UI thread are left as
follow-ups.
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.
Swipe left for older, right for newer, matching the existing arrow keys.
The image tracks the finger and slides out on commit; the exit animation
covers the page load, so this stays plain static-site navigation.
The keyboard handler moves out of the template into static/photo-nav.js,
which the existing //go:embed of static/ picks up with no generator
changes.
Swipes starting within 24px of a screen edge are left alone — that strip
is the browser's own back-gesture. Only touch pointers are handled, so
desktop drag-to-save still works, and the axis locks after 10px so
vertical drags stay scrolls. With no neighbour that way the image
rubber-bands instead of navigating.
Also prefetch the neighbour medium JPEGs, not just their HTML, or the
next page still blocks on a fresh image request.
The keyboard handler now bails on modifier keys; it previously hijacked
Alt+ArrowLeft, which is browser-back on Linux and Windows.
Native single-photo uploader for POST /upload. Lives in android/ with its
own flake, so the root flake stays Go-only and NixOS consumers of
nixosModules.default do not pull the Android SDK into their lock.
The server is untouched, so the app closes every gap client side:
- The slug is the enqueue-time timestamp, persisted in the work input.
server.go derives the stored slug from the uploaded filename, so reusing
that filename across WorkManager retries overwrites the same post rather
than creating a duplicate. That stands in for an idempotency key.
- EXIF is stripped before upload. The gallery publishes the uploaded file
verbatim as its full-size download, and phone photos carry GPS. JPEG and
PNG drop metadata at marker and chunk level with the compressed pixels
untouched; HEIC is decoded to JPEG because the extension check rejects it.
- Photos flagged for rotation are rotated into the pixels instead of relying
on the orientation tag, which cannot survive the strip and which the
thumbnailer ignores regardless.
- Anything over 18 MiB steps quality, then resolution, to stay inside
nginx's client_max_body_size 20M.
- Credentials are sealed with an AES-GCM key in the Android Keystore. The
key deliberately does not require user authentication, or background
retries could not read it. Basic auth is attached by an interceptor
rather than an Authenticator, which reacts to a 401 by replaying a
multipart body that is not reliably replayable.
- The connection test uses GET /upload. /health is unreachable from outside
because the nginx vhost only proxies location /upload.
WorkManager's SystemForegroundService needs foregroundServiceType declared
on the service entry, not just as a permission, or setForeground throws on
Android 14+. Only lintVitalRelease catches that, never a debug build.
Also anchors the .gitignore patterns. Unanchored, "photogallery" matched the
Kotlin package directory ws/inflo/photogallery/ and silently swallowed every
source file on git add.
Move the Newer and Older links to a right-aligned group joined by a
slash so they stop crowding the Back link, and drop the aqua accent so
every link in the row shares the muted serif tone.
Replace the standalone Back link with a three-cell nav row so each
photo page links to its neighbours in the timeline. Prefetch the
neighbouring pages and bind the arrow keys for keyboard navigation.
Emit /page/N/ pages alongside index.html and render a pager at the
bottom of each page. Ships as a plain link that navigates without JS,
and an IntersectionObserver enhancement that fetches the next page and
appends tiles for an infinite-scroll feel.
The profile header, photo page byline and copyright footer previously
hardcoded the author name, handle, bio, and font choices. Move all of
these to CLI flags on the binary and matching options on the NixOS
module so the same build can serve any single-user gallery.
New surface, all with defaults matching the previous hardcoded values:
--author / services.photogallery.author
--handle / services.photogallery.handle
--bio / services.photogallery.bio
--bio-alt / services.photogallery.bioAlt ("" to omit)
--fonts-url / services.photogallery.fontsURL
--serif-family / services.photogallery.serifFamily
--ml-family / services.photogallery.mlFamily
Templates read the two font families from a small inline <style> that
overrides the :root variables, so the stylesheet stays fully static and
the font URL is loaded via the configured stylesheet link.
README updated to describe the new options, the medium/full-size photo
detail flow, and the EXIF-based date prefill on the upload page.
Once a file was picked the preview <img> overlaid the file input and
blocked further clicks, so the drop zone became read-only. Making the
preview and the new 'Click to change' hint pointer-events:none lets
clicks pass through to the input again.
Also parses the JPEG APP1 EXIF segment inline (no dependency) to read
DateTimeOriginal and prefill the date field. Falls back to the file's
lastModified for PNGs or JPEGs without EXIF. The date field stays
editable in either case.
Vollkorn read as too chunky next to the Malayalam bio in Manjari.
Crimson Pro is a calmer book-face serif that pairs more evenly.
Renames the CSS variable from --vk to --serif to reflect that the
value is no longer Vollkorn-specific, and bumps .ml to 17px/1.5
so the Malayalam subtitle sits at a comfortable reading size.