- images.rs: scope /api/img and /api/photos/{id}/original by album owner —
close the cross-tenant original/thumbnail leak (tenancy test now covers
these routes)
- migration 0003: refuse to run when albums exist and users != 1 instead of
silently reassigning every album to the oldest user
- Gallery: callback-ref ResizeObserver so a gallery mounted empty still
lays out once photos arrive (was permanently blank)
- upload dedup: re-uploading identical content whose photo is in 'error'
resets and re-enqueues it instead of returning the broken row
- client hashing: skip (and fall back to plain upload) above 512MB to avoid
whole-file arrayBuffer OOM / the ~2GiB cap
- zip: always spool each entry (no unread prefetched S3 body held across a
slow client stream) and backfill BOTH sha256 and crc32 for legacy photos
- tests/auth: share one session_payload builder instead of re-implementing
the cookie format in the test and mint_session
Photos
Self-hosted client gallery for photographers. Upload RAWs/JPGs into albums, share them with clients via private (optionally password-protected) links, collect ratings and tags, and let clients download originals.
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) andworker(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: photographers sign in via any OIDC provider (authorization-code
flow + userinfo); only emails in
ALLOWED_EMAILSmay sign in, and sessions are re-checked against the allowlist on every request, so removing an email revokes access immediately. Multi-tenant: each allowed email is its own workspace — albums, photos, and share links are owned per photographer and invisible to the others (enforced via ownership-scoped data access and covered by the tenant-isolation test matrix). 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 rating stars and tag chips, drag-and-drop multi-file upload with progress.
Local development
Requirements: Rust (rustup — the pinned toolchain in rust-toolchain.toml
installs automatically), Node 20+, exiftool, Docker.
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
Tests (the tenant-isolation matrix needs a disposable database):
createdb photos_test # or: docker compose exec postgres createdb -U photos photos_test
TEST_DATABASE_URL=postgres://photos:photos@localhost:5432/photos_test cargo test
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
Build and push the image (single image contains server, worker, and the
built frontend):
docker build -t ghcr.io/YOU/photos:0.1.0 .
docker push ghcr.io/YOU/photos:0.1.0
Install the chart, pointing it at your existing Postgres and S3:
helm install photos deploy/chart \
--set image.repository=ghcr.io/YOU/photos \
--set image.tag=0.1.0 \
--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: nginxand the commentedproxy-body-size/proxy-request-bufferingannotations 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(orworker.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. - Ratings (1–5 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.
- 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, pipedbsdtar) as well as Finder/Explorer/unzip/7-Zip. Responses carry an exactContent-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 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-cliin the worker image would cover it). - 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).