nils 30d0b3064e
ci / docker (push) Successful in 13s
Client accept/reject votes with keyboard-driven culling
- verdicts table (per link, like ratings), PUT verdict endpoint, typed Verdict enum
- share page: thumbs up/down, verdict filter with counts, view-scoped selection bar
- lightbox: per-page shortcut table (P/X/U, 1-5/0, S), ? help overlay, action
  toast when a keyboard vote auto-advances
- shared useLightbox hook; single keydown subscription reading live state via ref
- album view: per-link thumbs and vote counts; share list shows accept/reject totals
- feedback queries deduped and run concurrently; verdict counts in one scan
2026-07-17 15:55:46 +02:00

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.

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, 15, ? shows all shortcuts), 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

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: 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).
  • 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).
S
Description
No description provided
Readme
3.3 MiB
Languages
Rust 53.8%
JavaScript 37%
CSS 8.2%
Go Template 0.5%
Dockerfile 0.4%
Other 0.1%