Compare commits

...
21 Commits
Author SHA1 Message Date
puttaalu 43075f00d4 Merge pull request 'Add /manage (edit, delete) and -default-view' (#1) from manage-endpoint into trunk-ng
Reviewed-on: #1
2026-10-03 11:15:40 +00:00
foxandClaude Fable 5.1 3f719359de feat(manage): add /manage pages to edit and delete photos, behind -manage
/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>
2026-09-30 08:17:17 +02:00
foxandClaude Fable 5.1 f1abd45f24 feat(generator): add -default-view to choose timeline or grid as site root
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>
2026-09-30 08:16:50 +02:00
puttaalu f33bdd55b1 Relicense to the public domain (Unlicense)
Release the project into the public domain instead of MIT.
2026-09-26 14:06:20 +02:00
puttaalu 91fbf4f93b Name the desktop binary photog
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.
2026-09-26 10:51:39 +02:00
puttaalu 17222f7120 Make the desktop flake build on macOS
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.
2026-09-26 10:44:23 +02:00
puttaalu 591eaed6a3 Add app icons across web, Android, and the desktop app
- 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.
2026-09-26 10:33:22 +02:00
puttaalu a7783671f2 Add bulk multi-photo upload to the desktop app
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.
2026-09-26 10:17:18 +02:00
puttaalu 1d81ec4511 Add a source-code link to the gallery header
Next to the RSS feed link on the timeline and grid headers, pointing at
the public repo so visitors can find the source.
2026-09-26 10:17:18 +02:00
puttaalu dc47619937 Rename the Android app to Photog 2026-09-26 10:17:18 +02:00
puttaalu c404aac66c add icons 2026-09-26 10:16:17 +02:00
puttaalu 23e14f5aa8 Merge desktop-qt-uploader: Qt6 desktop uploader 2026-08-27 08:44:35 +02:00
puttaalu 6af0eb115c Add multi-photo bulk upload to the Android app
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.
2026-08-27 08:43:24 +02:00
puttaalu d07e3eb0dc Add Qt6 desktop uploader
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.
2026-08-26 00:28:35 +02:00
puttaalu d4fec5b0ff Add Open Graph link previews to photo pages
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.
2026-08-24 13:59:49 +02:00
puttaalu eb6dba6eb0 Add single-day pages linked from timeline date headings
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.
2026-08-20 00:13:49 +02:00
puttaalu 4ddf5adf24 Make the timeline the default view, paginated and configurable
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.
2026-08-20 00:11:08 +02:00
puttaalu 9b6fcb482b Add a timeline page grouping photos by day
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.
2026-08-19 23:46:28 +02:00
puttaalu c169c030d6 Update CLAUDE.md for pagination, medium images, swipe nav and the Android app
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.
2026-08-19 23:36:14 +02:00
puttaalu 15822ed227 Add swipe navigation to the photo detail page
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.
2026-08-19 23:24:42 +02:00
puttaalu 210ce69e18 Add an Android uploader app
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.
2026-08-13 22:01:20 +02:00
111 changed files with 6135 additions and 89 deletions
+19 -5
View File
@@ -1,8 +1,22 @@
content/
public/
photogallery
result
result-*
/content/
/public/
/.demo/
# Anchored: unanchored these would also match the Kotlin package directory
# android/app/src/main/java/ws/inflo/photogallery/ and any nested "result".
/photogallery
/result
/result-*
*.tar.gz
.envrc
.direnv/
# Android app
android/.gradle/
android/build/
android/app/build/
android/local.properties
android/.idea/
android/.kotlin/
android/keystore.properties
*.jks
*.keystore
+91 -10
View File
@@ -2,6 +2,7 @@
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
@@ -9,7 +10,8 @@ No database, no framework — just a Go binary that watches a directory and rege
content/ # images + .toml sidecars (source of truth)
public/ # generated output (served by nginx / --serve flag)
templates/ # embedded HTML templates
static/ # embedded CSS
static/ # embedded CSS + JS
android/ # Kotlin/Compose uploader app, own flake
```
The binary does three things at once:
@@ -17,13 +19,25 @@ The binary does three things at once:
- Serves a multipart upload API at `POST /upload`
- In dev mode (`--serve`), also serves `public/` as static files
In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, htpasswd on the upload endpoint.
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 `.toml` sidecar. Any key other than `caption` and `date` in 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.
- `sameOrigin` compares the `Origin` header against `-base-url`, because behind nginx the `Host` header is the upstream address. A wrong `-base-url` therefore breaks saving in browsers that send no `Sec-Fetch-Site`.
- Delete is permanent and takes two steps: `GET /manage/delete/{slug}` only shows a confirmation page, the `POST` does the work. `Generator.Delete` removes the image and sidecar from `content/` first, then the four renditions and `public/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: `renderDayPages` drops `public/day/<date>/` for days without photos, and `prunePages` drops pager pages past the last one. Renditions of photos removed from `content/` by hand (not via `/manage`) are still **not** pruned.
## Stack
- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`
- **Templates** — `html/template`, embedded via `embed.FS`
- **Images** — centre-crop to square, Catmull-Rom resize to 600×600 JPEG thumbnails
- **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`
@@ -34,15 +48,53 @@ caption = "Tempelhof at sunrise"
date = "2026-06-14"
```
Missing sidecar → filename as caption, today as date.
Missing sidecar → filename as caption, today as date. Photos sort newest first by that date.
## Design
- Gruvbox dark hard colour scheme
- Vollkorn (serif) for headings and captions
- Manjari for Malayalam text (bio line)
- 3-column square grid → 2-column on mobile
- Single photo page with sidebar
- 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`):
- **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. `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** (`/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. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug.
- Which view owns the site root is set by `-default-view timeline|grid` (NixOS `services.photogallery.defaultView`). The other view moves under `/<name>/`: with `-default-view grid` the grid is `/` and the timeline is `/timeline/`. `viewBase` and `pageURL` in `generator.go` derive every page path and pager link from that, and the templates take the cross-links as `GridURL` / `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-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
@@ -54,6 +106,20 @@ go run . --serve
# 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:
```bash
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
```nix
@@ -86,16 +152,31 @@ nix build # succeeds
| File | Purpose |
|---|---|
| `main.go` | Flags, wires generator + watcher + server |
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs |
| `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
- `.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)
- Multi-image upload
- Delete/edit via UI
- Pruning renditions and photo pages of photos deleted from `content/` by hand (delete via `/manage` cleans 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
+20 -17
View File
@@ -1,21 +1,24 @@
MIT License
This is free and unencumbered software released into the public domain.
Copyright (c) 2026 Ashik S
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For more information, please refer to <https://unlicense.org>
+24 -1
View File
@@ -69,6 +69,9 @@ services.photogallery = {
serifFamily = "EB Garamond";
mlFamily = "Manjari";
# Optional: edit and delete published photos at /manage (off by default)
manage.enable = true;
# Optional: built-in nginx vhost
nginx = {
enable = true;
@@ -101,9 +104,12 @@ nix-shell -p apacheHttpd --run \
| `fontsURL` | Crimson Pro + Manjari | Stylesheet URL loading both web fonts |
| `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif |
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio |
| `pageSize` | `60` | Photos per page on the timeline and grid views |
| `defaultView` | `timeline` | View at the site root, `timeline` or `grid`; the other is served under `/timeline/` or `/grid/` |
| `manage.enable` | `false` | Serve the `/manage` pages (edit, delete) and, with `nginx.enable`, protect them with the same htpasswd |
| `nginx.enable` | `false` | Configure an nginx vhost |
| `nginx.domain` | — | Domain for the vhost |
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` |
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` and `/manage` |
| `nginx.enableACME` | `true` | Enable Let's Encrypt |
---
@@ -138,3 +144,20 @@ curl -u user:password \
```
Or open `https://photos.yourdomain.tld/upload` in a browser.
## Editing and deleting photos
The pages are off by default: start the binary with `-manage` (NixOS:
`services.photogallery.manage.enable = true`). Then open
`https://photos.yourdomain.tld/manage` in a browser. It lists every
photo; *Edit* opens a form for the caption and date, and saving rewrites the
photo's `.toml` sidecar, which triggers a rebuild.
*Delete* asks for confirmation, then removes the image, its sidecar and
everything generated from them. This is permanent — there is no trash folder,
so keep a backup of the content directory.
`/manage` sits behind the same htpasswd as `/upload`, and nothing on the
public site links to it. If you run your own nginx instead of the NixOS
module, add a `location /manage` block alongside the `/upload` one — without
it the path is not proxied and returns 404.
+67
View File
@@ -0,0 +1,67 @@
# Gallery Upload — Android client
Native uploader for the photogallery `POST /upload` endpoint. Pick or share a
photo, add a caption and date, post. Credentials are entered once and sealed
with a key held in the Android Keystore.
The server is not modified by this app. Everything it needs, it does client
side.
## Build
```bash
cd android
nix develop # Android SDK 36, build-tools 36.0.0, JDK 17, gradle
gradle wrapper # first time only, generates ./gradlew
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
The dev shell exports `ANDROID_HOME`, `JAVA_HOME` and a `GRADLE_OPTS` that
points AGP at the nix-provided `aapt2`. Without that last one AGP downloads a
generic-linux `aapt2` from Maven which will not run on NixOS.
## Release signing
`app/build.gradle.kts` wires up a release signing config only when
`android/keystore.properties` exists. Both that file and `*.jks` are
gitignored — keep the keystore outside the repo and back it up somewhere you
will still have in five years, because losing it means you can never upgrade an
installed build in place.
```bash
mkdir -p ~/keys
keytool -genkeypair -v \
-keystore ~/keys/photogallery-release.jks \
-alias photogallery \
-keyalg RSA -keysize 4096 -validity 10000
```
Then write `android/keystore.properties`:
```properties
storeFile=/home/you/keys/photogallery-release.jks
storePassword=…
keyAlias=photogallery
keyPassword=…
```
```bash
./gradlew assembleRelease
```
Without `keystore.properties` the release build still runs, it just comes out
unsigned.
## How it maps onto the server
| Server behaviour | What the app does about it |
|---|---|
| Slug derived from the uploaded filename | Sends `yyyy-MM-dd-HHmmss.jpg`, generated once at enqueue |
| Same filename overwrites the same post | Reused across retries, which makes retries idempotent |
| Uploaded file published verbatim as "full size" | Strips EXIF, including GPS, before upload |
| Extension check rejects HEIC | Converts HEIC to JPEG; JPEG and PNG pass through losslessly |
| nginx `client_max_body_size 20M` | Steps quality, then resolution, if the result exceeds 18 MiB |
| Thumbnailer ignores EXIF orientation | Bakes rotation into the pixels rather than relying on the tag |
| nginx only proxies `/upload`, so `/health` 404s | Tests credentials with `GET /upload` instead |
| Replies `uploaded <slug>.jpg` in plain text | Parses that into `<base>/photo/<slug>/` for the "Open" link |
+87
View File
@@ -0,0 +1,87 @@
import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
// Release signing is wired up only when android/keystore.properties exists.
// That file holds the keystore passwords and is gitignored, as is the .jks
// itself — keep both outside the repo. See android/README.md for the
// keytool invocation that creates them.
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) keystorePropsFile.inputStream().use { load(it) }
}
android {
namespace = "ws.inflo.photogallery"
compileSdk = 36
defaultConfig {
applicationId = "ws.inflo.photogallery"
minSdk = 28 // ImageDecoder + HEIF decode both land here
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
signingConfigs {
if (keystorePropsFile.exists()) {
create("release") {
storeFile = rootProject.file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Falls back to unsigned when keystore.properties is absent, so a
// fresh clone still builds.
signingConfig = signingConfigs.findByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.exifinterface)
implementation(libs.okhttp)
debugImplementation(libs.androidx.ui.tooling)
}
+6
View File
@@ -0,0 +1,6 @@
# OkHttp ships Animal Sniffer and Conscrypt references that are absent at
# runtime on Android. Silence them rather than pulling in the dependencies.
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- WorkManager expedited uploads run in a foreground service on API 31+. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:largeHeap="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.PhotoGalleryUploader"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask"
android:theme="@style/Theme.PhotoGalleryUploader">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Share sheet target: pick a photo in Google Photos, share here. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<!-- WorkManager runs expedited work in its own foreground service. On
Android 14+ the type has to be declared on the service entry, not
just requested as a permission, or setForeground() throws. -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>
@@ -0,0 +1,137 @@
package ws.inflo.photogallery
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import ws.inflo.photogallery.ui.PhotoGalleryTheme
import ws.inflo.photogallery.ui.PostScreen
import ws.inflo.photogallery.ui.SettingsScreen
import ws.inflo.photogallery.ui.UploadViewModel
class MainActivity : ComponentActivity() {
private val viewModel: UploadViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
consumeShareIntent(intent)
setContent {
PhotoGalleryTheme {
// Land on settings until there is somewhere to post to.
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) }
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(),
) { uris -> if (uris.isNotEmpty()) viewModel.selectImages(uris) }
val notificationPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* Upload works either way; this only affects the progress notification. */ }
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
this@MainActivity,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
if (showSettings) {
SettingsScreen(
state = viewModel.config,
onBaseUrlChange = viewModel::setBaseUrl,
onUsernameChange = viewModel::setUsername,
onPasswordChange = viewModel::setPassword,
onSave = viewModel::saveAndTest,
onBack = if (viewModel.isConfigured) {
{ showSettings = false }
} else {
null
},
)
} else {
PostScreen(
state = viewModel.post,
onPickImages = {
picker.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly,
),
)
},
onCaptionChange = viewModel::setCaption,
onDateChange = viewModel::setDate,
onRemove = viewModel::removeItem,
onSubmit = viewModel::submit,
onReset = viewModel::clear,
onOpenSettings = { showSettings = true },
onOpenUrl = ::openUrl,
)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
consumeShareIntent(intent)
}
/** Picks up one or several photos handed over by the share sheet. */
private fun consumeShareIntent(intent: Intent?) {
val uris: List<Uri> = when (intent?.action) {
Intent.ACTION_SEND -> {
val uri: Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
listOfNotNull(uri)
}
Intent.ACTION_SEND_MULTIPLE -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
}.orEmpty()
}
else -> return
}
if (uris.isNotEmpty()) viewModel.selectImages(uris)
}
private fun openUrl(url: String) {
if (url.isBlank()) return
try {
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
} catch (_: Exception) {
Toast.makeText(this, "No browser to open $url", Toast.LENGTH_SHORT).show()
}
}
}
@@ -0,0 +1,171 @@
package ws.inflo.photogallery.data
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Settings and credential persistence.
*
* The password is the only secret here and it never hits disk in the clear:
* it is sealed with an AES-GCM key held in the Android Keystore, so the bytes
* sitting in SharedPreferences are useless without the hardware-backed key.
*
* The key deliberately does not require user authentication. WorkManager
* retries an upload with nobody looking at the screen, and a key gated on a
* fingerprint would throw there.
*
* androidx.security-crypto (EncryptedSharedPreferences) covers this ground but
* is deprecated, so the Keystore calls are made directly.
*/
class SettingsStore(context: Context) {
private val prefs = context.applicationContext
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
var baseUrl: String
get() = prefs.getString(KEY_BASE_URL, "").orEmpty()
set(value) = prefs.edit().putString(KEY_BASE_URL, value.trim().trimEnd('/')).apply()
var username: String
get() = prefs.getString(KEY_USERNAME, "").orEmpty()
set(value) = prefs.edit().putString(KEY_USERNAME, value.trim()).apply()
/** Empty string when unset, or when the Keystore key no longer decrypts it. */
var password: String
get() = prefs.getString(KEY_PASSWORD, null)?.let(::decrypt).orEmpty()
set(value) = prefs.edit().putString(KEY_PASSWORD, encrypt(value)).apply()
val isConfigured: Boolean
get() = baseUrl.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty()
// ── Last successful post, shown on the post screen ──────────────────────
var lastPost: LastPost?
get() {
val slug = prefs.getString(KEY_LAST_SLUG, null) ?: return null
return LastPost(
slug = slug,
url = prefs.getString(KEY_LAST_URL, "").orEmpty(),
caption = prefs.getString(KEY_LAST_CAPTION, "").orEmpty(),
postedAtMillis = prefs.getLong(KEY_LAST_AT, 0L),
)
}
set(value) {
prefs.edit().apply {
if (value == null) {
remove(KEY_LAST_SLUG); remove(KEY_LAST_URL)
remove(KEY_LAST_CAPTION); remove(KEY_LAST_AT)
} else {
putString(KEY_LAST_SLUG, value.slug)
putString(KEY_LAST_URL, value.url)
putString(KEY_LAST_CAPTION, value.caption)
putLong(KEY_LAST_AT, value.postedAtMillis)
}
}.apply()
}
// ── Keystore sealing ────────────────────────────────────────────────────
private fun secretKey(): SecretKey {
val keystore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(keystore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)
?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE,
)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setUserAuthenticationRequired(false)
.build(),
)
return generator.generateKey()
}
private fun encrypt(plain: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
val ciphertext = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) +
SEPARATOR +
Base64.encodeToString(ciphertext, Base64.NO_WRAP)
}
/**
* Returns "" rather than throwing when the sealed blob cannot be opened.
* That happens legitimately — a Keystore key can be invalidated by a device
* restore or a lock-screen change — and the right response is to prompt for
* the password again, not to crash on launch.
*/
private fun decrypt(stored: String): String = try {
val parts = stored.split(SEPARATOR, limit = 2)
if (parts.size != 2) {
""
} else {
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}
} catch (_: Exception) {
""
}
companion object {
private const val PREFS = "photogallery_settings"
private const val KEY_BASE_URL = "base_url"
private const val KEY_USERNAME = "username"
private const val KEY_PASSWORD = "password_sealed"
private const val KEY_LAST_SLUG = "last_slug"
private const val KEY_LAST_URL = "last_url"
private const val KEY_LAST_CAPTION = "last_caption"
private const val KEY_LAST_AT = "last_at"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "photogallery_credentials"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_BITS = 128
private const val SEPARATOR = ":"
/**
* Returns an error message, or null when the URL is usable.
*
* https is required, not preferred. Basic auth puts the password in a
* trivially reversible header, so a plain-http base URL would hand the
* gallery password to anything on the path. The gallery's nginx vhost
* sets forceSSL anyway.
*/
fun validateBaseUrl(raw: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return "Base URL is required"
val url = trimmed.toHttpUrlOrNull() ?: return "Not a valid URL"
if (url.scheme != "https") {
return "Must be https — plain http sends your password in the clear"
}
return null
}
}
}
data class LastPost(
val slug: String,
val url: String,
val caption: String,
val postedAtMillis: Long,
)
@@ -0,0 +1,308 @@
package ws.inflo.photogallery.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.graphics.Matrix
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import java.io.ByteArrayOutputStream
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
/**
* Turns a picked or shared image into bytes the gallery's upload API accepts.
*
* Three things have to be true of the result: no EXIF (the gallery publishes
* the uploaded file verbatim as its "full size" download, so embedded GPS
* would be public), a .jpg or .png extension (server.go checks the extension
* and rejects anything else, HEIC included), and under nginx's 20M body cap.
*
* Where possible this is done without re-encoding, so the published file keeps
* the original sensor pixels exactly.
*/
object ImagePrep {
/** nginx is configured with client_max_body_size 20M; leave headroom. */
private const val MAX_UPLOAD_BYTES = 18L * 1024 * 1024
/**
* Cap on decoded pixels for the paths that must decode. A bitmap is 4 bytes
* per pixel, so 40MP is a ~160MB allocation — survivable with largeHeap,
* which is why the manifest asks for it. Beyond that we sample down rather
* than die.
*/
private const val MAX_DECODE_PIXELS = 40_000_000L
private const val JPEG_QUALITY = 92
/** Quality ladder walked when the encoded result overshoots the cap. */
private val QUALITY_LADDER = intArrayOf(92, 85, 75)
data class Prepared(
val file: File,
val extension: String,
/** Non-null when the app had to depart from a byte-exact copy. */
val note: String?,
)
/**
* EXIF capture date, for prefilling the date field. Read before any
* stripping happens — the same metadata block carries both this and the
* GPS tags being removed.
*/
fun captureDate(context: Context, uri: Uri): LocalDate? = try {
context.contentResolver.openInputStream(uri)?.use { stream ->
val exif = ExifInterface(stream)
val raw = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
?: exif.getAttribute(ExifInterface.TAG_DATETIME)
raw?.let { parseExifDate(it) }
}
} catch (_: Exception) {
null
}
private fun parseExifDate(raw: String): LocalDate? = try {
LocalDate.parse(raw.trim().substringBefore(' '), EXIF_DATE)
} catch (_: DateTimeParseException) {
null
}
private val EXIF_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy:MM:dd")
/**
* Produces the file to upload, written into [cacheDir].
*
* JPEG and PNG take a lossless route: whole metadata segments are dropped
* without touching the compressed image data. Anything else — HEIC being
* the common case — has to be decoded and re-encoded as JPEG.
*/
fun prepare(context: Context, uri: Uri, cacheDir: File, baseName: String): Prepared {
val source = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Could not read the selected image")
val orientation = readOrientation(context, uri)
val notes = mutableListOf<String>()
// A rotation flag cannot survive metadata stripping, and the gallery's
// thumbnailer ignores EXIF orientation regardless. Bake the rotation
// into the pixels instead, which costs a re-encode.
val mustRotate = orientation != ExifInterface.ORIENTATION_NORMAL &&
orientation != ExifInterface.ORIENTATION_UNDEFINED
val lossless: Pair<ByteArray, String>? = when {
mustRotate -> null
isJpeg(source) -> stripJpegSegments(source)?.let { it to "jpg" }
isPng(source) -> stripPngChunks(source)?.let { it to "png" }
else -> null
}
var bytes: ByteArray
var extension: String
if (lossless != null) {
bytes = lossless.first
extension = lossless.second
} else {
if (mustRotate) notes += "rotated to match its EXIF orientation"
else if (!isJpeg(source) && !isPng(source)) notes += "converted to JPEG"
bytes = encodeJpeg(decodeOriented(context, uri, orientation), JPEG_QUALITY)
extension = "jpg"
}
// Size valve. Only trips on genuinely huge originals; stepping quality
// is tried before touching resolution.
if (bytes.size > MAX_UPLOAD_BYTES) {
val bitmap = decodeOriented(context, uri, orientation)
var shrunk: ByteArray? = null
for (quality in QUALITY_LADDER) {
val attempt = encodeJpeg(bitmap, quality)
if (attempt.size <= MAX_UPLOAD_BYTES) {
shrunk = attempt
notes += "re-encoded at quality $quality to fit the 20M upload limit"
break
}
}
if (shrunk == null) {
var scaled = bitmap
var factor = 1.0
while (factor > 0.2) {
factor *= 0.75
scaled = Bitmap.createScaledBitmap(
bitmap,
(bitmap.width * factor).toInt().coerceAtLeast(1),
(bitmap.height * factor).toInt().coerceAtLeast(1),
true,
)
val attempt = encodeJpeg(scaled, QUALITY_LADDER.last())
if (attempt.size <= MAX_UPLOAD_BYTES) {
shrunk = attempt
notes += "scaled to ${scaled.width}×${scaled.height} to fit the 20M upload limit"
break
}
}
}
bytes = shrunk ?: error("Image is too large to upload even after downscaling")
extension = "jpg"
}
val file = File(cacheDir, "$baseName.$extension")
file.writeBytes(bytes)
return Prepared(
file = file,
extension = extension,
note = notes.takeIf { it.isNotEmpty() }?.joinToString("; "),
)
}
/**
* Small, correctly-rotated bitmap for the on-screen preview. Decoded at a
* sample size rather than full resolution — the preview is a thumbnail and
* has no business allocating a 40MP bitmap.
*/
fun preview(context: Context, uri: Uri, maxDimension: Int = 1024): Bitmap? = try {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
val longest = maxOf(info.size.width, info.size.height)
var sample = 1
while (longest / (sample * 2) >= maxDimension) sample *= 2
if (sample > 1) decoder.setTargetSampleSize(sample)
}
applyOrientation(bitmap, readOrientation(context, uri))
} catch (_: Exception) {
null
}
private fun readOrientation(context: Context, uri: Uri): Int = try {
context.contentResolver.openInputStream(uri)?.use { stream ->
ExifInterface(stream).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED,
)
} ?: ExifInterface.ORIENTATION_UNDEFINED
} catch (_: Exception) {
ExifInterface.ORIENTATION_UNDEFINED
}
private fun decodeOriented(context: Context, uri: Uri, orientation: Int): Bitmap {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.isMutableRequired = false
val pixels = info.size.width.toLong() * info.size.height.toLong()
if (pixels > MAX_DECODE_PIXELS) {
var sample = 2
while (pixels / (sample.toLong() * sample) > MAX_DECODE_PIXELS) sample *= 2
decoder.setTargetSampleSize(sample)
}
}
return applyOrientation(bitmap, orientation)
}
private fun applyOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> { matrix.postRotate(90f); matrix.postScale(-1f, 1f) }
ExifInterface.ORIENTATION_TRANSVERSE -> { matrix.postRotate(270f); matrix.postScale(-1f, 1f) }
else -> return bitmap
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
private fun encodeJpeg(bitmap: Bitmap, quality: Int): ByteArray =
ByteArrayOutputStream(bitmap.width * bitmap.height / 4).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)
out.toByteArray()
}
private fun isJpeg(b: ByteArray) =
b.size > 3 && b.u8(0) == 0xFF && b.u8(1) == 0xD8 && b.u8(2) == 0xFF
private fun isPng(b: ByteArray) =
b.size > 8 && b.u8(0) == 0x89 && b.u8(1) == 0x50 && b.u8(2) == 0x4E && b.u8(3) == 0x47
private fun ByteArray.u8(i: Int) = this[i].toInt() and 0xFF
/**
* Drops APP1 (EXIF and XMP) and COM segments, keeping the compressed scan
* data byte-for-byte. APP0/JFIF and APP2/ICC stay: removing the colour
* profile would shift the rendered colours.
*
* Returns null if the marker structure does not parse, in which case the
* caller falls back to a decode-and-re-encode that is guaranteed to strip.
*/
private fun stripJpegSegments(src: ByteArray): ByteArray? {
val out = ByteArrayOutputStream(src.size)
out.write(0xFF); out.write(0xD8)
var i = 2
while (i + 1 < src.size) {
if (src.u8(i) != 0xFF) return null
val marker = src.u8(i + 1)
// Standalone markers: no length field, no payload.
if (marker == 0x01 || marker in 0xD0..0xD7) {
out.write(0xFF); out.write(marker)
i += 2
continue
}
// Start of scan: entropy-coded data runs to the end of the file.
if (marker == 0xDA) {
out.write(src, i, src.size - i)
return out.toByteArray()
}
if (i + 3 >= src.size) return null
val length = (src.u8(i + 2) shl 8) or src.u8(i + 3)
if (length < 2 || i + 2 + length > src.size) return null
val isExifOrXmp = marker == 0xE1
val isComment = marker == 0xFE
if (!isExifOrXmp && !isComment) out.write(src, i, 2 + length)
i += 2 + length
}
return null
}
/**
* Drops the metadata chunks and keeps everything else, including the
* critical IHDR/PLTE/IDAT/IEND run and colour chunks like iCCP and sRGB.
* Whole chunks go, so no CRC needs recomputing.
*/
private fun stripPngChunks(src: ByteArray): ByteArray? {
val drop = setOf("eXIf", "tEXt", "zTXt", "iTXt", "tIME")
val out = ByteArrayOutputStream(src.size)
out.write(src, 0, 8)
var i = 8
while (i + 8 <= src.size) {
val length = ((src.u8(i).toLong() shl 24) or (src.u8(i + 1).toLong() shl 16) or
(src.u8(i + 2).toLong() shl 8) or src.u8(i + 3).toLong()).toInt()
if (length < 0) return null
val type = String(src, i + 4, 4, Charsets.US_ASCII)
val total = 12 + length
if (i + total > src.size) return null
if (type !in drop) out.write(src, i, total)
i += total
if (type == "IEND") return out.toByteArray()
}
return null
}
}
@@ -0,0 +1,140 @@
package ws.inflo.photogallery.net
import okhttp3.Credentials
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed interface UploadOutcome {
data class Success(val slug: String, val url: String) : UploadOutcome
/** Wrong credentials. Retrying will not help. */
data object AuthFailed : UploadOutcome
/** Server said no for a reason a retry will not change. */
data class Rejected(val code: Int, val message: String) : UploadOutcome
/** Network trouble or a 5xx. Worth retrying. */
data class Transient(val message: String) : UploadOutcome
}
/**
* Talks to the gallery's POST /upload endpoint.
*
* Behind nginx that endpoint sits under HTTP Basic auth. The credentials go
* out on every request via an interceptor rather than through OkHttp's
* [okhttp3.Authenticator], on purpose: an Authenticator only reacts to a 401
* and then replays the request, and a multipart body streamed off disk is not
* reliably replayable — the retry can arrive with an empty body.
*/
class UploadClient(
baseUrl: String,
username: String,
password: String,
) {
private val base = baseUrl.trimEnd('/')
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.MINUTES) // a full-resolution photo on mobile data
.readTimeout(60, TimeUnit.SECONDS)
.addInterceptor(BasicAuthInterceptor(username, password))
.build()
/**
* Uploads [file] as the given [fileName].
*
* The filename matters more than it looks: server.go derives the photo's
* slug from it, so passing the same name on a retry overwrites the same
* post instead of creating a duplicate.
*/
fun upload(
file: File,
fileName: String,
caption: String,
date: String,
): UploadOutcome {
val mediaType = if (fileName.endsWith(".png", ignoreCase = true)) {
"image/png".toMediaType()
} else {
"image/jpeg".toMediaType()
}
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("image", fileName, file.asRequestBody(mediaType))
.addFormDataPart("caption", caption)
.addFormDataPart("date", date)
.build()
val request = Request.Builder()
.url("$base/upload")
.post(body)
.build()
return try {
client.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
when {
response.isSuccessful -> {
val slug = parseSlug(text, fileName.substringBeforeLast('.'))
UploadOutcome.Success(slug = slug, url = "$base/photo/$slug/")
}
response.code == 401 || response.code == 403 -> UploadOutcome.AuthFailed
response.code in 500..599 ->
UploadOutcome.Transient("Server error ${response.code}")
else ->
UploadOutcome.Rejected(response.code, text.trim().ifEmpty { "Upload rejected" })
}
}
} catch (e: IOException) {
UploadOutcome.Transient(e.message ?: "Network error")
}
}
/**
* Credential check. /health is not usable for this — the nginx vhost only
* proxies location /upload, so /health falls through to the static root and
* 404s. GET /upload returns the upload page through the same Basic-auth
* gate the POST uses, which is exactly what needs testing.
*/
fun testConnection(): String? {
val request = Request.Builder().url("$base/upload").get().build()
return try {
client.newCall(request).execute().use { response ->
when {
response.isSuccessful -> null
response.code == 401 || response.code == 403 -> "Wrong username or password"
else -> "Server returned ${response.code}"
}
}
} catch (e: IOException) {
e.message ?: "Could not reach the server"
}
}
/** Server replies "uploaded <slug>.<ext>"; recover the slug from that. */
private fun parseSlug(responseBody: String, fallback: String): String {
val name = responseBody.trim().removePrefix("uploaded ").trim()
val slug = name.substringBeforeLast('.')
return slug.ifEmpty { fallback }
}
private class BasicAuthInterceptor(username: String, password: String) : Interceptor {
private val header = Credentials.basic(username, password)
override fun intercept(chain: Interceptor.Chain): Response =
chain.proceed(
chain.request().newBuilder()
.header("Authorization", header)
.build(),
)
}
}
@@ -0,0 +1,296 @@
package ws.inflo.photogallery.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM yyyy")
@Composable
fun PostScreen(
state: PostUiState,
onPickImages: () -> Unit,
onCaptionChange: (id: String, value: String) -> Unit,
onDateChange: (id: String, value: LocalDate) -> Unit,
onRemove: (id: String) -> Unit,
onSubmit: () -> Unit,
onReset: () -> Unit,
onOpenSettings: () -> Unit,
onOpenUrl: (String) -> Unit,
) {
// Which item's date is being edited, if any.
var dateEditFor by remember { mutableStateOf<PickedItem?>(null) }
val pendingCount = state.items.count { it.status !is ItemStatus.Success }
val canPost = state.hasItems && !state.posting && pendingCount > 0
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Post photos", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
TextButton(onClick = onOpenSettings) { Text("Settings") }
}
Spacer(Modifier.height(12.dp))
}
item {
OutlinedButton(onClick = onPickImages, modifier = Modifier.fillMaxWidth()) {
Text(if (state.hasItems) "Choose different photos" else "Choose photos")
}
Spacer(Modifier.height(16.dp))
}
if (!state.hasItems) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.background(GruvBg1),
contentAlignment = Alignment.Center,
) {
Text("No photos selected", color = GruvFg4)
}
}
}
items(state.items, key = { it.id }) { item ->
ItemCard(
item = item,
onCaptionChange = { onCaptionChange(item.id, it) },
onEditDate = { dateEditFor = item },
onRemove = { onRemove(item.id) },
onOpenUrl = onOpenUrl,
)
Spacer(Modifier.height(12.dp))
}
if (state.hasItems) {
item {
Spacer(Modifier.height(8.dp))
Button(
onClick = onSubmit,
enabled = canPost,
modifier = Modifier.fillMaxWidth(),
) {
Text(
when {
state.posting -> "Posting… (${state.succeeded}/${state.items.size})"
pendingCount == 0 -> "All posted"
pendingCount == 1 -> "Post 1 photo"
else -> "Post $pendingCount photos"
},
)
}
if (state.allDone) {
Spacer(Modifier.height(12.dp))
val summary = buildString {
append("${state.succeeded} posted")
if (state.failed > 0) append(", ${state.failed} failed")
}
Text(summary, color = if (state.failed > 0) GruvRed else GruvAqua)
Spacer(Modifier.height(8.dp))
TextButton(onClick = onReset) { Text("Clear") }
}
}
}
state.lastPost?.let { last ->
item {
Spacer(Modifier.height(24.dp))
HorizontalDivider(color = GruvBg2)
Spacer(Modifier.height(12.dp))
Text("Last post", color = GruvFg4, style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(4.dp))
Text(
last.caption.ifBlank { last.slug },
color = GruvFg,
style = MaterialTheme.typography.bodyMedium,
)
Text(
last.slug,
color = GruvFg4,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = { onOpenUrl(last.url) }) { Text("Open") }
}
}
}
}
dateEditFor?.let { editing ->
DatePickerModal(
initial = editing.date,
onDismiss = { dateEditFor = null },
onConfirm = {
onDateChange(editing.id, it)
dateEditFor = null
},
)
}
}
@Composable
private fun ItemCard(
item: PickedItem,
onCaptionChange: (String) -> Unit,
onEditDate: () -> Unit,
onRemove: () -> Unit,
onOpenUrl: (String) -> Unit,
) {
Row(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.size(84.dp)
.background(GruvBg1),
contentAlignment = Alignment.Center,
) {
val preview = item.preview
when {
preview != null -> Image(
bitmap = preview.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
else -> CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(24.dp))
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
OutlinedTextField(
value = item.caption,
onValueChange = onCaptionChange,
label = { Text("Caption") },
singleLine = true,
enabled = item.status !is ItemStatus.Uploading,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = onEditDate,
enabled = item.status !is ItemStatus.Uploading,
) {
Text(item.date.format(DISPLAY_DATE))
}
Spacer(Modifier.width(8.dp))
ItemStatusLabel(item.status, onOpenUrl)
}
}
Spacer(Modifier.width(8.dp))
when (item.status) {
is ItemStatus.Uploading ->
CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(20.dp))
is ItemStatus.Success -> Unit
else -> TextButton(onClick = onRemove) { Text("✕", color = GruvFg4) }
}
}
}
@Composable
private fun ItemStatusLabel(status: ItemStatus, onOpenUrl: (String) -> Unit) {
when (status) {
is ItemStatus.Pending -> Unit
is ItemStatus.Uploading -> Text("Uploading…", color = GruvFg4, style = MaterialTheme.typography.bodySmall)
is ItemStatus.Success -> TextButton(onClick = { onOpenUrl(status.url) }) { Text("View") }
is ItemStatus.Error -> Text(
status.message,
color = GruvRed,
style = MaterialTheme.typography.bodySmall,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DatePickerModal(
initial: LocalDate,
onDismiss: () -> Unit,
onConfirm: (LocalDate) -> Unit,
) {
val state = rememberDatePickerState(
initialSelectedDateMillis = initial
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
val millis = state.selectedDateMillis
if (millis != null) {
onConfirm(
Instant.ofEpochMilli(millis)
.atZone(ZoneId.systemDefault())
.toLocalDate(),
)
} else {
onDismiss()
}
},
) { Text("OK") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
) {
DatePicker(state = state)
}
}
@@ -0,0 +1,129 @@
package ws.inflo.photogallery.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun SettingsScreen(
state: SettingsUiState,
onBaseUrlChange: (String) -> Unit,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onSave: () -> Unit,
onBack: (() -> Unit)?,
) {
var passwordVisible by remember { mutableStateOf(false) }
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Text("Settings", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = state.baseUrl,
onValueChange = onBaseUrlChange,
label = { Text("Gallery URL") },
placeholder = { Text("https://photos.example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
Text(
"Must be https. Basic auth puts the password in a trivially " +
"reversible header, so plain http would expose it.",
color = GruvFg4,
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = state.username,
onValueChange = onUsernameChange,
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text("Password") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
TextButton(onClick = { passwordVisible = !passwordVisible }) {
Text(if (passwordVisible) "Hide" else "Show")
}
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
Text(
"Stored sealed with a key held in the Android Keystore, never " +
"in the clear.",
color = GruvFg4,
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(24.dp))
Button(
onClick = onSave,
enabled = !state.testing,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.testing) "Testing…" else "Save and test connection")
}
state.message?.let {
Spacer(Modifier.height(12.dp))
Text(it, color = if (state.messageIsError) GruvRed else GruvAqua)
}
onBack?.let {
Spacer(Modifier.height(16.dp))
TextButton(onClick = it) { Text("← Back") }
}
}
}
}
@@ -0,0 +1,40 @@
package ws.inflo.photogallery.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Gruvbox dark hard, same values as static/style.css in the gallery.
val GruvBg = Color(0xFF1D2021)
val GruvBg0 = Color(0xFF282828)
val GruvBg1 = Color(0xFF3C3836)
val GruvBg2 = Color(0xFF504945)
val GruvFg = Color(0xFFEBDBB2)
val GruvFg3 = Color(0xFFBDAE93)
val GruvFg4 = Color(0xFFA89984)
val GruvAqua = Color(0xFF8EC07C)
val GruvGreen = Color(0xFF98971A)
val GruvBlue = Color(0xFF458588)
val GruvRed = Color(0xFFFB4934)
private val GruvboxDark = darkColorScheme(
primary = GruvAqua,
onPrimary = GruvBg,
secondary = GruvBlue,
onSecondary = GruvFg,
background = GruvBg,
onBackground = GruvFg,
surface = GruvBg0,
onSurface = GruvFg,
surfaceVariant = GruvBg1,
onSurfaceVariant = GruvFg3,
outline = GruvBg2,
error = GruvRed,
onError = GruvBg,
)
@Composable
fun PhotoGalleryTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = GruvboxDark, content = content)
}
@@ -0,0 +1,268 @@
package ws.inflo.photogallery.ui
import android.app.Application
import android.graphics.Bitmap
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ws.inflo.photogallery.data.LastPost
import ws.inflo.photogallery.data.SettingsStore
import ws.inflo.photogallery.image.ImagePrep
import ws.inflo.photogallery.net.UploadClient
import ws.inflo.photogallery.work.UploadQueue
import ws.inflo.photogallery.work.UploadWorker
import java.io.File
import java.time.LocalDate
import java.util.UUID
/** Per-photo status within a batch. */
sealed interface ItemStatus {
data object Pending : ItemStatus
data object Uploading : ItemStatus
data class Success(val url: String, val note: String?) : ItemStatus
data class Error(val message: String) : ItemStatus
}
/**
* One photo in the batch. A single-photo post (picker or share) is just a
* batch of one, so the whole screen runs a single code path.
*/
data class PickedItem(
val id: String = UUID.randomUUID().toString(),
val source: Uri,
val preview: Bitmap? = null,
val caption: String = "",
val date: LocalDate = LocalDate.now(),
val status: ItemStatus = ItemStatus.Pending,
)
data class PostUiState(
val items: List<PickedItem> = emptyList(),
val lastPost: LastPost? = null,
) {
val posting: Boolean get() = items.any { it.status is ItemStatus.Uploading }
val hasItems: Boolean get() = items.isNotEmpty()
val succeeded: Int get() = items.count { it.status is ItemStatus.Success }
val failed: Int get() = items.count { it.status is ItemStatus.Error }
/** True once every item has reached a terminal state and at least one ran. */
val allDone: Boolean
get() = items.isNotEmpty() &&
items.all { it.status is ItemStatus.Success || it.status is ItemStatus.Error }
}
data class SettingsUiState(
val baseUrl: String = "",
val username: String = "",
val password: String = "",
val testing: Boolean = false,
val message: String? = null,
val messageIsError: Boolean = false,
)
class UploadViewModel(app: Application) : AndroidViewModel(app) {
private val settings = SettingsStore(app)
var post by mutableStateOf(PostUiState(lastPost = settings.lastPost))
private set
var config by mutableStateOf(
SettingsUiState(
baseUrl = settings.baseUrl,
username = settings.username,
password = settings.password,
),
)
private set
val isConfigured: Boolean get() = settings.isConfigured
// ── Post screen ─────────────────────────────────────────────────────────
/** Replaces the current selection with [uris] and loads each preview. */
fun selectImages(uris: List<Uri>) {
if (uris.isEmpty()) return
val items = uris.map { PickedItem(source = it) }
post = post.copy(items = items)
val context = getApplication<Application>()
for (item in items) {
viewModelScope.launch {
val preview = withContext(Dispatchers.IO) {
ImagePrep.preview(context, item.source, maxDimension = 256)
}
val captured = withContext(Dispatchers.IO) {
ImagePrep.captureDate(context, item.source)
}
updateItem(item.id) {
it.copy(preview = preview, date = captured ?: it.date)
}
}
}
}
fun setCaption(id: String, value: String) {
updateItem(id) { it.copy(caption = value) }
}
fun setDate(id: String, value: LocalDate) {
updateItem(id) { it.copy(date = value) }
}
fun removeItem(id: String) {
// A photo already uploading keeps its worker; just drop it from the UI.
post = post.copy(items = post.items.filterNot { it.id == id })
}
fun clear() {
post = PostUiState(lastPost = settings.lastPost)
}
/** Uploads every item not already posted. Re-running retries only failures. */
fun submit() {
if (!settings.isConfigured) {
post = post.copy(
items = post.items.map {
if (it.status is ItemStatus.Success) it
else it.copy(status = ItemStatus.Error("Set the server URL and credentials first"))
},
)
return
}
val pending = post.items.filter { it.status !is ItemStatus.Success }
if (pending.isEmpty()) return
val context = getApplication<Application>()
for (item in pending) {
updateItem(item.id) { it.copy(status = ItemStatus.Uploading) }
viewModelScope.launch {
val staged = withContext(Dispatchers.IO) { stage(item.source) }
if (staged == null) {
updateItem(item.id) {
it.copy(status = ItemStatus.Error("Could not read the selected image"))
}
return@launch
}
val current = post.items.firstOrNull { it.id == item.id } ?: return@launch
val workId = UploadQueue.enqueue(
context = context,
uri = Uri.fromFile(staged),
caption = current.caption,
date = current.date,
uniqueSuffix = item.id.take(6),
)
observe(item.id, workId)
}
}
}
private fun stage(uri: Uri): File? {
return try {
val context = getApplication<Application>()
val target = File(context.cacheDir, "staged-${UUID.randomUUID()}")
val copied = context.contentResolver.openInputStream(uri)?.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
if (copied == null) null else target
} catch (_: Exception) {
null
}
}
private fun observe(itemId: String, workId: UUID) {
viewModelScope.launch {
WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(workId).collect { info ->
if (info == null) return@collect
when (info.state) {
WorkInfo.State.SUCCEEDED -> {
updateItem(itemId) {
it.copy(
status = ItemStatus.Success(
url = info.outputData.getString(UploadWorker.KEY_RESULT_URL).orEmpty(),
note = info.outputData.getString(UploadWorker.KEY_RESULT_NOTE),
),
)
}
post = post.copy(lastPost = settings.lastPost)
}
WorkInfo.State.FAILED -> updateItem(itemId) {
it.copy(
status = ItemStatus.Error(
info.outputData.getString(UploadWorker.KEY_RESULT_ERROR)
?: "Upload failed",
),
)
}
WorkInfo.State.CANCELLED -> updateItem(itemId) {
it.copy(status = ItemStatus.Error("Upload cancelled"))
}
else -> Unit
}
}
}
}
private inline fun updateItem(id: String, transform: (PickedItem) -> PickedItem) {
post = post.copy(items = post.items.map { if (it.id == id) transform(it) else it })
}
// ── Settings screen ─────────────────────────────────────────────────────
fun setBaseUrl(value: String) {
config = config.copy(baseUrl = value, message = null)
}
fun setUsername(value: String) {
config = config.copy(username = value, message = null)
}
fun setPassword(value: String) {
config = config.copy(password = value, message = null)
}
fun saveAndTest() {
val urlError = SettingsStore.validateBaseUrl(config.baseUrl)
if (urlError != null) {
config = config.copy(message = urlError, messageIsError = true)
return
}
if (config.username.isBlank() || config.password.isBlank()) {
config = config.copy(
message = "Username and password are required",
messageIsError = true,
)
return
}
settings.baseUrl = config.baseUrl
settings.username = config.username
settings.password = config.password
config = config.copy(testing = true, message = null)
viewModelScope.launch {
val error = withContext(Dispatchers.IO) {
UploadClient(settings.baseUrl, settings.username, settings.password)
.testConnection()
}
config = config.copy(
testing = false,
message = error ?: "Saved — server reachable and credentials accepted",
messageIsError = error != null,
)
}
}
}
@@ -0,0 +1,67 @@
package ws.inflo.photogallery.work
import android.content.Context
import android.net.Uri
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
object UploadQueue {
/**
* Slug format. Uses the enqueue wall-clock time rather than the photo's
* capture time: capture time collides across a burst. A [uniqueSuffix] is
* appended so that a bulk post — several photos enqueued in the same second
* — cannot collide either; without it enqueueUniqueWork would silently drop
* the duplicates. The real capture date still travels in the `date` field
* and lands in the .toml sidecar.
*/
private val SLUG_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")
private val ISO_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
fun enqueue(
context: Context,
uri: Uri,
caption: String,
date: LocalDate,
uniqueSuffix: String,
): UUID {
val slug = LocalDateTime.now().format(SLUG_FORMAT) + "-" + uniqueSuffix
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(
Data.Builder()
.putString(UploadWorker.KEY_URI, uri.toString())
.putString(UploadWorker.KEY_SLUG, slug)
.putString(UploadWorker.KEY_CAPTION, caption)
.putString(UploadWorker.KEY_DATE, date.format(ISO_DATE))
.build(),
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
// Unique per slug, so a double tap cannot enqueue the same post twice.
WorkManager.getInstance(context).enqueueUniqueWork(
"upload-$slug",
ExistingWorkPolicy.KEEP,
request,
)
return request.id
}
}
@@ -0,0 +1,160 @@
package ws.inflo.photogallery.work
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ws.inflo.photogallery.R
import ws.inflo.photogallery.data.LastPost
import ws.inflo.photogallery.data.SettingsStore
import ws.inflo.photogallery.image.ImagePrep
import ws.inflo.photogallery.net.UploadClient
import ws.inflo.photogallery.net.UploadOutcome
/**
* Performs one upload, surviving the app being backgrounded or killed.
*
* Retries are safe by construction. The slug is generated once when the work
* is enqueued and carried in the input data, and server.go derives the stored
* filename from the uploaded filename — so a retry after an ambiguous timeout
* overwrites the same post rather than creating a second one. That is the only
* reason this works without an idempotency key on the server.
*/
class UploadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val uriString = inputData.getString(KEY_URI) ?: return@withContext Result.failure()
val slug = inputData.getString(KEY_SLUG) ?: return@withContext Result.failure()
val caption = inputData.getString(KEY_CAPTION).orEmpty()
val date = inputData.getString(KEY_DATE).orEmpty()
val settings = SettingsStore(applicationContext)
if (!settings.isConfigured) {
return@withContext failWith("Set the server URL and credentials first")
}
val prepared = try {
ImagePrep.prepare(
context = applicationContext,
uri = uriString.toUri(),
cacheDir = applicationContext.cacheDir,
baseName = slug,
)
} catch (e: Exception) {
return@withContext failWith(e.message ?: "Could not read the image")
}
val client = UploadClient(
baseUrl = settings.baseUrl,
username = settings.username,
password = settings.password,
)
val outcome = client.upload(
file = prepared.file,
fileName = prepared.file.name,
caption = caption,
date = date,
)
// Keep the staged and prepared copies only while a retry might still
// need them.
if (outcome !is UploadOutcome.Transient) {
prepared.file.delete()
deleteStagedSource(uriString)
}
when (outcome) {
is UploadOutcome.Success -> {
settings.lastPost = LastPost(
slug = outcome.slug,
url = outcome.url,
caption = caption,
postedAtMillis = System.currentTimeMillis(),
)
Result.success(
androidx.work.Data.Builder()
.putString(KEY_RESULT_SLUG, outcome.slug)
.putString(KEY_RESULT_URL, outcome.url)
.putString(KEY_RESULT_NOTE, prepared.note)
.build(),
)
}
UploadOutcome.AuthFailed -> failWith("Wrong username or password")
is UploadOutcome.Rejected -> failWith(outcome.message)
is UploadOutcome.Transient ->
if (runAttemptCount < MAX_ATTEMPTS) Result.retry()
else failWith(outcome.message)
}
}
/** Removes the copy the UI staged in our cache, never anything outside it. */
private fun deleteStagedSource(uriString: String) {
val uri = uriString.toUri()
if (uri.scheme != "file") return
val file = java.io.File(uri.path ?: return)
if (file.parentFile == applicationContext.cacheDir) file.delete()
}
private fun failWith(message: String) = Result.failure(
androidx.work.Data.Builder().putString(KEY_RESULT_ERROR, message).build(),
)
override suspend fun getForegroundInfo(): ForegroundInfo {
val manager = applicationContext
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
applicationContext.getString(R.string.upload_channel_name),
NotificationManager.IMPORTANCE_LOW,
),
)
}
val notification: Notification = Notification.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(applicationContext.getString(R.string.app_name))
.setContentText("Posting photo…")
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setOngoing(true)
.build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(NOTIFICATION_ID, notification)
}
}
companion object {
const val KEY_URI = "uri"
const val KEY_SLUG = "slug"
const val KEY_CAPTION = "caption"
const val KEY_DATE = "date"
const val KEY_RESULT_SLUG = "result_slug"
const val KEY_RESULT_URL = "result_url"
const val KEY_RESULT_NOTE = "result_note"
const val KEY_RESULT_ERROR = "result_error"
private const val MAX_ATTEMPTS = 5
private const val CHANNEL_ID = "uploads"
private const val NOTIFICATION_ID = 4711
}
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- minSdk is 28, so every device resolves the v26 adaptive icon and no
density-bucket PNG fallbacks are needed. The foreground is the full circular
app icon (ic_launcher_foreground.png); the launcher mask crops the disc to
its shape, and the solid gruvbox background fills the transparent corners. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/gruvbox_bg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/gruvbox_bg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Gruvbox dark hard, matching static/style.css in the gallery itself. -->
<resources>
<color name="gruvbox_bg">#1d2021</color>
<color name="gruvbox_bg0">#282828</color>
<color name="gruvbox_fg">#ebdbb2</color>
<color name="gruvbox_aqua">#8ec07c</color>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Photog</string>
<string name="upload_channel_name">Uploads</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Platform parent rather than Theme.Material3.* so the app does not need
the View-based material-components dependency. Compose supplies the
real theming; this only prevents a white flash before it composes. -->
<style name="Theme.PhotoGalleryUploader" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/gruvbox_bg</item>
<item name="android:statusBarColor">@color/gruvbox_bg</item>
<item name="android:navigationBarColor">@color/gruvbox_bg</item>
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+139
View File
@@ -0,0 +1,139 @@
{
"nodes": {
"android-nixpkgs": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1786567347,
"narHash": "sha256-I52DnM27HeCMJMgCEyNb5wz8prz6GOjzIxJnvF0ezD0=",
"owner": "tadfisher",
"repo": "android-nixpkgs",
"rev": "ccdf76eeb9011ad795cca84d1cc4bd9baa9a7329",
"type": "github"
},
"original": {
"owner": "tadfisher",
"ref": "stable",
"repo": "android-nixpkgs",
"type": "github"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"android-nixpkgs",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1786599213,
"narHash": "sha256-yNJd40f11EzXBjSByCB7IPpeFFAdeoSKKM67dGkfFoU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0e251e24a4f24e036a084b6b4b2d2491af4167f4",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"android-nixpkgs": "android-nixpkgs",
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+60
View File
@@ -0,0 +1,60 @@
{
description = "photogallery Android uploader dev shell";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
# Reproducible Android SDK across NixOS + macOS without the nixpkgs
# androidenv read-only-SDK-root quirks.
android-nixpkgs = {
url = "github:tadfisher/android-nixpkgs/stable";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, android-nixpkgs }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true; # Android SDK is unfree
};
buildToolsVersion = "36.0.0";
# SDK components for the Compose app. Pinned to the exact versions
# the Gradle config requests — adding extras only inflates the
# closure. When AGP bumps any of these, the build error names the
# exact missing version: look it up in `android-nixpkgs` and replace
# the line. No NDK or CMake: there is no native code in this app.
androidSdk = android-nixpkgs.sdk.${system} (sdkPkgs: with sdkPkgs; [
cmdline-tools-latest # sdkmanager, avdmanager
platform-tools # adb, for sideloading to the phone
platforms-android-36 # compileSdk / targetSdk
build-tools-36-0-0 # aapt2, d8, zipalign
build-tools-35-0-0 # AGP 8.x resolves this one too, and the
# nix store is read-only so it cannot
# download it itself
]);
in {
devShells.default = pkgs.mkShell {
packages = [
pkgs.jdk17 # AGP 8.x toolchain
pkgs.gradle # bootstraps ./gradlew, then the wrapper takes over
androidSdk
];
shellHook = ''
export ANDROID_HOME="${androidSdk}/share/android-sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export JAVA_HOME="${pkgs.jdk17.home}"
# AGP downloads aapt2 from Maven by default. The Maven binary is
# generic-linux ELF and won't run on NixOS. Force Gradle to use the
# patchelfed aapt2 shipped in our pinned build-tools instead.
export GRADLE_OPTS="-Dorg.gradle.project.android.aapt2FromMavenOverride=$ANDROID_HOME/build-tools/${buildToolsVersion}/aapt2"
'';
};
});
}
+8
View File
@@ -0,0 +1,8 @@
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+30
View File
@@ -0,0 +1,30 @@
[versions]
agp = "8.13.0"
kotlin = "2.2.20"
coreKtx = "1.17.0"
lifecycle = "2.9.4"
activityCompose = "1.11.0"
composeBom = "2025.09.00"
work = "2.10.5"
okhttp = "4.12.0"
exifinterface = "1.4.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "photogallery-uploader"
include(":app")
+3
View File
@@ -0,0 +1,3 @@
/build/
/.direnv/
.envrc
+42
View File
@@ -0,0 +1,42 @@
cmake_minimum_required(VERSION 3.20)
project(photogallery_uploader LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
find_package(Qt6 REQUIRED COMPONENTS Widgets Network Concurrent)
find_package(Qt6Keychain REQUIRED)
add_executable(uploader
main.cpp
mainwindow.cpp
photoitemwidget.cpp
settings.cpp
uploadclient.cpp
imageprep.cpp
app.qrc
)
target_link_libraries(uploader PRIVATE
Qt6::Widgets
Qt6::Network
Qt6::Concurrent
Qt6Keychain::Qt6Keychain
)
# The binary is "photog" (matching the app + package), so `nix run` and the
# desktop entry find it; the CMake target stays "uploader".
set_target_properties(uploader PROPERTIES OUTPUT_NAME photog)
# Installation — the binary, a desktop entry, and the app icon in the hicolor
# theme, so `nix profile install ./desktop` puts "Photog" in the app menu.
include(GNUInstallDirs)
install(TARGETS uploader RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(FILES photog.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
foreach(sz 32 128 256 512)
install(FILES icons/app-${sz}.png
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/${sz}x${sz}/apps
RENAME photog.png)
endforeach()
+9
View File
@@ -0,0 +1,9 @@
<!DOCTYPE RCC>
<RCC version="1.0">
<qresource prefix="/">
<file>icons/app-32.png</file>
<file>icons/app-128.png</file>
<file>icons/app-256.png</file>
<file>icons/app-512.png</file>
</qresource>
</RCC>
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1787498568,
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+55
View File
@@ -0,0 +1,55 @@
{
description = "photogallery uploader — Qt6 C++ desktop app";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
inherit (pkgs) lib stdenv;
# Wayland is Linux-only; macOS uses the Cocoa QPA plugin inside qtbase,
# so qtwayland is added only on Linux (it refuses to evaluate on darwin).
qtModules = [ pkgs.qt6.qtbase pkgs.qt6.qtimageformats ]
++ lib.optionals stdenv.hostPlatform.isLinux [ pkgs.qt6.qtwayland ];
qtPlugins = lib.makeSearchPathOutput "out" "lib/qt-6/plugins" qtModules;
in {
# Installable build: `nix profile install ./desktop` drops "Photog" into
# the application menu with its icon. wrapQtAppsHook wraps the binary so
# it finds the Qt platform (Cocoa on macOS; xcb + wayland on Linux) and
# image-format plugins on its own, without the dev shell.
packages.default = stdenv.mkDerivation {
pname = "photog";
version = "0.1.0";
src = ./.;
nativeBuildInputs = with pkgs; [
cmake
ninja
pkg-config
qt6.wrapQtAppsHook
];
buildInputs = [ pkgs.qt6Packages.qtkeychain ] ++ qtModules;
meta.mainProgram = "photog";
};
devShells.default = pkgs.mkShell {
packages = with pkgs; [
cmake
ninja
pkg-config
qt6Packages.qtkeychain # password → Keychain (macOS) / libsecret / kwallet
] ++ qtModules;
shellHook = ''
# Let a binary run straight from the build dir find Qt's platform and
# image-format plugins without wrapQtAppsHook.
export QT_PLUGIN_PATH="${qtPlugins}''${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}"
'';
};
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+237
View File
@@ -0,0 +1,237 @@
#include "imageprep.h"
#include <QBuffer>
#include <QDir>
#include <QFile>
#include <QImage>
#include <QImageReader>
#include <QImageWriter>
namespace {
// nginx is configured with client_max_body_size 20M; leave headroom.
constexpr qint64 kMaxUploadBytes = 18LL * 1024 * 1024;
constexpr int kJpegQuality = 92;
const int kQualityLadder[] = {92, 85, 75};
// Encodes to JPEG in memory at the given quality.
QByteArray encodeJpeg(const QImage &image, int quality)
{
QByteArray out;
QBuffer buffer(&out);
buffer.open(QIODevice::WriteOnly);
QImageWriter writer(&buffer, "jpeg");
writer.setQuality(quality);
writer.write(image);
return out;
}
// Decodes with EXIF orientation baked into the pixels. Works for HEIC too, via
// the qtimageformats plugin.
QImage decodeOriented(const QString &path)
{
QImageReader reader(path);
reader.setAutoTransform(true);
return reader.read();
}
} // namespace
ImagePrep::Prepared ImagePrep::prepare(const QString &sourcePath, const QString &cacheDir,
const QString &baseName)
{
Prepared result;
QFile in(sourcePath);
if (!in.open(QIODevice::ReadOnly)) {
result.error = QStringLiteral("Could not read the selected image");
return result;
}
const QByteArray source = in.readAll();
in.close();
QStringList notes;
// A rotation flag cannot survive metadata stripping, so bake it into the
// pixels instead, which costs a re-encode.
QImageReader orientationReader(sourcePath);
const bool mustRotate =
orientationReader.transformation() != QImageIOHandler::TransformationNone;
QByteArray bytes;
QString extension;
if (!mustRotate && isJpeg(source)) {
bytes = stripJpegSegments(source);
extension = QStringLiteral("jpg");
} else if (!mustRotate && isPng(source)) {
bytes = stripPngChunks(source);
extension = QStringLiteral("png");
}
if (bytes.isEmpty()) {
// Re-encode path: rotated originals, HEIC, or a JPEG/PNG whose marker
// structure did not parse.
const QImage image = decodeOriented(sourcePath);
if (image.isNull()) {
result.error = QStringLiteral("Could not decode the selected image");
return result;
}
if (mustRotate)
notes << QStringLiteral("rotated to match its EXIF orientation");
else if (!isJpeg(source) && !isPng(source))
notes << QStringLiteral("converted to JPEG");
bytes = encodeJpeg(image, kJpegQuality);
extension = QStringLiteral("jpg");
}
// Size valve. Only trips on genuinely huge originals; quality is stepped
// before touching resolution.
if (bytes.size() > kMaxUploadBytes) {
QImage image = decodeOriented(sourcePath);
if (image.isNull()) {
result.error = QStringLiteral("Could not decode the selected image");
return result;
}
QByteArray shrunk;
for (int quality : kQualityLadder) {
const QByteArray attempt = encodeJpeg(image, quality);
if (attempt.size() <= kMaxUploadBytes) {
shrunk = attempt;
notes << QStringLiteral("re-encoded at quality %1 to fit the 20M upload limit").arg(quality);
break;
}
}
if (shrunk.isEmpty()) {
double factor = 1.0;
while (factor > 0.2) {
factor *= 0.75;
const QImage scaled = image.scaledToWidth(
qMax(1, int(image.width() * factor)), Qt::SmoothTransformation);
const QByteArray attempt = encodeJpeg(scaled, kQualityLadder[2]);
if (attempt.size() <= kMaxUploadBytes) {
shrunk = attempt;
notes << QStringLiteral("scaled to %1×%2 to fit the 20M upload limit")
.arg(scaled.width()).arg(scaled.height());
break;
}
}
}
if (shrunk.isEmpty()) {
result.error = QStringLiteral("Image is too large to upload even after downscaling");
return result;
}
bytes = shrunk;
extension = QStringLiteral("jpg");
}
const QString outPath = QDir(cacheDir).filePath(baseName + QLatin1Char('.') + extension);
QFile out(outPath);
if (!out.open(QIODevice::WriteOnly) || out.write(bytes) != bytes.size()) {
result.error = QStringLiteral("Could not write the prepared image");
return result;
}
out.close();
result.filePath = outPath;
result.note = notes.join(QStringLiteral("; "));
return result;
}
bool ImagePrep::isJpeg(const QByteArray &b)
{
return b.size() > 3 && quint8(b[0]) == 0xFF && quint8(b[1]) == 0xD8 && quint8(b[2]) == 0xFF;
}
bool ImagePrep::isPng(const QByteArray &b)
{
return b.size() > 8 && quint8(b[0]) == 0x89 && quint8(b[1]) == 0x50 && quint8(b[2]) == 0x4E
&& quint8(b[3]) == 0x47;
}
// Drops APP1 (EXIF and XMP) and COM segments, keeping the compressed scan data
// byte-for-byte. APP0/JFIF and APP2/ICC stay: removing the colour profile would
// shift the rendered colours. Returns an empty array if the marker structure
// does not parse, so the caller falls back to a re-encode.
QByteArray ImagePrep::stripJpegSegments(const QByteArray &src)
{
const auto u8 = [&](int i) { return quint8(src[i]); };
QByteArray out;
out.append(char(0xFF));
out.append(char(0xD8));
int i = 2;
while (i + 1 < src.size()) {
if (u8(i) != 0xFF)
return {};
const int marker = u8(i + 1);
// Standalone markers: no length field, no payload.
if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) {
out.append(char(0xFF));
out.append(char(marker));
i += 2;
continue;
}
// Start of scan: entropy-coded data runs to the end of the file.
if (marker == 0xDA) {
out.append(src.mid(i));
return out;
}
if (i + 3 >= src.size())
return {};
const int length = (u8(i + 2) << 8) | u8(i + 3);
if (length < 2 || i + 2 + length > src.size())
return {};
const bool isExifOrXmp = marker == 0xE1;
const bool isComment = marker == 0xFE;
if (!isExifOrXmp && !isComment)
out.append(src.mid(i, 2 + length));
i += 2 + length;
}
return {};
}
// Drops the metadata chunks, keeps everything else including the critical
// IHDR/PLTE/IDAT/IEND run and colour chunks (iCCP, sRGB). Whole chunks go, so no
// CRC needs recomputing.
QByteArray ImagePrep::stripPngChunks(const QByteArray &src)
{
static const QByteArray drop[] = {"eXIf", "tEXt", "zTXt", "iTXt", "tIME"};
const auto u8 = [&](int i) { return quint8(src[i]); };
if (src.size() < 8)
return {};
QByteArray out;
out.append(src.mid(0, 8));
int i = 8;
while (i + 8 <= src.size()) {
const qint64 length =
(qint64(u8(i)) << 24) | (qint64(u8(i + 1)) << 16) | (qint64(u8(i + 2)) << 8) | qint64(u8(i + 3));
if (length < 0)
return {};
const QByteArray type = src.mid(i + 4, 4);
const qint64 total = 12 + length;
if (i + total > src.size())
return {};
bool dropped = false;
for (const QByteArray &d : drop) {
if (type == d) {
dropped = true;
break;
}
}
if (!dropped)
out.append(src.mid(i, int(total)));
i += int(total);
if (type == "IEND")
return out;
}
return {};
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <QByteArray>
#include <QString>
// Turns a picked image into a file the gallery's upload API accepts.
//
// Three things must be true of the result: no EXIF (the gallery publishes the
// uploaded file verbatim as its full-size download, so embedded GPS would be
// public), a .jpg or .png extension (server.go rejects anything else, HEIC
// included), and under nginx's 20M body cap.
//
// JPEG and PNG take a lossless route — whole metadata segments are dropped
// without touching the compressed image data — so the published file keeps the
// original sensor pixels. Anything else, or a file whose EXIF orientation must
// be baked in, is decoded and re-encoded as JPEG (HEIC via Qt's qtimageformats
// plugin). The strippers are a direct port of the Android app's.
class ImagePrep
{
public:
struct Prepared {
QString filePath;
QString note; // non-empty when the app departed from a byte-exact copy
QString error;
bool ok() const { return error.isEmpty(); }
};
// Writes the upload-ready file into cacheDir as baseName.(jpg|png).
static Prepared prepare(const QString &sourcePath, const QString &cacheDir,
const QString &baseName);
private:
static QByteArray stripJpegSegments(const QByteArray &src); // empty on parse failure
static QByteArray stripPngChunks(const QByteArray &src);
static bool isJpeg(const QByteArray &b);
static bool isPng(const QByteArray &b);
};
+25
View File
@@ -0,0 +1,25 @@
#include <QApplication>
#include <QIcon>
#include "mainwindow.h"
int main(int argc, char **argv)
{
QApplication app(argc, argv);
app.setApplicationName(QStringLiteral("photogallery-uploader"));
app.setOrganizationName(QStringLiteral("inflo.ws"));
// Window / taskbar / alt-tab icon, embedded via app.qrc. Several sizes so
// the small titlebar rendering stays crisp rather than downscaling 512px.
QIcon icon;
icon.addFile(QStringLiteral(":/icons/app-32.png"));
icon.addFile(QStringLiteral(":/icons/app-128.png"));
icon.addFile(QStringLiteral(":/icons/app-256.png"));
icon.addFile(QStringLiteral(":/icons/app-512.png"));
app.setWindowIcon(icon);
MainWindow window;
window.resize(440, 680);
window.show();
return app.exec();
}
+361
View File
@@ -0,0 +1,361 @@
#include "mainwindow.h"
#include "imageprep.h"
#include "photoitemwidget.h"
#include "uploadclient.h"
#include <QDateTime>
#include <QFileDialog>
#include <QFileInfo>
#include <QFormLayout>
#include <QFuture>
#include <QFutureWatcher>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QScrollArea>
#include <QSpinBox>
#include <QStackedWidget>
#include <QStandardPaths>
#include <QUuid>
#include <QVBoxLayout>
#include <QtConcurrent>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
{
setWindowTitle(QStringLiteral("Photo Gallery Uploader"));
m_stack = new QStackedWidget(this);
m_stack->addWidget(buildSettingsPage()); // index 0
m_stack->addWidget(buildPostPage()); // index 1
setCentralWidget(m_stack);
if (m_settings.isConfigured())
showPost();
else
showSettings();
}
QWidget *MainWindow::buildSettingsPage()
{
auto *page = new QWidget;
auto *layout = new QVBoxLayout(page);
layout->setContentsMargins(24, 24, 24, 24);
auto *form = new QFormLayout;
m_baseUrl = new QLineEdit;
m_baseUrl->setPlaceholderText(QStringLiteral("https://photos.example.com"));
m_username = new QLineEdit;
m_password = new QLineEdit;
m_password->setEchoMode(QLineEdit::Password);
m_concurrency = new QSpinBox;
m_concurrency->setRange(1, Settings::kMaxConcurrency);
form->addRow(QStringLiteral("Base URL"), m_baseUrl);
form->addRow(QStringLiteral("Username"), m_username);
form->addRow(QStringLiteral("Password"), m_password);
form->addRow(QStringLiteral("Simultaneous uploads"), m_concurrency);
layout->addLayout(form);
m_saveButton = new QPushButton(QStringLiteral("Save && test connection"));
connect(m_saveButton, &QPushButton::clicked, this, &MainWindow::saveAndTest);
layout->addWidget(m_saveButton);
m_settingsStatus = new QLabel;
m_settingsStatus->setWordWrap(true);
layout->addWidget(m_settingsStatus);
m_settingsBack = new QPushButton(QStringLiteral("Back"));
connect(m_settingsBack, &QPushButton::clicked, this, &MainWindow::showPost);
layout->addWidget(m_settingsBack);
layout->addWidget(new QLabel(QStringLiteral(
"The password is stored in your system keychain, never in plain text. "
"https is required — Basic auth would otherwise send it in the clear.")));
layout->addStretch();
return page;
}
QWidget *MainWindow::buildPostPage()
{
auto *page = new QWidget;
auto *outer = new QVBoxLayout(page);
outer->setContentsMargins(24, 24, 24, 24);
auto *choose = new QPushButton(QStringLiteral("Choose photos…"));
connect(choose, &QPushButton::clicked, this, &MainWindow::chooseImages);
outer->addWidget(choose);
// Scrollable list of per-photo cards.
auto *scroll = new QScrollArea;
scroll->setWidgetResizable(true);
auto *container = new QWidget;
m_itemsLayout = new QVBoxLayout(container);
m_itemsLayout->setAlignment(Qt::AlignTop);
m_emptyHint = new QLabel(QStringLiteral("No photos selected"));
m_emptyHint->setAlignment(Qt::AlignCenter);
m_itemsLayout->addWidget(m_emptyHint);
scroll->setWidget(container);
outer->addWidget(scroll, 1);
m_postButton = new QPushButton(QStringLiteral("Post"));
m_postButton->setEnabled(false);
connect(m_postButton, &QPushButton::clicked, this, &MainWindow::submit);
outer->addWidget(m_postButton);
m_postSummary = new QLabel;
m_postSummary->setWordWrap(true);
outer->addWidget(m_postSummary);
m_lastPost = new QLabel;
m_lastPost->setOpenExternalLinks(true);
m_lastPost->setWordWrap(true);
outer->addWidget(m_lastPost);
auto *settingsButton = new QPushButton(QStringLiteral("Settings"));
connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings);
outer->addWidget(settingsButton);
return page;
}
void MainWindow::showSettings()
{
m_baseUrl->setText(m_settings.baseUrl());
m_username->setText(m_settings.username());
m_password->setText(m_settings.readPassword());
m_concurrency->setValue(m_settings.concurrency());
m_settingsStatus->clear();
m_settingsBack->setVisible(m_settings.isConfigured());
m_stack->setCurrentIndex(0);
}
void MainWindow::showPost()
{
const Settings::LastPost last = m_settings.lastPost();
if (last.valid()) {
m_lastPost->setText(QStringLiteral("Last post: <a href=\"%1\">%2</a>")
.arg(last.url, last.caption.isEmpty() ? last.slug : last.caption));
} else {
m_lastPost->clear();
}
m_stack->setCurrentIndex(1);
}
void MainWindow::saveAndTest()
{
// Concurrency is not a credential; persist it regardless of URL validity.
m_settings.setConcurrency(m_concurrency->value());
const QString urlError = Settings::validateBaseUrl(m_baseUrl->text());
if (!urlError.isEmpty()) {
m_settingsStatus->setText(urlError);
return;
}
if (m_username->text().trimmed().isEmpty() || m_password->text().isEmpty()) {
m_settingsStatus->setText(QStringLiteral("Username and password are required"));
return;
}
m_settings.setBaseUrl(m_baseUrl->text());
m_settings.setUsername(m_username->text());
m_settings.writePassword(m_password->text());
m_baseUrl->setText(m_settings.baseUrl());
m_saveButton->setEnabled(false);
m_settingsStatus->setText(QStringLiteral("Testing…"));
auto *client = new UploadClient(m_settings.baseUrl(), m_settings.username(),
m_settings.readPassword(), this);
connect(client, &UploadClient::testFinished, this, [this, client](const QString &error) {
m_saveButton->setEnabled(true);
m_settingsBack->setVisible(m_settings.isConfigured());
m_settingsStatus->setText(error.isEmpty()
? QStringLiteral("Saved — server reachable and credentials accepted")
: error);
client->deleteLater();
});
client->testConnection();
}
void MainWindow::chooseImages()
{
const QStringList paths = QFileDialog::getOpenFileNames(
this, QStringLiteral("Choose photos"), QString(),
QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.heif *.webp *.tif *.tiff *.bmp);;All files (*)"));
for (const QString &path : paths)
addItem(path);
refreshPosting();
}
void MainWindow::addItem(const QString &path)
{
m_emptyHint->hide();
auto *item = new PhotoItemWidget(path);
connect(item, &PhotoItemWidget::removeRequested, this, &MainWindow::removeItem);
m_items.append(item);
m_itemsLayout->addWidget(item);
}
void MainWindow::removeItem(PhotoItemWidget *item)
{
m_items.removeOne(item);
m_queue.removeOne(item); // may be waiting for a slot — drop it before deleting
item->deleteLater();
if (m_items.isEmpty())
m_emptyHint->show();
refreshPosting();
}
void MainWindow::clearItems()
{
for (PhotoItemWidget *item : std::as_const(m_items))
item->deleteLater();
m_items.clear();
m_queue.clear();
m_emptyHint->show();
m_postSummary->clear();
refreshPosting();
}
void MainWindow::submit()
{
if (m_items.isEmpty())
return;
if (!m_settings.isConfigured()) {
m_postSummary->setText(QStringLiteral("Set the server URL and credentials first"));
return;
}
// Capture the batch's credentials once, then feed the queue through a
// concurrency cap rather than firing every photo at the server at once.
m_batchCacheDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
m_batchBaseUrl = m_settings.baseUrl();
m_batchUsername = m_settings.username();
m_batchPassword = m_settings.readPassword();
m_queue.clear();
for (PhotoItemWidget *item : std::as_const(m_items)) {
if (item->status() != PhotoItemWidget::Status::Success)
m_queue.append(item); // re-posting retries only failures
}
if (m_queue.isEmpty())
return;
m_postSummary->clear();
pumpQueue();
refreshPosting();
}
int MainWindow::inFlightCount() const
{
int n = 0;
for (PhotoItemWidget *item : std::as_const(m_items)) {
const PhotoItemWidget::Status s = item->status();
if (s == PhotoItemWidget::Status::Preparing || s == PhotoItemWidget::Status::Uploading)
++n;
}
return n;
}
void MainWindow::pumpQueue()
{
const int cap = m_settings.concurrency();
while (inFlightCount() < cap && !m_queue.isEmpty())
startItem(m_queue.takeFirst());
}
void MainWindow::startItem(PhotoItemWidget *item)
{
item->setStatus(PhotoItemWidget::Status::Preparing);
// Slug from wall-clock time plus a per-item suffix so a batch posted in the
// same second cannot collide (server derives the stored name from the
// uploaded filename). The real capture date rides in the date field.
const QString slug = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-HHmmss"))
+ QLatin1Char('-') + QUuid::createUuid().toString(QUuid::Id128).left(6);
const QString source = item->sourcePath();
const QString cacheDir = m_batchCacheDir;
const QString baseUrl = m_batchBaseUrl;
const QString username = m_batchUsername;
const QString password = m_batchPassword;
auto *watcher = new QFutureWatcher<ImagePrep::Prepared>(this);
connect(watcher, &QFutureWatcher<ImagePrep::Prepared>::finished, this,
[this, watcher, item, baseUrl, username, password]() {
const ImagePrep::Prepared prepared = watcher->result();
watcher->deleteLater();
if (!prepared.ok()) {
item->setStatus(PhotoItemWidget::Status::Error, prepared.error);
pumpQueue(); // free the slot for the next photo
refreshPosting();
return;
}
item->setStatus(PhotoItemWidget::Status::Uploading);
const QString caption = item->caption();
auto *client = new UploadClient(baseUrl, username, password, this);
connect(client, &UploadClient::uploadFinished, this,
[this, client, item, prepared, caption](UploadClient::Result result,
const QString &slug, const QString &url,
const QString &message) {
QFile::remove(prepared.filePath); // no retry queue on the desktop
if (result == UploadClient::Result::Success) {
m_settings.setLastPost({slug, url, caption, QDateTime::currentDateTime()});
item->setStatus(PhotoItemWidget::Status::Success, {}, url);
} else {
item->setStatus(PhotoItemWidget::Status::Error, message);
}
client->deleteLater();
pumpQueue(); // free the slot for the next photo
refreshPosting();
});
client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(),
caption, item->date());
});
watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() {
return ImagePrep::prepare(source, cacheDir, slug);
}));
}
void MainWindow::refreshPosting()
{
int pending = 0;
int inFlight = 0;
int succeeded = 0;
int failed = 0;
for (PhotoItemWidget *item : std::as_const(m_items)) {
switch (item->status()) {
case PhotoItemWidget::Status::Pending:
++pending;
break;
case PhotoItemWidget::Status::Preparing:
case PhotoItemWidget::Status::Uploading:
++inFlight;
break;
case PhotoItemWidget::Status::Success:
++succeeded;
break;
case PhotoItemWidget::Status::Error:
++failed;
++pending; // a failure is re-postable
break;
}
}
const bool posting = inFlight > 0;
m_postButton->setEnabled(!m_items.isEmpty() && !posting && pending > 0);
m_postButton->setText(posting ? QStringLiteral("Posting… (%1/%2)").arg(succeeded).arg(m_items.size())
: pending == 1 ? QStringLiteral("Post 1 photo")
: pending > 1 ? QStringLiteral("Post %1 photos").arg(pending)
: QStringLiteral("All posted"));
if (!posting && (succeeded + failed) > 0) {
QString summary = QStringLiteral("%1 posted").arg(succeeded);
if (failed > 0)
summary += QStringLiteral(", %1 failed").arg(failed);
m_postSummary->setText(summary);
}
}
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <QList>
#include <QMainWindow>
#include "settings.h"
class QDateEdit;
class QLabel;
class QLineEdit;
class QPushButton;
class QSpinBox;
class QStackedWidget;
class QVBoxLayout;
class PhotoItemWidget;
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
private:
QWidget *buildSettingsPage();
QWidget *buildPostPage();
void showSettings();
void showPost();
void saveAndTest();
void chooseImages();
void addItem(const QString &path);
void removeItem(PhotoItemWidget *item);
void clearItems();
void submit();
void pumpQueue(); // starts queued items up to the concurrency cap
void startItem(PhotoItemWidget *item);
int inFlightCount() const; // items currently preparing or uploading
void refreshPosting(); // updates the Post button + summary from item states
Settings m_settings;
QStackedWidget *m_stack = nullptr;
// Settings page
QLineEdit *m_baseUrl = nullptr;
QLineEdit *m_username = nullptr;
QLineEdit *m_password = nullptr;
QSpinBox *m_concurrency = nullptr;
QPushButton *m_saveButton = nullptr;
QPushButton *m_settingsBack = nullptr;
QLabel *m_settingsStatus = nullptr;
// Post page
QVBoxLayout *m_itemsLayout = nullptr; // holds the PhotoItemWidgets
QList<PhotoItemWidget *> m_items;
QLabel *m_emptyHint = nullptr;
QPushButton *m_postButton = nullptr;
QLabel *m_postSummary = nullptr;
QLabel *m_lastPost = nullptr;
// Bulk-post state: items waiting for a free upload slot, plus the batch's
// credentials captured once at submit time.
QList<PhotoItemWidget *> m_queue;
QString m_batchBaseUrl;
QString m_batchUsername;
QString m_batchPassword;
QString m_batchCacheDir;
};
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Photog
GenericName=Photo Uploader
Comment=Upload photos to your gallery
Exec=photog
Icon=photog
Terminal=false
Categories=Graphics;Photography;Network;
+101
View File
@@ -0,0 +1,101 @@
#include "photoitemwidget.h"
#include <QDate>
#include <QDateEdit>
#include <QHBoxLayout>
#include <QImageReader>
#include <QLabel>
#include <QLineEdit>
#include <QPixmap>
#include <QPushButton>
#include <QVBoxLayout>
namespace {
constexpr int kThumbSize = 72;
}
PhotoItemWidget::PhotoItemWidget(QString sourcePath, QWidget *parent)
: QWidget(parent)
, m_sourcePath(std::move(sourcePath))
{
auto *row = new QHBoxLayout(this);
row->setContentsMargins(0, 4, 0, 4);
m_thumb = new QLabel;
m_thumb->setFixedSize(kThumbSize, kThumbSize);
m_thumb->setAlignment(Qt::AlignCenter);
m_thumb->setFrameShape(QFrame::Box);
{
QImageReader reader(m_sourcePath);
reader.setAutoTransform(true);
const QImage image = reader.read();
if (!image.isNull())
m_thumb->setPixmap(QPixmap::fromImage(image).scaled(
kThumbSize, kThumbSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
else
m_thumb->setText(QStringLiteral("?"));
}
row->addWidget(m_thumb);
auto *fields = new QVBoxLayout;
m_caption = new QLineEdit;
m_caption->setPlaceholderText(QStringLiteral("Caption (defaults to the filename)"));
fields->addWidget(m_caption);
m_date = new QDateEdit(QDate::currentDate());
m_date->setCalendarPopup(true);
m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
fields->addWidget(m_date);
m_statusLabel = new QLabel;
m_statusLabel->setOpenExternalLinks(true);
m_statusLabel->setWordWrap(true);
fields->addWidget(m_statusLabel);
row->addLayout(fields, 1);
m_remove = new QPushButton(QStringLiteral("✕"));
m_remove->setFlat(true);
m_remove->setFixedWidth(32);
connect(m_remove, &QPushButton::clicked, this, [this]() { emit removeRequested(this); });
row->addWidget(m_remove, 0, Qt::AlignTop);
}
QString PhotoItemWidget::caption() const
{
return m_caption->text();
}
QString PhotoItemWidget::date() const
{
return m_date->date().toString(QStringLiteral("yyyy-MM-dd"));
}
void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url)
{
m_status = status;
// Inputs are frozen once the photo is in flight; the remove button stays
// usable except mid-transfer.
const bool editable = status == Status::Pending || status == Status::Error;
m_caption->setEnabled(editable);
m_date->setEnabled(editable);
m_remove->setEnabled(status != Status::Preparing && status != Status::Uploading);
switch (status) {
case Status::Pending:
m_statusLabel->clear();
break;
case Status::Preparing:
m_statusLabel->setText(QStringLiteral("Preparing…"));
break;
case Status::Uploading:
m_statusLabel->setText(QStringLiteral("Uploading…"));
break;
case Status::Success:
m_statusLabel->setText(QStringLiteral("Posted — <a href=\"%1\">View</a>").arg(url));
break;
case Status::Error:
m_statusLabel->setText(QStringLiteral("<span style=\"color:#fb4934\">%1</span>").arg(message));
break;
}
}
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <QString>
#include <QWidget>
class QDateEdit;
class QLabel;
class QLineEdit;
class QPushButton;
// One photo in the batch: thumbnail, caption, date, and its own upload status.
// A single-photo post is just a batch of one, so the window runs one code path.
class PhotoItemWidget : public QWidget
{
Q_OBJECT
public:
enum class Status { Pending, Preparing, Uploading, Success, Error };
explicit PhotoItemWidget(QString sourcePath, QWidget *parent = nullptr);
QString sourcePath() const { return m_sourcePath; }
QString caption() const;
QString date() const; // yyyy-MM-dd
Status status() const { return m_status; }
void setStatus(Status status, const QString &message = {}, const QString &url = {});
signals:
void removeRequested(PhotoItemWidget *self);
private:
QString m_sourcePath;
Status m_status = Status::Pending;
QLabel *m_thumb = nullptr;
QLineEdit *m_caption = nullptr;
QDateEdit *m_date = nullptr;
QLabel *m_statusLabel = nullptr;
QPushButton *m_remove = nullptr;
};
+126
View File
@@ -0,0 +1,126 @@
#include "settings.h"
#include <QEventLoop>
#include <QUrl>
#include <algorithm>
#include <qt6keychain/keychain.h>
namespace {
const QLatin1String kService("photogallery-uploader");
const QLatin1String kPasswordKey("password");
} // namespace
Settings::Settings()
: m_settings(QStringLiteral("inflo.ws"), QStringLiteral("photogallery-uploader"))
{
}
QString Settings::baseUrl() const
{
return m_settings.value(QStringLiteral("baseUrl")).toString();
}
void Settings::setBaseUrl(const QString &value)
{
m_settings.setValue(QStringLiteral("baseUrl"), normalizeBaseUrl(value));
}
QString Settings::username() const
{
return m_settings.value(QStringLiteral("username")).toString();
}
void Settings::setUsername(const QString &value)
{
m_settings.setValue(QStringLiteral("username"), value.trimmed());
}
int Settings::concurrency() const
{
const int v = m_settings.value(QStringLiteral("concurrency"), kDefaultConcurrency).toInt();
return std::clamp(v, 1, kMaxConcurrency);
}
void Settings::setConcurrency(int value)
{
m_settings.setValue(QStringLiteral("concurrency"), std::clamp(value, 1, kMaxConcurrency));
}
QString Settings::readPassword() const
{
QKeychain::ReadPasswordJob job(kService);
job.setAutoDelete(false);
job.setKey(kPasswordKey);
QEventLoop loop;
QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit);
job.start();
loop.exec();
if (job.error() != QKeychain::NoError)
return QString();
return job.textData();
}
void Settings::writePassword(const QString &value) const
{
QKeychain::WritePasswordJob job(kService);
job.setAutoDelete(false);
job.setKey(kPasswordKey);
job.setTextData(value);
QEventLoop loop;
QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit);
job.start();
loop.exec();
}
bool Settings::isConfigured() const
{
return !baseUrl().isEmpty() && !username().isEmpty() && !readPassword().isEmpty();
}
Settings::LastPost Settings::lastPost() const
{
LastPost p;
p.slug = m_settings.value(QStringLiteral("last/slug")).toString();
p.url = m_settings.value(QStringLiteral("last/url")).toString();
p.caption = m_settings.value(QStringLiteral("last/caption")).toString();
p.postedAt = m_settings.value(QStringLiteral("last/postedAt")).toDateTime();
return p;
}
void Settings::setLastPost(const LastPost &value)
{
m_settings.setValue(QStringLiteral("last/slug"), value.slug);
m_settings.setValue(QStringLiteral("last/url"), value.url);
m_settings.setValue(QStringLiteral("last/caption"), value.caption);
m_settings.setValue(QStringLiteral("last/postedAt"), value.postedAt);
}
QString Settings::validateBaseUrl(const QString &raw)
{
const QString trimmed = raw.trimmed();
if (trimmed.isEmpty())
return QStringLiteral("Base URL is required");
const QUrl url(trimmed);
if (!url.isValid() || url.host().isEmpty())
return QStringLiteral("Not a valid URL");
if (url.scheme() != QStringLiteral("https"))
return QStringLiteral("Must be https — plain http sends your password in the clear");
return QString();
}
QString Settings::normalizeBaseUrl(const QString &raw)
{
QString v = raw.trimmed();
while (v.endsWith(QLatin1Char('/')))
v.chop(1);
if (v.endsWith(QStringLiteral("/upload"), Qt::CaseInsensitive))
v.chop(QStringLiteral("/upload").size());
while (v.endsWith(QLatin1Char('/')))
v.chop(1);
return v;
}
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include <QDateTime>
#include <QSettings>
#include <QString>
// Persists the non-secret settings (base URL, username, last post) in a
// QSettings INI under ~/.config, and the password in the system keychain
// (libsecret / kwallet via QtKeychain). The password never lands on disk in the
// clear, mirroring the Android app's Keystore sealing.
class Settings
{
public:
struct LastPost {
QString slug;
QString url;
QString caption;
QDateTime postedAt;
bool valid() const { return !slug.isEmpty(); }
};
Settings();
QString baseUrl() const;
void setBaseUrl(const QString &value);
QString username() const;
void setUsername(const QString &value);
// Max photos uploaded at once in a bulk post, clamped to [1, kMaxConcurrency].
static constexpr int kMaxConcurrency = 8;
static constexpr int kDefaultConcurrency = 3;
int concurrency() const;
void setConcurrency(int value);
// Keychain access is synchronous here (a local event loop): the app does
// nothing until the credential is in hand, and there is no background retry
// worker as on Android that would need it off the UI thread.
QString readPassword() const;
void writePassword(const QString &value) const;
bool isConfigured() const;
LastPost lastPost() const;
void setLastPost(const LastPost &value);
// Returns an error message, or an empty string when the URL is usable.
// https is required: Basic auth would otherwise send the password in the
// clear.
static QString validateBaseUrl(const QString &raw);
// Trims trailing slashes and a trailing "/upload" (the endpoint path a user
// pasting the upload URL would leave on — the client appends it itself).
static QString normalizeBaseUrl(const QString &raw);
private:
mutable QSettings m_settings;
};
+126
View File
@@ -0,0 +1,126 @@
#include "uploadclient.h"
#include <QFile>
#include <QFileInfo>
#include <QHttpMultiPart>
#include <QHttpPart>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
UploadClient::UploadClient(QString baseUrl, QString username, QString password, QObject *parent)
: QObject(parent)
, m_base(std::move(baseUrl))
, m_username(std::move(username))
, m_password(std::move(password))
, m_net(new QNetworkAccessManager(this))
{
while (m_base.endsWith(QLatin1Char('/')))
m_base.chop(1);
}
QByteArray UploadClient::authHeader() const
{
const QByteArray raw = (m_username + QLatin1Char(':') + m_password).toUtf8();
return "Basic " + raw.toBase64();
}
void UploadClient::upload(const QString &filePath, const QString &fileName,
const QString &caption, const QString &date)
{
auto *file = new QFile(filePath);
if (!file->open(QIODevice::ReadOnly)) {
delete file;
emit uploadFinished(Result::Rejected, {}, {}, QStringLiteral("Could not read the prepared image"));
return;
}
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
const QByteArray contentType =
fileName.endsWith(QStringLiteral(".png"), Qt::CaseInsensitive) ? "image/png" : "image/jpeg";
QHttpPart imagePart;
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(contentType));
imagePart.setHeader(QNetworkRequest::ContentDispositionHeader,
QVariant(QStringLiteral("form-data; name=\"image\"; filename=\"%1\"").arg(fileName)));
file->setParent(multiPart);
imagePart.setBodyDevice(file);
multiPart->append(imagePart);
const auto textPart = [](const QString &name, const QString &value) {
QHttpPart part;
part.setHeader(QNetworkRequest::ContentDispositionHeader,
QVariant(QStringLiteral("form-data; name=\"%1\"").arg(name)));
part.setBody(value.toUtf8());
return part;
};
multiPart->append(textPart(QStringLiteral("caption"), caption));
multiPart->append(textPart(QStringLiteral("date"), date));
QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload")));
request.setRawHeader("Authorization", authHeader());
QNetworkReply *reply = m_net->post(request, multiPart);
multiPart->setParent(reply);
const QString fallbackSlug = QFileInfo(fileName).completeBaseName();
connect(reply, &QNetworkReply::finished, this, [this, reply, fallbackSlug]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString body = QString::fromUtf8(reply->readAll()).trimmed();
if (reply->error() == QNetworkReply::NoError && status >= 200 && status < 300) {
// Server replies "uploaded slug.ext"; recover the slug.
QString name = body;
if (name.startsWith(QStringLiteral("uploaded ")))
name = name.mid(QStringLiteral("uploaded ").size()).trimmed();
const int dot = name.lastIndexOf(QLatin1Char('.'));
QString slug = dot > 0 ? name.left(dot) : name;
if (slug.isEmpty())
slug = fallbackSlug;
emit uploadFinished(Result::Success, slug, m_base + QStringLiteral("/photo/") + slug + QLatin1Char('/'), {});
return;
}
if (status == 401 || status == 403) {
emit uploadFinished(Result::AuthFailed, {}, {}, QStringLiteral("Wrong username or password"));
return;
}
if (status >= 500 && status < 600) {
emit uploadFinished(Result::Transient, {}, {}, QStringLiteral("Server error %1").arg(status));
return;
}
if (status == 0) {
// No HTTP response — a socket/TLS failure. Worth retrying.
emit uploadFinished(Result::Transient, {}, {}, reply->errorString());
return;
}
emit uploadFinished(Result::Rejected, {}, {}, body.isEmpty() ? QStringLiteral("Upload rejected") : body);
});
}
void UploadClient::testConnection()
{
QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload")));
request.setRawHeader("Authorization", authHeader());
QNetworkReply *reply = m_net->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() == QNetworkReply::NoError && status >= 200 && status < 300) {
emit testFinished(QString());
return;
}
if (status == 401 || status == 403) {
emit testFinished(QStringLiteral("Wrong username or password"));
return;
}
if (status == 0) {
emit testFinished(reply->errorString());
return;
}
emit testFinished(QStringLiteral("Server returned %1").arg(status));
});
}
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <QObject>
#include <QString>
class QNetworkAccessManager;
// Talks to the gallery's POST /upload endpoint, which sits behind nginx HTTP
// Basic auth. The credentials go out preemptively in the Authorization header
// on every request (never via QNetworkAccessManager's authenticationRequired
// signal, which only fires on a 401 and would replay a multipart body already
// streamed off disk).
//
// Asynchronous: results arrive via signals so the UI stays responsive during an
// upload over a slow link.
class UploadClient : public QObject
{
Q_OBJECT
public:
enum class Result {
Success, // uploaded; slug/url populated
AuthFailed, // wrong credentials — a retry will not help
Rejected, // server said no for a reason a retry won't change
Transient, // network trouble or a 5xx — worth retrying
};
UploadClient(QString baseUrl, QString username, QString password, QObject *parent = nullptr);
// The filename matters: server.go derives the slug from it, so re-sending
// the same name overwrites the same post instead of duplicating it.
void upload(const QString &filePath, const QString &fileName,
const QString &caption, const QString &date);
// Credential check via GET /upload (same Basic-auth gate the POST uses).
// /health is not usable: nginx only proxies location /upload.
void testConnection();
signals:
void uploadFinished(UploadClient::Result result, const QString &slug,
const QString &url, const QString &message);
// errorMessage is empty on success.
void testFinished(const QString &errorMessage);
private:
QByteArray authHeader() const;
QString m_base;
QString m_username;
QString m_password;
QNetworkAccessManager *m_net;
};
+1 -1
View File
@@ -22,7 +22,7 @@
meta = with pkgs.lib; {
description = "Minimal static photo gallery";
license = licenses.mit;
license = licenses.unlicense;
mainProgram = "photogallery";
platforms = platforms.linux;
};
+393 -35
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"encoding/xml"
"html/template"
"image"
@@ -22,11 +23,27 @@ import (
)
const (
thumbSize = 600
mediumMaxSize = 1600
pageSize = 60
thumbSize = 600
mediumMaxSize = 1600
defaultPageSize = 60
// The two paginated views. The one chosen as -default-view is published
// at the site root, the other under /<name>/.
viewTimeline = "timeline"
viewGrid = "grid"
// Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a
// 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely
// reported to drop the preview entirely once the image passes ~300 KB, so
// the medium rendition (which can exceed that) is not reused here.
ogMaxSize = 1200
ogTargetBytes = 250 * 1024
)
// ogQualityLadder is walked from best to worst until the encoded OG image
// fits under ogTargetBytes. The last entry is used as-is even if still over.
var ogQualityLadder = []int{85, 78, 70, 62}
// PhotoMeta is the structure of a .toml sidecar file.
type PhotoMeta struct {
Caption string `toml:"caption"`
@@ -35,12 +52,23 @@ type PhotoMeta struct {
// Photo is the in-memory representation of one image.
type Photo struct {
Slug string
File string // relative path under public/, e.g. "images/foo.jpg"
Thumb string // relative path under public/, e.g. "thumbs/foo.jpg"
Medium string // relative path under public/, e.g. "medium/foo.jpg"
Caption string
Date time.Time
Slug string
File string // relative path under public/, e.g. "images/foo.jpg"
Thumb string // relative path under public/, e.g. "thumbs/foo.jpg"
Medium string // relative path under public/, e.g. "medium/foo.jpg"
OG string // relative path under public/, e.g. "og/foo.jpg"
OGWidth int // pixel dimensions of the OG image, for og:image:width/height
OGHeight int
Caption string
Date time.Time
}
// DayGroup is a run of photos sharing the same calendar day, for the timeline.
type DayGroup struct {
day string // "2006-01-02", grouping key (unexported: not for templates)
Label string // "14 June 2026"
Count int
Photos []Photo
}
type Generator struct {
@@ -54,12 +82,20 @@ type Generator struct {
fontsURL string
serifFamily string
mlFamily string
pageSize int
defaultView string
mu sync.Mutex
tmpl *template.Template
}
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string) *Generator {
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int, defaultView string) *Generator {
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
if pageSize < 1 {
pageSize = defaultPageSize
}
if defaultView != viewGrid {
defaultView = viewTimeline
}
return &Generator{
contentDir: contentDir,
outputDir: outputDir,
@@ -71,6 +107,8 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f
fontsURL: fontsURL,
serifFamily: serifFamily,
mlFamily: mlFamily,
pageSize: pageSize,
defaultView: defaultView,
tmpl: tmpl,
}
}
@@ -82,6 +120,23 @@ func (g *Generator) initial() string {
return ""
}
// viewBase returns the URL prefix a view is published under: "" for the
// default view, which owns the site root, and "/<view>" for the other one.
func (g *Generator) viewBase(view string) string {
if view == g.defaultView {
return ""
}
return "/" + view
}
// pageURL returns the URL of the 1-based page n of a view rooted at base.
func pageURL(base string, n int) string {
if n == 1 {
return base + "/"
}
return base + "/page/" + strconv.Itoa(n) + "/"
}
// Build performs a full rebuild of the site. It is safe to call concurrently;
// concurrent calls are serialised by a mutex.
func (g *Generator) Build() error {
@@ -93,7 +148,7 @@ func (g *Generator) Build() error {
return err
}
for _, d := range []string{"images", "thumbs", "medium", "photo", "static"} {
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "day", "static"} {
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err
}
@@ -105,7 +160,20 @@ func (g *Generator) Build() error {
}
}
if err := g.renderIndexPages(photos); err != nil {
// The default view lives at the site root, so a directory named after it
// can only be left over from a run with the other -default-view. Drop it,
// or the old pages would stay reachable and go stale.
if err := os.RemoveAll(filepath.Join(g.outputDir, g.defaultView)); err != nil {
return err
}
if err := g.renderTimelinePages(photos); err != nil {
return err
}
if err := g.renderGridPages(photos); err != nil {
return err
}
if err := g.renderDayPages(photos); err != nil {
return err
}
for i := range photos {
@@ -131,6 +199,48 @@ func (g *Generator) Build() error {
return nil
}
// Delete removes a photo for good: its image and sidecar in the content
// directory, then everything generated from them. Pages that list the photo
// are put right by the next Build. It returns os.ErrNotExist when no photo
// has that slug.
func (g *Generator) Delete(slug string) error {
g.mu.Lock()
defer g.mu.Unlock()
photos, err := g.loadPhotos()
if err != nil {
return err
}
found := false
for _, p := range photos {
if p.Slug != slug {
continue
}
found = true
// The source goes first: if it cannot be removed, the photo stays
// fully intact rather than half deleted.
if err := os.Remove(filepath.Join(g.contentDir, filepath.Base(p.File))); err != nil {
return err
}
for _, rel := range []string{p.File, p.Thumb, p.Medium, p.OG} {
err := os.Remove(filepath.Join(g.outputDir, filepath.FromSlash(rel)))
if err != nil && !os.IsNotExist(err) {
return err
}
}
}
if !found {
return os.ErrNotExist
}
// slug is safe to join from here on: it matched a directory entry.
if err := os.Remove(filepath.Join(g.contentDir, slug+".toml")); err != nil && !os.IsNotExist(err) {
return err
}
return os.RemoveAll(filepath.Join(g.outputDir, "photo", slug))
}
// loadPhotos scans the content directory and returns photos sorted newest first.
func (g *Generator) loadPhotos() ([]Photo, error) {
entries, err := os.ReadDir(g.contentDir)
@@ -172,6 +282,7 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
File: "images/" + name,
Thumb: "thumbs/" + slug + ".jpg",
Medium: "medium/" + slug + ".jpg",
OG: "og/" + slug + ".jpg",
Caption: meta.Caption,
Date: date,
})
@@ -191,6 +302,7 @@ func (g *Generator) processImage(p *Photo) error {
dstImg := filepath.Join(g.outputDir, p.File)
dstThumb := filepath.Join(g.outputDir, p.Thumb)
dstMedium := filepath.Join(g.outputDir, p.Medium)
dstOG := filepath.Join(g.outputDir, p.OG)
if !upToDate(src, dstImg) {
if err := copyFile(src, dstImg); err != nil {
@@ -207,9 +319,36 @@ func (g *Generator) processImage(p *Photo) error {
return err
}
}
if !upToDate(src, dstOG) {
if err := generateOG(src, dstOG, ogMaxSize); err != nil {
return err
}
}
// og:image:width/height need the OG image's real dimensions, and they must
// be known even when the file above was skipped as up to date. DecodeConfig
// reads only the JPEG header, so this is cheap.
if cfg, err := decodeConfigFile(dstOG); err == nil {
p.OGWidth = cfg.Width
p.OGHeight = cfg.Height
} else {
log.Printf("og dimensions %s: %v", p.Slug, err)
}
return nil
}
// decodeConfigFile reads just the image header of path and returns its config
// (dimensions, colour model) without decoding the pixels.
func decodeConfigFile(path string) (image.Config, error) {
f, err := os.Open(path)
if err != nil {
return image.Config{}, err
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
return cfg, err
}
// upToDate reports whether dst exists and is at least as new as src.
func upToDate(src, dst string) bool {
si, err := os.Stat(src)
@@ -299,48 +438,89 @@ func generateMedium(src, dst string, maxDim int) error {
return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88})
}
// renderIndexPages writes public/index.html and public/page/N/index.html
// files, chunking photos into pageSize slices for progressive pagination.
func (g *Generator) renderIndexPages(photos []Photo) error {
// generateOG reads src, resizes it so its longest side is at most maxDim, and
// writes a JPEG small enough for social link previews. It walks ogQualityLadder
// from best to worst, stopping at the first quality whose output is under
// ogTargetBytes, and falls back to the lowest quality if none fits.
func generateOG(src, dst string, maxDim int) error {
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return err
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
nw, nh := w, h
if w > maxDim || h > maxDim {
if w >= h {
nw = maxDim
nh = h * maxDim / w
} else {
nh = maxDim
nw = w * maxDim / h
}
}
resized := image.NewRGBA(image.Rect(0, 0, nw, nh))
xdraw.CatmullRom.Scale(resized, resized.Bounds(), img, b, xdraw.Over, nil)
var buf bytes.Buffer
for i, q := range ogQualityLadder {
buf.Reset()
if err := jpeg.Encode(&buf, resized, &jpeg.Options{Quality: q}); err != nil {
return err
}
if buf.Len() <= ogTargetBytes || i == len(ogQualityLadder)-1 {
break
}
}
return os.WriteFile(dst, buf.Bytes(), 0644)
}
// renderGridPages writes the paginated square-grid view: index.html and
// page/N/index.html under the grid's base (the site root when the grid is the
// default view, /grid/ otherwise), chunking photos into pageSize slices for
// progressive pagination.
func (g *Generator) renderGridPages(photos []Photo) error {
total := len(photos)
totalPages := (total + pageSize - 1) / pageSize
totalPages := (total + g.pageSize - 1) / g.pageSize
if totalPages == 0 {
totalPages = 1
}
base := g.viewBase(viewGrid)
for i := 0; i < totalPages; i++ {
start := i * pageSize
end := start + pageSize
start := i * g.pageSize
end := start + g.pageSize
if end > total {
end = total
}
slice := photos[start:end]
dst := filepath.Join(g.outputDir, "index.html")
if i > 0 {
dir := filepath.Join(g.outputDir, "page", strconv.Itoa(i+1))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst = filepath.Join(dir, "index.html")
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst := filepath.Join(dir, "index.html")
prev := ""
next := ""
switch i {
case 0:
// no prev
case 1:
prev = "/"
default:
prev = "/page/" + strconv.Itoa(i) + "/"
if i > 0 {
prev = pageURL(base, i)
}
if i+1 < totalPages {
next = "/page/" + strconv.Itoa(i+2) + "/"
next = pageURL(base, i+2)
}
data := map[string]any{
"Photos": slice,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"Total": total,
"Page": i + 1,
"TotalPages": totalPages,
@@ -357,11 +537,11 @@ func (g *Generator) renderIndexPages(photos []Photo) error {
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
}
if err := g.render("index.html", dst, data); err != nil {
if err := g.render("grid.html", dst, data); err != nil {
return err
}
}
return nil
return g.prunePages(base, totalPages)
}
// renderPhotoPage writes public/photo/<slug>/index.html.
@@ -385,6 +565,176 @@ func (g *Generator) renderPhotoPage(p Photo, newer, older *Photo) error {
})
}
// groupByDay collapses photos (already sorted newest first) into per-day runs,
// preserving order. Two photos land in the same group iff their dates share a
// calendar day.
func groupByDay(photos []Photo) []DayGroup {
var groups []DayGroup
for _, p := range photos {
day := p.Date.Format("2006-01-02")
if n := len(groups); n > 0 && groups[n-1].day == day {
groups[n-1].Photos = append(groups[n-1].Photos, p)
groups[n-1].Count++
continue
}
groups = append(groups, DayGroup{
day: day,
Label: day, // ISO 2006-01-02
Count: 1,
Photos: []Photo{p},
})
}
return groups
}
// chunkDayGroups packs day groups into pages, filling each page until it holds
// at least target photos, then starting a new one. A single day is never split
// across a page boundary, so page sizes vary slightly around target. Always
// returns at least one page (empty when there are no photos) so the site root
// renders.
func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
var pages [][]DayGroup
var cur []DayGroup
count := 0
for _, grp := range groups {
cur = append(cur, grp)
count += grp.Count
if count >= target {
pages = append(pages, cur)
cur, count = nil, 0
}
}
if len(cur) > 0 {
pages = append(pages, cur)
}
if len(pages) == 0 {
pages = [][]DayGroup{nil}
}
return pages
}
// renderTimelinePages writes the day-grouped timeline: index.html and
// page/N/index.html under the timeline's base (the site root when the
// timeline is the default view). Photos are grouped by day, then packed
// into pages of roughly pageSize without splitting a day across pages.
func (g *Generator) renderTimelinePages(photos []Photo) error {
pages := chunkDayGroups(groupByDay(photos), g.pageSize)
totalPages := len(pages)
base := g.viewBase(viewTimeline)
for i, groups := range pages {
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst := filepath.Join(dir, "index.html")
prev := ""
next := ""
if i > 0 {
prev = pageURL(base, i)
}
if i+1 < totalPages {
next = pageURL(base, i+2)
}
data := map[string]any{
"Groups": groups,
"GridURL": pageURL(g.viewBase(viewGrid), 1),
"Total": len(photos),
"Page": i + 1,
"TotalPages": totalPages,
"PrevURL": prev,
"NextURL": next,
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Bio": g.bio,
"BioAlt": g.bioAlt,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
}
if err := g.render("timeline.html", dst, data); err != nil {
return err
}
}
return g.prunePages(base, totalPages)
}
// prunePages removes the pager pages of a view past its last one. They are
// left behind when the photo count shrinks or the views swap places, and
// would keep showing photos that are gone.
func (g *Generator) prunePages(base string, totalPages int) error {
// Page 1 is the view's own root, never a leftover: start past it even
// if a caller reports no pages at all.
for n := max(totalPages, 1) + 1; ; n++ {
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, n)))
if _, err := os.Stat(dir); err != nil {
return nil
}
if err := os.RemoveAll(dir); err != nil {
return err
}
}
}
// renderDayPages writes one page per calendar day under public/day/<ISO>/,
// holding just that day's photos. Timeline date headings link here. Pages of
// days that no longer have photos, after a delete or a date change, are
// removed.
func (g *Generator) renderDayPages(photos []Photo) error {
groups := groupByDay(photos)
live := make(map[string]bool, len(groups))
for _, grp := range groups {
live[grp.day] = true
}
dayDir := filepath.Join(g.outputDir, "day")
entries, err := os.ReadDir(dayDir)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() && !live[e.Name()] {
if err := os.RemoveAll(filepath.Join(dayDir, e.Name())); err != nil {
return err
}
}
}
for _, grp := range groups {
dir := filepath.Join(g.outputDir, "day", grp.day)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
"Date": grp.Label,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"Count": grp.Count,
"Photos": grp.Photos,
"Total": len(photos),
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Bio": g.bio,
"BioAlt": g.bioAlt,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
})
if err != nil {
return err
}
}
return nil
}
func (g *Generator) render(tmpl, dst string, data any) error {
f, err := os.Create(dst)
if err != nil {
@@ -467,6 +817,14 @@ func (g *Generator) copyStatic() error {
if err := os.WriteFile(dst, data, 0644); err != nil {
return err
}
// Also serve the favicon from the site root, where browsers and
// crawlers request /favicon.ico by default without a <link> tag.
if e.Name() == "favicon.ico" {
if err := os.WriteFile(filepath.Join(g.outputDir, e.Name()), data, 0644); err != nil {
return err
}
}
}
return nil
}
+165
View File
@@ -0,0 +1,165 @@
package main
import (
"bytes"
"image"
"image/jpeg"
"os"
"path/filepath"
"strings"
"testing"
)
// buildSite builds a site of three photos spread over two days, with a page
// size of two so both views need a second page, and returns the output dir.
func buildSite(t *testing.T, output, defaultView string) {
t.Helper()
content := t.TempDir()
var img bytes.Buffer
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
t.Fatal(err)
}
for slug, date := range map[string]string{
"one": "2026-06-14",
"two": "2026-06-14",
"three": "2026-06-10",
} {
writeFile(t, filepath.Join(content, slug+".jpg"), img.Bytes())
writeFile(t, filepath.Join(content, slug+".toml"),
[]byte("caption = \""+slug+"\"\ndate = \""+date+"\"\n"))
}
g := NewGenerator(content, output, "https://photos.example.com",
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 2, defaultView)
if err := g.Build(); err != nil {
t.Fatal(err)
}
}
func writeFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
}
func readPage(t *testing.T, output, rel string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(output, filepath.FromSlash(rel), "index.html"))
if err != nil {
t.Fatal(err)
}
return string(data)
}
// Timeline pages carry day sections, grid pages do not.
func isTimeline(page string) bool {
return strings.Contains(page, `class="tl-day"`)
}
func TestDefaultViewOwnsSiteRoot(t *testing.T) {
tests := []struct {
defaultView string
gridAt string // URL of the grid's first page
timelineAt string // URL of the timeline's first page
}{
{viewTimeline, "/grid/", "/"},
{viewGrid, "/", "/timeline/"},
{"nonsense", "/grid/", "/"}, // unknown values fall back to the timeline
}
for _, tt := range tests {
t.Run(tt.defaultView, func(t *testing.T) {
output := t.TempDir()
buildSite(t, output, tt.defaultView)
timeline := readPage(t, output, tt.timelineAt)
grid := readPage(t, output, tt.gridAt)
if !isTimeline(timeline) {
t.Errorf("%s is not the timeline", tt.timelineAt)
}
if isTimeline(grid) {
t.Errorf("%s is not the grid", tt.gridAt)
}
// The views link to each other, and to their own second page.
for page, want := range map[string]string{
timeline: `href="` + tt.gridAt + `" class="rss-a">Grid`,
grid: `href="` + tt.timelineAt + `" class="rss-a">Timeline`,
} {
if !strings.Contains(page, want) {
t.Errorf("missing link %q", want)
}
}
for first, base := range map[string]string{
timeline: strings.TrimSuffix(tt.timelineAt, "/"),
grid: strings.TrimSuffix(tt.gridAt, "/"),
} {
next := base + "/page/2/"
if !strings.Contains(first, `href="`+next+`" data-load-more`) {
t.Errorf("first page does not link to %s", next)
}
second := readPage(t, output, next)
if !strings.Contains(second, `class="pager-newer" href="`+base+`/"`) {
t.Errorf("%s does not link back to %s/", next, base)
}
}
// Day pages lead back to the timeline, wherever it lives.
day := readPage(t, output, "/day/2026-06-14/")
if !strings.Contains(day, `href="`+tt.timelineAt+`" class="rss-a">← Timeline`) {
t.Errorf("day page does not link to the timeline at %s", tt.timelineAt)
}
})
}
}
func TestSwitchingDefaultViewDropsOldViewDir(t *testing.T) {
output := t.TempDir()
buildSite(t, output, viewTimeline)
if _, err := os.Stat(filepath.Join(output, "grid", "index.html")); err != nil {
t.Fatalf("grid view missing before the switch: %v", err)
}
buildSite(t, output, viewGrid)
if _, err := os.Stat(filepath.Join(output, "grid")); !os.IsNotExist(err) {
t.Errorf("stale /grid/ survived the switch to -default-view grid")
}
if isTimeline(readPage(t, output, "/")) {
t.Errorf("site root is still the timeline")
}
}
func TestShrinkingSiteDropsLeftoverPages(t *testing.T) {
output := t.TempDir()
buildSite(t, output, viewTimeline) // three photos, page size two
content := t.TempDir()
var img bytes.Buffer
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(content, "one.jpg"), img.Bytes())
writeFile(t, filepath.Join(content, "one.toml"),
[]byte("caption = \"one\"\ndate = \"2026-06-14\"\n"))
// The same output directory, now fed a single photo: one page per view,
// one day.
g := NewGenerator(content, output, "https://photos.example.com",
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 2, viewTimeline)
if err := g.Build(); err != nil {
t.Fatal(err)
}
for _, gone := range []string{"page/2", "grid/page/2", "day/2026-06-10"} {
if _, err := os.Stat(filepath.Join(output, filepath.FromSlash(gone))); !os.IsNotExist(err) {
t.Errorf("leftover %s survived", gone)
}
}
for _, kept := range []string{"index.html", "grid/index.html", "day/2026-06-14/index.html"} {
if _, err := os.Stat(filepath.Join(output, filepath.FromSlash(kept))); err != nil {
t.Errorf("%s is missing: %v", kept, err)
}
}
}
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+9 -2
View File
@@ -19,15 +19,22 @@ func main() {
fontsURL := flag.String("fonts-url", "https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap", "stylesheet URL loading the two web fonts")
serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text")
mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line")
pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)")
manage := flag.Bool("manage", false, "serve the /manage pages for editing and deleting published photos (off by default; put them behind auth in nginx)")
defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other one is published under /<name>/")
flag.Parse()
if *defaultView != viewTimeline && *defaultView != viewGrid {
log.Fatalf("-default-view must be %q or %q, got %q", viewTimeline, viewGrid, *defaultView)
}
for _, d := range []string{*contentDir, *outputDir} {
if err := os.MkdirAll(d, 0755); err != nil {
log.Fatalf("mkdir %s: %v", d, err)
}
}
g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily)
g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily, *pageSize, *defaultView)
if err := g.Build(); err != nil {
log.Printf("initial build: %v", err)
@@ -40,6 +47,6 @@ func main() {
defer w.Close()
go w.Watch()
s := NewServer(*port, *contentDir, *outputDir, *serve, g)
s := NewServer(*port, *contentDir, *outputDir, *serve, *manage, g)
log.Fatal(s.Listen())
}
+224
View File
@@ -0,0 +1,224 @@
package main
import (
"bytes"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
)
// The /manage pages edit and delete photos that are already published. Like
// /upload they carry no auth of their own: in production nginx puts them
// behind htpasswd, and nothing on the public site links to them.
// maxEditBody caps the edit form. It carries a caption and a date, nothing else.
const maxEditBody = 64 << 10
// handleManagePage lists every photo with links to edit and delete it.
func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) {
photos, err := s.generator.loadPhotos()
if err != nil {
log.Printf("manage: %v", err)
http.Error(w, "could not read photos", http.StatusInternalServerError)
return
}
data := s.managePageData()
data["Photos"] = photos
if saved := r.URL.Query().Get("saved"); saved != "" {
for _, p := range photos {
if p.Slug == saved {
data["Saved"] = p.Caption
}
}
}
data["Deleted"] = r.URL.Query().Get("deleted")
s.renderManage(w, "manage.html", data)
}
// handleEditPage shows the edit form for one photo, prefilled from its sidecar.
func (s *Server) handleEditPage(w http.ResponseWriter, r *http.Request) {
photo, ok := s.findPhoto(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
data := s.managePageData()
data["Photo"] = photo
s.renderManage(w, "edit.html", data)
}
// handleEdit accepts the edit form with fields:
//
// caption — human-readable caption (empty falls back to the slug, as on upload)
// date — YYYY-MM-DD
//
// It rewrites the photo's .toml sidecar; the image itself is never touched.
func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
http.Error(w, "cross-site request refused", http.StatusForbidden)
return
}
photo, ok := s.findPhoto(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxEditBody)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
caption := strings.TrimSpace(r.PostFormValue("caption"))
if caption == "" {
caption = photo.Slug
}
dateStr := r.PostFormValue("date")
if _, err := time.Parse("2006-01-02", dateStr); err != nil {
http.Error(w, "date must be YYYY-MM-DD", http.StatusBadRequest)
return
}
// Encode first and write in one go, so a rebuild racing this request
// never reads a half-written sidecar.
var buf bytes.Buffer
if err := toml.NewEncoder(&buf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil {
http.Error(w, "could not write metadata", http.StatusInternalServerError)
return
}
tomlPath := filepath.Join(s.contentDir, photo.Slug+".toml")
if err := os.WriteFile(tomlPath, buf.Bytes(), 0644); err != nil {
log.Printf("write %s: %v", tomlPath, err)
http.Error(w, "could not save metadata", http.StatusInternalServerError)
return
}
// Build() is mutex-guarded so calling it here and from the watcher is safe.
go func() {
if err := s.generator.Build(); err != nil {
log.Printf("rebuild after edit: %v", err)
}
}()
http.Redirect(w, r, "/manage?saved="+url.QueryEscape(photo.Slug), http.StatusSeeOther)
}
// handleDeletePage asks for confirmation before a photo is deleted. Deleting
// is a separate page rather than a button in the list so that it always takes
// two deliberate steps.
func (s *Server) handleDeletePage(w http.ResponseWriter, r *http.Request) {
photo, ok := s.findPhoto(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
data := s.managePageData()
data["Photo"] = photo
s.renderManage(w, "delete.html", data)
}
// handleDelete removes a photo for good: image, sidecar and everything
// generated from them. There is no undo.
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
http.Error(w, "cross-site request refused", http.StatusForbidden)
return
}
photo, ok := s.findPhoto(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
if err := s.generator.Delete(photo.Slug); err != nil {
log.Printf("delete %s: %v", photo.Slug, err)
http.Error(w, "could not delete photo", http.StatusInternalServerError)
return
}
log.Printf("deleted %s (%q)", photo.Slug, photo.Caption)
// Build() is mutex-guarded so calling it here and from the watcher is safe.
go func() {
if err := s.generator.Build(); err != nil {
log.Printf("rebuild after delete: %v", err)
}
}()
http.Redirect(w, r, "/manage?deleted="+url.QueryEscape(photo.Caption), http.StatusSeeOther)
}
// findPhoto looks slug up among the photos in the content directory. Matching
// against real entries, rather than building a path from the URL, is what
// keeps a crafted slug from reaching outside the content directory.
func (s *Server) findPhoto(slug string) (Photo, bool) {
photos, err := s.generator.loadPhotos()
if err != nil {
log.Printf("manage: %v", err)
return Photo{}, false
}
for _, p := range photos {
if p.Slug == slug {
return p, true
}
}
return Photo{}, false
}
// sameOrigin reports whether a form post came from the gallery's own pages.
// Basic auth credentials are replayed by the browser on every request to the
// host, so without this check any other website could submit the edit or
// delete form on a logged-in user's behalf.
//
// The Origin header is compared against -base-url as well as the Host header:
// behind nginx the Host that reaches this server is the proxy's upstream
// address, not the public name. Requests with neither header come from
// non-browser clients such as curl, which cannot be driven cross-site.
func (s *Server) sameOrigin(r *http.Request) bool {
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
return site == "same-origin" || site == "none"
}
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
if origin == s.generator.baseURL {
return true
}
u, err := url.Parse(origin)
return err == nil && u.Host == r.Host
}
// managePageData returns the template fields shared by the manage pages.
func (s *Server) managePageData() map[string]any {
g := s.generator
return map[string]any{
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
}
}
// renderManage renders into a buffer first so a template error becomes a
// clean 500 instead of a truncated page.
func (s *Server) renderManage(w http.ResponseWriter, tmpl string, data map[string]any) {
var buf bytes.Buffer
if err := s.generator.tmpl.ExecuteTemplate(&buf, tmpl, data); err != nil {
log.Printf("render %s: %v", tmpl, err)
http.Error(w, "could not render page", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Always reflect the sidecars as they are now, never a cached copy.
w.Header().Set("Cache-Control", "no-store")
buf.WriteTo(w)
}
+355
View File
@@ -0,0 +1,355 @@
package main
import (
"bytes"
"image"
"image/jpeg"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/BurntSushi/toml"
)
// newTestServer returns a server over a content directory holding one photo,
// "sunrise", with a sidecar.
func newTestServer(t *testing.T) *Server {
t.Helper()
// Not t.TempDir(): an edit kicks off a background rebuild that can still
// be writing when the test ends, and TempDir fails the test if its
// cleanup finds the directory busy.
root, err := os.MkdirTemp("", "photog-test-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(root) })
content := filepath.Join(root, "content")
output := filepath.Join(root, "public")
for _, d := range []string{content, output} {
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
}
var img bytes.Buffer
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(content, "sunrise.jpg"), img.Bytes())
writeFile(t, filepath.Join(content, "sunrise.toml"),
[]byte("caption = \"Tempelhof at sunrise\"\ndate = \"2026-06-14\"\n"))
g := NewGenerator(content, output, "https://photos.example.com",
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 60, viewTimeline)
return NewServer("0", content, output, false, true, g)
}
func readMeta(t *testing.T, s *Server, slug string) PhotoMeta {
t.Helper()
var meta PhotoMeta
if _, err := toml.DecodeFile(filepath.Join(s.contentDir, slug+".toml"), &meta); err != nil {
t.Fatal(err)
}
return meta
}
// postEdit submits the edit form for slug and returns the response.
func postEdit(s *Server, slug string, form url.Values, headers map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/manage/edit/"+slug, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for k, v := range headers {
r.Header.Set(k, v)
}
r.SetPathValue("slug", slug)
w := httptest.NewRecorder()
s.handleEdit(w, r)
return w
}
func TestManagePageListsPhotos(t *testing.T) {
s := newTestServer(t)
w := httptest.NewRecorder()
s.handleManagePage(w, httptest.NewRequest("GET", "/manage", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"Tempelhof at sunrise", "2026-06-14", `href="/manage/edit/sunrise"`} {
if !strings.Contains(body, want) {
t.Errorf("manage page is missing %q", want)
}
}
}
func TestEditPagePrefillsForm(t *testing.T) {
s := newTestServer(t)
r := httptest.NewRequest("GET", "/manage/edit/sunrise", nil)
r.SetPathValue("slug", "sunrise")
w := httptest.NewRecorder()
s.handleEditPage(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{`value="Tempelhof at sunrise"`, `value="2026-06-14"`} {
if !strings.Contains(body, want) {
t.Errorf("edit page is missing %q", want)
}
}
}
func TestEditRewritesSidecar(t *testing.T) {
s := newTestServer(t)
form := url.Values{"caption": {`Tempelhof, "golden hour"`}, "date": {"2026-06-15"}}
w := postEdit(s, "sunrise", form, nil)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body)
}
if loc := w.Header().Get("Location"); loc != "/manage?saved=sunrise" {
t.Errorf("Location = %q", loc)
}
meta := readMeta(t, s, "sunrise")
if meta.Caption != `Tempelhof, "golden hour"` || meta.Date != "2026-06-15" {
t.Errorf("sidecar = %+v", meta)
}
}
func TestEditEmptyCaptionFallsBackToSlug(t *testing.T) {
s := newTestServer(t)
w := postEdit(s, "sunrise", url.Values{"caption": {" "}, "date": {"2026-06-14"}}, nil)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", w.Code)
}
if meta := readMeta(t, s, "sunrise"); meta.Caption != "sunrise" {
t.Errorf("caption = %q, want the slug", meta.Caption)
}
}
func TestEditRejectsBadInput(t *testing.T) {
valid := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}}
tests := []struct {
name string
slug string
form url.Values
headers map[string]string
want int
}{
{"unknown photo", "nope", valid, nil, http.StatusNotFound},
{"path traversal", "../sunrise", valid, nil, http.StatusNotFound},
{"malformed date", "sunrise", url.Values{"caption": {"changed"}, "date": {"15.06.2026"}}, nil, http.StatusBadRequest},
{"missing date", "sunrise", url.Values{"caption": {"changed"}}, nil, http.StatusBadRequest},
{"cross-site fetch", "sunrise", valid, map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden},
{"foreign origin", "sunrise", valid, map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestServer(t)
w := postEdit(s, tt.slug, tt.form, tt.headers)
if w.Code != tt.want {
t.Errorf("status = %d, want %d", w.Code, tt.want)
}
if meta := readMeta(t, s, "sunrise"); meta.Caption != "Tempelhof at sunrise" || meta.Date != "2026-06-14" {
t.Errorf("sidecar was changed: %+v", meta)
}
})
}
}
func TestEditAcceptsOwnOrigin(t *testing.T) {
form := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}}
tests := []struct {
name string
headers map[string]string
}{
{"same-origin fetch metadata", map[string]string{"Sec-Fetch-Site": "same-origin"}},
// Behind nginx the Host header is the upstream address, so only
// -base-url can vouch for the public origin.
{"origin equal to base URL", map[string]string{"Origin": "https://photos.example.com"}},
{"origin equal to host", map[string]string{"Origin": "http://example.com"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestServer(t)
if w := postEdit(s, "sunrise", form, tt.headers); w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body)
}
})
}
}
// twoPhotoServer returns a built site with "sunrise" plus "dusk", which has a
// day of its own, so deleting it must also take that day's page away.
func twoPhotoServer(t *testing.T) *Server {
t.Helper()
s := newTestServer(t)
var img bytes.Buffer
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(s.contentDir, "dusk.jpg"), img.Bytes())
writeFile(t, filepath.Join(s.contentDir, "dusk.toml"),
[]byte("caption = \"Dusk\"\ndate = \"2026-07-01\"\n"))
if err := s.generator.Build(); err != nil {
t.Fatal(err)
}
return s
}
// photoFiles lists every file and directory that belongs to one photo.
func photoFiles(s *Server, slug, day string) []string {
return []string{
filepath.Join(s.contentDir, slug+".jpg"),
filepath.Join(s.contentDir, slug+".toml"),
filepath.Join(s.outputDir, "images", slug+".jpg"),
filepath.Join(s.outputDir, "thumbs", slug+".jpg"),
filepath.Join(s.outputDir, "medium", slug+".jpg"),
filepath.Join(s.outputDir, "og", slug+".jpg"),
filepath.Join(s.outputDir, "photo", slug),
filepath.Join(s.outputDir, "day", day),
}
}
func postDelete(s *Server, slug string, headers map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/manage/delete/"+slug, nil)
for k, v := range headers {
r.Header.Set(k, v)
}
r.SetPathValue("slug", slug)
w := httptest.NewRecorder()
s.handleDelete(w, r)
return w
}
func TestDeletePageAsksFirst(t *testing.T) {
s := twoPhotoServer(t)
r := httptest.NewRequest("GET", "/manage/delete/dusk", nil)
r.SetPathValue("slug", "dusk")
w := httptest.NewRecorder()
s.handleDeletePage(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if !strings.Contains(w.Body.String(), `action="/manage/delete/dusk"`) {
t.Errorf("confirmation page has no delete form")
}
// Looking at the confirmation page must not delete anything.
for _, f := range photoFiles(s, "dusk", "2026-07-01") {
if _, err := os.Stat(f); err != nil {
t.Errorf("%s is gone after a GET: %v", f, err)
}
}
}
func TestDeleteRemovesPhotoEverywhere(t *testing.T) {
s := twoPhotoServer(t)
w := postDelete(s, "dusk", nil)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body)
}
if loc := w.Header().Get("Location"); loc != "/manage?deleted=Dusk" {
t.Errorf("Location = %q", loc)
}
// The handler rebuilds in the background; build here to have a
// finished site to look at.
if err := s.generator.Build(); err != nil {
t.Fatal(err)
}
for _, f := range photoFiles(s, "dusk", "2026-07-01") {
if _, err := os.Stat(f); !os.IsNotExist(err) {
t.Errorf("%s survived the delete", f)
}
}
for _, f := range photoFiles(s, "sunrise", "2026-06-14") {
if _, err := os.Stat(f); err != nil {
t.Errorf("the other photo lost %s: %v", f, err)
}
}
for _, page := range []string{"index.html", "grid/index.html", "feed.xml"} {
data, err := os.ReadFile(filepath.Join(s.outputDir, page))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "dusk") {
t.Errorf("%s still mentions the deleted photo", page)
}
}
}
func TestDeleteRejectsBadInput(t *testing.T) {
tests := []struct {
name string
slug string
headers map[string]string
want int
}{
{"unknown photo", "nope", nil, http.StatusNotFound},
{"path traversal", "../dusk", nil, http.StatusNotFound},
{"cross-site fetch", "dusk", map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden},
{"foreign origin", "dusk", map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := twoPhotoServer(t)
if w := postDelete(s, tt.slug, tt.headers); w.Code != tt.want {
t.Errorf("status = %d, want %d", w.Code, tt.want)
}
for _, slug := range []string{"dusk", "sunrise"} {
if _, err := os.Stat(filepath.Join(s.contentDir, slug+".jpg")); err != nil {
t.Errorf("%s was deleted: %v", slug, err)
}
}
})
}
}
func TestManageRoutesNeedTheFlag(t *testing.T) {
for _, manage := range []bool{false, true} {
s := newTestServer(t)
s.manage = manage
mux := s.routes()
want := http.StatusNotFound
if manage {
want = http.StatusOK
}
for _, path := range []string{"/manage", "/manage/edit/sunrise", "/manage/delete/sunrise"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != want {
t.Errorf("manage=%v: GET %s = %d, want %d", manage, path, w.Code, want)
}
}
// The delete POST only does something with the flag: without it the
// photo must survive, with it (no Origin header, as from curl) it goes.
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("POST", "/manage/delete/sunrise", nil))
_, err := os.Stat(filepath.Join(s.contentDir, "sunrise.jpg"))
present := err == nil
if present == manage {
t.Errorf("manage=%v: POST delete = %d, photo present afterwards = %v", manage, w.Code, present)
}
// /upload and /health are there regardless.
for _, path := range []string{"/upload", "/health"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != http.StatusOK {
t.Errorf("manage=%v: GET %s = %d, want 200", manage, path, w.Code)
}
}
}
}
+25 -3
View File
@@ -76,6 +76,20 @@ in {
description = "CSS font-family for the alt-script (Malayalam) bio line. Must match a family loaded by fontsURL.";
};
pageSize = lib.mkOption {
type = lib.types.ints.positive;
default = 60;
description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this.";
};
defaultView = lib.mkOption {
type = lib.types.enum [ "timeline" "grid" ];
default = "timeline";
description = "View published at the site root. The other one is published under /timeline/ or /grid/.";
};
manage.enable = lib.mkEnableOption "the /manage pages for editing and deleting published photos (served behind the same htpasswd as /upload)";
nginx = {
enable = lib.mkEnableOption "nginx virtual host for photogallery";
@@ -88,7 +102,7 @@ in {
htpasswdFile = lib.mkOption {
type = lib.types.path;
example = "/etc/nginx/.htpasswd-gallery";
description = "Path to htpasswd file protecting the /upload endpoint.";
description = "Path to htpasswd file protecting the /upload and /manage endpoints.";
};
enableACME = lib.mkOption {
@@ -128,7 +142,7 @@ in {
User = "photogallery";
Group = "photogallery";
ExecStart = lib.escapeShellArgs [
ExecStart = lib.escapeShellArgs ([
"${pkg}/bin/photogallery"
"--content" cfg.contentDir
"--output" cfg.outputDir
@@ -141,7 +155,9 @@ in {
"--fonts-url" cfg.fontsURL
"--serif-family" cfg.serifFamily
"--ml-family" cfg.mlFamily
];
"--page-size" (toString cfg.pageSize)
"--default-view" cfg.defaultView
] ++ lib.optional cfg.manage.enable "--manage");
Restart = "on-failure";
RestartSec = "5s";
@@ -177,6 +193,12 @@ in {
basicAuthFile = cfg.nginx.htpasswdFile;
extraConfig = "client_max_body_size 20M;";
};
locations."/manage" = lib.mkIf cfg.manage.enable {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
basicAuth = "gallery";
basicAuthFile = cfg.nginx.htpasswdFile;
};
};
};
})
+19 -2
View File
@@ -18,26 +18,43 @@ type Server struct {
contentDir string
outputDir string
serve bool
manage bool
generator *Generator
}
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Server {
func NewServer(port, contentDir, outputDir string, serve, manage bool, g *Generator) *Server {
return &Server{
port: port,
contentDir: contentDir,
outputDir: outputDir,
serve: serve,
manage: manage,
generator: g,
}
}
func (s *Server) Listen() error {
// routes builds the handler. The /manage pages are only registered with
// -manage, so a service without the flag has no edit or delete code path at
// all and answers 404 there.
func (s *Server) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /upload", s.handleUploadPage)
mux.HandleFunc("POST /upload", s.handleUpload)
if s.manage {
mux.HandleFunc("GET /manage", s.handleManagePage)
mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage)
mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit)
mux.HandleFunc("GET /manage/delete/{slug}", s.handleDeletePage)
mux.HandleFunc("POST /manage/delete/{slug}", s.handleDelete)
}
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
return mux
}
func (s *Server) Listen() error {
mux := s.routes()
if s.serve {
// Serve the generated site on the same port — for local development only.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

+193
View File
@@ -0,0 +1,193 @@
// Navigation for the single photo page: arrow keys and touch swipe.
//
// Both read their destinations from the neighbour links the template already
// renders, so a photo with no newer/older sibling simply has nothing to find
// and the gesture rubber-bands instead of navigating. If this file fails to
// load the page still works — the links remain clickable.
(function () {
'use strict';
var newer = document.querySelector('[data-nav="newer"]');
var older = document.querySelector('[data-nav="older"]');
// ── Keyboard ──────────────────────────────────────────────────────────
document.addEventListener('keydown', function (e) {
// Alt+Arrow is back/forward in most browsers; don't shadow it.
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
if (e.target instanceof Element && e.target.matches('input, textarea')) return;
if (e.key === 'ArrowLeft' && newer) location.href = newer.href;
if (e.key === 'ArrowRight' && older) location.href = older.href;
});
// ── Swipe ─────────────────────────────────────────────────────────────
var wrap = document.querySelector('.dimg-wrap');
var img = wrap && wrap.querySelector('img');
if (!wrap || !img) return;
var EDGE_DEAD_ZONE = 24; // px from either screen edge, left to the browser
var AXIS_LOCK = 10; // px of travel before we decide scroll vs swipe
var MIN_TRAVEL = 60; // px needed to commit
var TRAVEL_RATIO = 0.18; // ...or this fraction of the viewport, whichever is more
var MIN_VELOCITY = 0.5; // px/ms; a fast flick commits below MIN_TRAVEL
var VELOCITY_WINDOW = 30; // ms between velocity samples
var RESISTANCE = 0.35; // drag factor when there is no neighbour that way
var FADE = 0.35; // how far the image dims across a full-width drag
var EXIT_MS = 180; // keep in sync with the CSS transition duration
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var pointerId = null;
var startX = 0, startY = 0;
var prevX = 0, prevT = 0;
var dx = 0;
var locked = false; // horizontal drag in progress
var abandoned = false; // decided this gesture was a vertical scroll
var leaving = false; // committed, animating out
var swiped = false; // suppress the click that trails a swipe
// Swipe left (content moves left, the thing to the right comes in) → older.
// Matches ArrowRight above.
function targetFor(delta) {
return delta < 0 ? older : newer;
}
function paint() {
var shift = targetFor(dx) ? dx : dx * RESISTANCE;
var progress = Math.min(Math.abs(shift) / wrap.clientWidth, 1);
img.style.transform = 'translate3d(' + shift + 'px, 0, 0)';
img.style.opacity = String(1 - progress * FADE);
}
function release() {
pointerId = null;
locked = false;
abandoned = false;
dx = 0;
}
function snapBack() {
wrap.classList.remove('dragging');
wrap.classList.add('animating');
img.style.transform = '';
img.style.opacity = '';
window.setTimeout(function () {
wrap.classList.remove('animating');
}, EXIT_MS);
release();
}
function commit(target) {
if (reduceMotion) {
location.href = target.href;
return;
}
leaving = true;
wrap.classList.remove('dragging');
wrap.classList.add('animating');
img.style.transform =
'translate3d(' + (dx < 0 ? -wrap.clientWidth : wrap.clientWidth) + 'px, 0, 0)';
img.style.opacity = '0';
var went = false;
function go() {
if (went) return;
went = true;
location.href = target.href;
}
// transitionend does not fire if the tab is backgrounded mid-animation.
img.addEventListener('transitionend', go, { once: true });
window.setTimeout(go, EXIT_MS + 70);
release();
}
wrap.addEventListener('pointerdown', function (e) {
// A click can only follow a pointerdown, so clearing here is enough to
// keep a genuine tap working after an earlier swipe snapped back.
swiped = false;
if (leaving) return;
if (pointerId !== null) return; // already tracking a finger
if (e.pointerType !== 'touch') return; // leave mouse drag-to-save alone
if (!e.isPrimary) return; // second finger: pinch, not swipe
if (e.clientX < EDGE_DEAD_ZONE ||
e.clientX > window.innerWidth - EDGE_DEAD_ZONE) return;
pointerId = e.pointerId;
startX = prevX = e.clientX;
startY = e.clientY;
prevT = e.timeStamp;
dx = 0;
locked = false;
abandoned = false;
});
wrap.addEventListener('pointermove', function (e) {
if (e.pointerId !== pointerId || abandoned) return;
dx = e.clientX - startX;
var dy = e.clientY - startY;
if (!locked) {
if (Math.abs(dx) < AXIS_LOCK && Math.abs(dy) < AXIS_LOCK) return;
if (Math.abs(dy) >= Math.abs(dx)) {
abandoned = true; // vertical: hand the gesture back to the scroller
return;
}
locked = true;
wrap.classList.remove('animating');
wrap.classList.add('dragging');
wrap.setPointerCapture(pointerId);
}
if (e.cancelable) e.preventDefault();
if (e.timeStamp - prevT > VELOCITY_WINDOW) {
prevX = e.clientX;
prevT = e.timeStamp;
}
paint();
}, { passive: false });
wrap.addEventListener('pointerup', function (e) {
if (e.pointerId !== pointerId) return;
if (!locked) {
release();
return;
}
swiped = true;
var target = targetFor(dx);
var elapsed = e.timeStamp - prevT;
var velocity = elapsed > 0 ? (e.clientX - prevX) / elapsed : 0;
var far = Math.abs(dx) > Math.max(MIN_TRAVEL, window.innerWidth * TRAVEL_RATIO);
// A flick only counts if it is still moving the way the drag went.
var flicked = Math.abs(velocity) > MIN_VELOCITY && (velocity < 0) === (dx < 0);
if (target && (far || flicked)) {
commit(target);
} else {
snapBack();
}
});
wrap.addEventListener('pointercancel', function (e) {
if (e.pointerId !== pointerId) return;
if (locked) snapBack();
else release();
});
// The image sits inside an <a> to the full-size original. Without this a
// swipe that ends over it opens that link in a new tab.
wrap.addEventListener('click', function (e) {
if (!swiped) return;
e.preventDefault();
e.stopPropagation();
}, true);
})();
+74
View File
@@ -130,6 +130,16 @@ body {
color: var(--fg2);
}
/* Divider between adjacent header links, e.g. GRID | RSS FEED. The pipe is a
flex child of the second link, so its right margin adds to that link's
gap:5px — 9px + 5px matches the 14px left margin for even spacing. */
.rss-a + .rss-a::before {
content: "|";
margin-left: 14px;
margin-right: 9px;
color: var(--fg4);
}
/* ── Photo grid ────────────────────────────────────────────── */
.grid-wrap {
@@ -187,6 +197,43 @@ body {
.pager-newer { margin-right: auto; }
.pager-older { margin-left: auto; }
/* ── Timeline ──────────────────────────────────────────────── */
.tl-day {
margin-bottom: 34px;
}
.tl-date {
display: flex;
align-items: baseline;
gap: 10px;
font-family: monospace;
font-size: 15px;
font-weight: 600;
color: var(--fg2);
letter-spacing: 0.04em;
padding: 0 3px 10px;
margin-bottom: 3px;
border-bottom: 1px solid var(--line);
}
.tl-date a {
color: inherit;
text-decoration: none;
}
.tl-date a:hover {
color: var(--aqua);
}
.tl-count {
font-family: monospace;
font-size: 11px;
font-weight: 400;
color: var(--fg4);
letter-spacing: 0.04em;
}
/* ── Single photo page ─────────────────────────────────────── */
.photo-page {
@@ -248,6 +295,33 @@ body {
height: auto;
}
/* Swipe navigation. pan-y claims the horizontal axis for the gesture handler
while leaving vertical scrolling native. will-change is scoped to the two
transient states so the image is not permanently promoted to its own layer. */
.dimg-wrap {
touch-action: pan-y;
}
.dimg-wrap.dragging {
user-select: none;
-webkit-user-select: none;
}
.dimg-wrap.dragging img {
transition: none;
will-change: transform;
}
.dimg-wrap.animating img {
transition: transform 180ms ease-out, opacity 180ms ease-out;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.dimg-wrap.animating img { transition: none; }
}
.dsidebar {
width: 220px;
flex-shrink: 0;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Some files were not shown because too many files have changed in this diff Show More