Files
photos/README.md
T
nils 46dd8df55c
ci / docker (push) Successful in 17s
Album filters: star threshold as interactive stars control
Replaces the three fixed star chips with the Stars component as a
min-average selector, combinable with the verdict chips
2026-07-17 18:59:26 +02:00

192 lines
9.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Photos
Self-hosted client gallery for photographers. Upload RAWs/JPGs into albums,
share them with clients via private (optionally password-protected) links,
collect accept/reject votes, ratings and tags, and let clients download
originals.
## Screenshots
The album view — drag-and-drop upload, justified gallery, and each client's
feedback (votes, stars, tags) overlaid on the thumbnails:
![Album view with upload zone and feedback overlays](docs/screenshots/album-admin.jpg)
What clients see on a share link — vote on favorites, filter by verdict,
download selects or the whole album as a ZIP:
![Client gallery with verdict filters](docs/screenshots/share-client.jpg)
The lightbox — accept/reject, star rating, tags, and keyboard-driven culling
(`P`/`X`/`U`, `1``5`):
![Lightbox with voting and rating controls](docs/screenshots/lightbox.jpg)
## Architecture
```
┌─────────────┐
browser ── ingress ──► │ api (Rust) │ ──► Postgres (data + job queue)
│ axum │ ──► S3 (originals, previews, thumbs)
└─────────────┘
┌─────────────┐
│ worker(s) │ ◄── polls jobs table (SKIP LOCKED)
│ exiftool + │ ──► renders preview (2048px) + thumb
│ image crate │ (512px) JPEGs into S3
└─────────────┘
```
- **Backend**: Rust (axum, sqlx). Two binaries from one crate: `server` (API +
serves the built frontend) and `worker` (job processor).
- **Job queue**: plain Postgres table claimed with `FOR UPDATE SKIP LOCKED`,
with retries and exponential backoff. Job kinds: `process_photo`,
`delete_s3_prefix`.
- **RAW handling**: the worker extracts the camera's embedded JPEG preview via
`exiftool` (fast, matches in-camera rendering), applies EXIF orientation,
and resizes. Originals are always stored and downloadable untouched.
- **Storage layout**: `photos/<photo_id>/original/<filename>`,
`photos/<photo_id>/preview.jpg`, `photos/<photo_id>/thumb.jpg`. The bucket
stays fully private; all image traffic is streamed through the API with
auth checks (no bucket CORS or public access needed).
- **Auth**: photographer signs in via any OIDC provider (authorization-code
flow + userinfo); only emails in `ALLOWED_EMAILS` may sign in, and sessions
are re-checked against the allowlist on every request, so removing an email
revokes access immediately. Clients use unguessable share tokens, optionally
gated by an argon2-hashed password (10 wrong guesses lock the link for
15 minutes).
- **Frontend**: React + Vite SPA — justified gallery, lightbox with
accept/reject thumbs, rating stars and tag chips, keyboard-driven culling
(`P`/`X`/`U`, `1``5`, `?` shows all shortcuts), drag-and-drop multi-file
upload with progress. Touch-first on mobile: swipe sideways to browse,
flick a photo up/down to accept/reject (real physics — votes commit
instantly, the animation is decoration), pinch to zoom, always-visible
selection checkmarks. Voting the last photo fades back to the gallery.
## Local development
Requirements: Rust (rustup — the pinned toolchain in `rust-toolchain.toml`
installs automatically), Node 20+, `exiftool`, Docker.
```sh
docker compose up -d # Postgres + MinIO (bucket auto-created)
cp .env.example .env # then edit OIDC_* and ALLOWED_EMAILS
set -a; source .env; set +a
cargo run --bin server # API on :8080 (runs migrations on start)
cargo run --bin worker # job worker (separate terminal, same env)
cd frontend && npm install && npm run dev # UI on :5173, proxies /api
```
Register the OIDC client with redirect URI `<PUBLIC_URL>/api/auth/callback`
(locally: `http://localhost:5173/api/auth/callback`). Any standard OIDC
provider works (Authentik, Keycloak, Zitadel, Dex, ...); the app uses
discovery, so only the issuer URL is configured.
## Configuration
All configuration is via environment variables:
| Variable | Required | Description |
| --- | --- | --- |
| `DATABASE_URL` | yes | Postgres connection string |
| `PUBLIC_URL` | yes | External base URL, e.g. `https://photos.example.com` |
| `SESSION_SECRET` | yes | ≥32 chars; signs session/share cookies |
| `S3_BUCKET` | yes | Bucket name |
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | yes | S3 credentials |
| `S3_ENDPOINT` | no | Set for MinIO/Ceph/etc.; empty = AWS S3 |
| `S3_REGION` | no | Default `us-east-1` |
| `S3_FORCE_PATH_STYLE` | no | `true` for MinIO |
| `OIDC_ISSUER` | yes | Issuer URL (discovery is fetched from it) |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | yes | OIDC client credentials |
| `ALLOWED_EMAILS` | yes | Comma-separated photographer emails |
| `BIND_ADDR` | no | Default `0.0.0.0:8080` |
| `STATIC_DIR` | no | Built frontend dir (default `frontend/dist`) |
| `WORKER_CONCURRENCY` | no | Parallel jobs per worker pod (default 2) |
| `RUST_LOG` | no | e.g. `info,sqlx=warn` |
| `DEV_AUTOLOGIN_EMAIL` | no | **Dev only**: skip OIDC and sign in as this email (must also be in `ALLOWED_EMAILS`). The server refuses to start with this set when `PUBLIC_URL` is https. |
## Deploying to Kubernetes
The prebuilt image is at `git.draic.info/nils/photos` (single image contains
`server`, `worker`, and the built frontend). To build your own instead:
```sh
docker build -t registry.example.com/you/photos:0.4.3 .
docker push registry.example.com/you/photos:0.4.3
```
Install the chart, pointing it at your existing Postgres and S3:
```sh
helm install photos deploy/chart \
--set image.repository=git.draic.info/nils/photos \
--set image.tag=0.4.3 \
--set publicUrl=https://photos.example.com \
--set ingress.host=photos.example.com \
--set config.oidcIssuer=https://auth.example.com \
--set config.allowedEmails=you@example.com \
--set config.s3.bucket=photos \
--set config.s3.endpoint=https://s3.example.com \
--set config.s3.forcePathStyle=true \
--set secrets.databaseUrl=postgres://... \
--set secrets.s3AccessKey=... \
--set secrets.s3SecretKey=... \
--set secrets.oidcClientId=photos \
--set secrets.oidcClientSecret=... \
--set secrets.sessionSecret=$(openssl rand -hex 32)
```
For production prefer a values file, or create the Secret yourself and set
`secrets.existingSecret` (keys: `DATABASE_URL`, `S3_ACCESS_KEY`,
`S3_SECRET_KEY`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `SESSION_SECRET`).
Notes:
- The chart defaults to Traefik, which needs no special config (no body-size
limit, streaming by default). For ingress-nginx, set `ingress.className:
nginx` and the commented `proxy-body-size`/`proxy-request-buffering`
annotations in values.yaml — nginx's 1MiB default otherwise rejects RAW
uploads.
- Migrations run automatically on startup of either binary (they take a
Postgres advisory lock, so concurrent starts are safe).
- Scale `worker.replicas` (or `worker.concurrency`) if imports queue up;
the queue is safe for any number of workers.
## How sharing works
- Each album can have any number of share links (`/s/<24-char-token>`), each
with its own label (e.g. the client's name), optional password, optional
expiry, and a per-link download toggle.
- Accept/reject votes (👍/👎), ratings (15 stars) and free-form tags are
stored **per link**, so create one link per client to keep feedback
separate. The album view shows all feedback grouped by link label, and
clients can filter their gallery by verdict (e.g. review only what's still
undecided, or select-all + download the accepted set). Culling works with
keyboard (`P`/`X` auto-advance) on desktop and swipe gestures on mobile.
- Clients (and you) can multi-select photos and download them — or the whole
album — as a ZIP. Archives are streamed (each file spools briefly through a
temp file for its checksum, then pipelines while the next one prefetches),
stored uncompressed with real capture-date timestamps, and are fully
spec-compliant — they extract with strict streaming readers (Java
`ZipInputStream`, piped `bsdtar`) as well as Finder/Explorer/unzip/7-Zip.
Responses carry an exact `Content-Length`, so browsers show progress and
flag interrupted downloads as failed. Concurrent zip streams are capped
at 4.
- 10 wrong passwords lock a link for 15 minutes (fresh attempts after the
window). A locked link shows in the album's share list with an Unlock
button.
- Deleting a link removes its votes/ratings/tags; deleting photos or albums
cleans up S3 objects via background jobs.
## Known limitations / deliberate v1 cuts
- Full RAW develop fallback for files whose embedded preview is tiny
(exceedingly rare on modern cameras; `darktable-cli` in the worker image
would cover it).
- Multiple photographer accounts with separate libraries (any allowed email
sees everything).
- No S3 orphan sweeper: a crash in the narrow window between an upload's S3
put and its DB commit can leave an unreferenced original in the bucket
(never data loss — just unclaimed storage).