Initial release: self-hosted client photo gallery
ci / docker (push) Successful in 13s

Rust (axum + sqlx) API and worker sharing a Postgres-backed job queue
(SKIP LOCKED, heartbeat, reaper, typed statuses), S3 storage with derived
keys and a fully private bucket, OIDC photographer login with per-request
allowlist checks, client share links with argon2 passwords and lockout,
cookie-based image authorization with sliding expiry, hand-rolled
spec-compliant streaming ZIP downloads with exact Content-Length,
React + Vite gallery frontend, single Docker image, Helm chart for
external S3 + Postgres, and Gitea CI.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-17 13:12:42 +02:00
co-authored by Claude
commit 16d2a56a78
55 changed files with 11962 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
target
frontend/node_modules
frontend/dist
deploy
.git
.env
*.md
+25
View File
@@ -0,0 +1,25 @@
# Copy to .env and `set -a; source .env; set +a` (or use direnv) for local dev.
DATABASE_URL=postgres://photos:photos@localhost:5432/photos
BIND_ADDR=127.0.0.1:8080
# For local dev this is the Vite dev server; OIDC redirect URI must match <PUBLIC_URL>/api/auth/callback
PUBLIC_URL=http://localhost:5173
SESSION_SECRET=change-me-to-a-long-random-string-min-32-chars
S3_BUCKET=photos
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_FORCE_PATH_STYLE=true
OIDC_ISSUER=https://auth.example.com
OIDC_CLIENT_ID=photos
OIDC_CLIENT_SECRET=change-me
ALLOWED_EMAILS=you@example.com
# DEV ONLY: skip OIDC and sign straight in as this email. Never set in production.
# DEV_AUTOLOGIN_EMAIL=you@example.com
WORKER_CONCURRENCY=2
RUST_LOG=info,sqlx=warn
+43
View File
@@ -0,0 +1,43 @@
name: ci
on:
push:
branches:
- main
- staging
tags:
- '*'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Registry
uses: docker/login-action@v2
with:
registry: ${{ vars.REGISTRY_URL }}
username: ${{ vars.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: remote
endpoint: ${{ env.BUILDKIT_ARM64_ENDPOINT }}
# The image contains both the Rust backend (server + worker binaries)
# and the built frontend — see the multi-stage Dockerfile.
- name: Build and Push Docker Image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
push: true
provenance: false
tags: |
${{ vars.REGISTRY_URL }}/${{ gitea.repository }}:${{ gitea.ref_type == 'tag' && gitea.ref_name || (gitea.ref_name == 'main' && 'latest' || gitea.ref_name) }}
${{ vars.REGISTRY_URL }}/${{ gitea.repository }}:${{ gitea.sha }}
+5
View File
@@ -0,0 +1,5 @@
/target
node_modules
frontend/dist
.env
.DS_Store
Generated
+4697
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
[package]
name = "photos"
version = "0.1.0"
edition = "2021"
[lib]
name = "photos"
path = "src/lib.rs"
[[bin]]
name = "server"
path = "src/bin/server.rs"
[[bin]]
name = "worker"
path = "src/bin/worker.rs"
[dependencies]
anyhow = "1"
argon2 = "0.5"
crc32fast = "1"
aws-config = { version = "1", features = ["behavior-version-latest"] }
aws-sdk-s3 = "1"
axum = { version = "0.8", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie", "cookie-signed"] }
chrono = { version = "0.4", features = ["serde"] }
futures = "0.3"
image = "0.25"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "migrate"] }
tempfile = "3"
time = "0.3"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
tower-http = { version = "0.6", features = ["fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
urlencoding = "2"
uuid = { version = "1", features = ["v4", "serde"] }
[dev-dependencies]
cookie = { version = "0.18", features = ["signed"] }
[profile.release]
lto = "thin"
+30
View File
@@ -0,0 +1,30 @@
# ---- frontend ----
FROM node:22-alpine AS frontend
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# ---- backend ----
FROM rust:1-bookworm AS backend
WORKDIR /app
COPY Cargo.toml Cargo.lock* ./
COPY src ./src
COPY migrations ./migrations
RUN cargo build --release --bins
# ---- runtime (shared by api and worker) ----
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends exiftool ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --system --uid 1000 photos
WORKDIR /app
COPY --from=backend /app/target/release/server /app/target/release/worker /usr/local/bin/
COPY --from=frontend /app/dist /app/static
ENV STATIC_DIR=/app/static
USER photos
EXPOSE 8080
# The worker deployment overrides this with: command ["worker"]
CMD ["server"]
+165
View File
@@ -0,0 +1,165 @@
# 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) 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 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.
```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
Build and push the image (single image contains `server`, `worker`, and the
built frontend):
```sh
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:
```sh
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.
- 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.
- 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 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).
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: photos
description: Self-hosted client photo gallery (Rust API + worker, React frontend)
type: application
version: 0.1.0
appVersion: "0.1.0"
+42
View File
@@ -0,0 +1,42 @@
{{- define "photos.fullname" -}}
{{- if contains "photos" .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-photos" .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "photos.labels" -}}
app.kubernetes.io/name: photos
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "photos.secretName" -}}
{{- if .Values.secrets.existingSecret -}}
{{- .Values.secrets.existingSecret -}}
{{- else -}}
{{- include "photos.fullname" . -}}
{{- end -}}
{{- end -}}
{{- define "photos.env" -}}
- name: PUBLIC_URL
value: {{ .Values.publicUrl | quote }}
- name: S3_BUCKET
value: {{ .Values.config.s3.bucket | quote }}
- name: S3_REGION
value: {{ .Values.config.s3.region | quote }}
{{- if .Values.config.s3.endpoint }}
- name: S3_ENDPOINT
value: {{ .Values.config.s3.endpoint | quote }}
{{- end }}
- name: S3_FORCE_PATH_STYLE
value: {{ .Values.config.s3.forcePathStyle | quote }}
- name: OIDC_ISSUER
value: {{ .Values.config.oidcIssuer | quote }}
- name: ALLOWED_EMAILS
value: {{ .Values.config.allowedEmails | quote }}
- name: RUST_LOG
value: {{ .Values.config.logLevel | quote }}
{{- end -}}
@@ -0,0 +1,47 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "photos.fullname" . }}-api
labels:
{{- include "photos.labels" . | nindent 4 }}
app.kubernetes.io/component: api
spec:
replicas: {{ .Values.api.replicas }}
selector:
matchLabels:
app.kubernetes.io/name: photos
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: api
template:
metadata:
labels:
{{- include "photos.labels" . | nindent 8 }}
app.kubernetes.io/component: api
spec:
containers:
- name: api
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["server"]
ports:
- name: http
containerPort: 8080
env:
{{- include "photos.env" . | nindent 12 }}
- name: BIND_ADDR
value: "0.0.0.0:8080"
envFrom:
- secretRef:
name: {{ include "photos.secretName" . }}
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 3
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
resources:
{{- toYaml .Values.api.resources | nindent 12 }}
@@ -0,0 +1,34 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "photos.fullname" . }}-worker
labels:
{{- include "photos.labels" . | nindent 4 }}
app.kubernetes.io/component: worker
spec:
replicas: {{ .Values.worker.replicas }}
selector:
matchLabels:
app.kubernetes.io/name: photos
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: worker
template:
metadata:
labels:
{{- include "photos.labels" . | nindent 8 }}
app.kubernetes.io/component: worker
spec:
containers:
- name: worker
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["worker"]
env:
{{- include "photos.env" . | nindent 12 }}
- name: WORKER_CONCURRENCY
value: {{ .Values.worker.concurrency | quote }}
envFrom:
- secretRef:
name: {{ include "photos.secretName" . }}
resources:
{{- toYaml .Values.worker.resources | nindent 12 }}
+33
View File
@@ -0,0 +1,33 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "photos.fullname" . }}
labels:
{{- include "photos.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "photos.fullname" . }}
port:
name: http
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
{{- if not .Values.secrets.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "photos.fullname" . }}
labels:
{{- include "photos.labels" . | nindent 4 }}
stringData:
DATABASE_URL: {{ required "secrets.databaseUrl (or secrets.existingSecret) is required" .Values.secrets.databaseUrl | quote }}
S3_ACCESS_KEY: {{ required "secrets.s3AccessKey is required" .Values.secrets.s3AccessKey | quote }}
S3_SECRET_KEY: {{ required "secrets.s3SecretKey is required" .Values.secrets.s3SecretKey | quote }}
OIDC_CLIENT_ID: {{ required "secrets.oidcClientId is required" .Values.secrets.oidcClientId | quote }}
OIDC_CLIENT_SECRET: {{ required "secrets.oidcClientSecret is required" .Values.secrets.oidcClientSecret | quote }}
SESSION_SECRET: {{ required "secrets.sessionSecret is required" .Values.secrets.sessionSecret | quote }}
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "photos.fullname" . }}
labels:
{{- include "photos.labels" . | nindent 4 }}
spec:
selector:
app.kubernetes.io/name: photos
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: api
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
+68
View File
@@ -0,0 +1,68 @@
image:
repository: ghcr.io/CHANGE-ME/photos
tag: latest
pullPolicy: IfNotPresent
api:
replicas: 1
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 1Gi
worker:
replicas: 1
concurrency: 2
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
memory: 2Gi
# External base URL of the app; the OIDC redirect URI is <publicUrl>/api/auth/callback
publicUrl: https://photos.example.com
config:
s3:
# Leave endpoint empty for AWS S3; set for MinIO/Ceph/etc.
endpoint: ""
region: us-east-1
bucket: photos
forcePathStyle: false
oidcIssuer: https://auth.example.com
# Comma-separated photographer emails allowed to sign in
allowedEmails: you@example.com
logLevel: info,sqlx=warn
# Sensitive settings. Either reference an existing Secret containing the keys
# DATABASE_URL, S3_ACCESS_KEY, S3_SECRET_KEY, OIDC_CLIENT_ID,
# OIDC_CLIENT_SECRET, SESSION_SECRET — or inline the values and the chart
# creates the Secret for you.
secrets:
existingSecret: ""
databaseUrl: ""
s3AccessKey: ""
s3SecretKey: ""
oidcClientId: ""
oidcClientSecret: ""
sessionSecret: ""
service:
port: 80
ingress:
enabled: true
className: traefik
host: photos.example.com
# Traefik needs nothing extra: no default body-size limit, streams uploads.
# For ingress-nginx set className: nginx and uncomment (multi-GB raw
# uploads hit nginx's 1MiB default limit otherwise):
# nginx.ingress.kubernetes.io/proxy-body-size: "0"
# nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
annotations: {}
tls:
enabled: true
secretName: photos-tls
+40
View File
@@ -0,0 +1,40 @@
# Local development dependencies only (Postgres + MinIO).
# Run the app itself with `cargo run --bin server` / `cargo run --bin worker`
# and `npm run dev` in frontend/ (see README).
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: photos
POSTGRES_PASSWORD: photos
POSTGRES_DB: photos
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- miniodata:/data
minio-init:
image: minio/mc
depends_on:
- minio
entrypoint: >
/bin/sh -c "
until mc alias set local http://minio:9000 minioadmin minioadmin; do sleep 1; done;
mc mb -p local/photos;
exit 0"
volumes:
pgdata:
miniodata:
+29
View File
@@ -0,0 +1,29 @@
//! Dev utility: mint a signed session cookie without going through OIDC.
//! Anyone holding SESSION_SECRET can forge sessions anyway; this just makes
//! local API testing possible before an IdP is wired up.
//!
//! Usage: SESSION_SECRET=... cargo run --example mint_session -- <user-uuid> <email>
use cookie::{Cookie, CookieJar, Key};
use sha2::{Digest, Sha512};
fn main() {
let mut args = std::env::args().skip(1);
let user_id = args.next().expect("usage: mint_session <user-uuid> <email>");
// Sessions are only honored for lowercased emails present in ALLOWED_EMAILS.
let email = args
.next()
.expect("usage: mint_session <user-uuid> <email>")
.to_lowercase();
let secret = std::env::var("SESSION_SECRET").expect("SESSION_SECRET must be set");
let key = Key::from(&Sha512::digest(secret.as_bytes()));
let exp = chrono::Utc::now().timestamp() + 86400;
let mut jar = CookieJar::new();
jar.signed_mut(&key).add(Cookie::new(
"photos_session",
format!("{user_id}|{exp}|{email}"),
));
// The plain jar now holds the signed on-wire value.
let cookie = jar.get("photos_session").unwrap();
println!("photos_session={}", cookie.value());
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>Photos</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1761
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "photos-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.11"
}
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { Link, Route, Routes } from 'react-router-dom'
import { api } from './api'
import AlbumsPage from './pages/AlbumsPage'
import AlbumPage from './pages/AlbumPage'
import SharePage from './pages/SharePage'
function AdminLayout({ children }) {
const [me, setMe] = useState(undefined) // undefined=loading, null=logged out
useEffect(() => {
api('/api/me')
.then(setMe)
.catch(() => setMe(null))
}, [])
if (me === undefined) return <div className="center-page">Loading</div>
if (me === null) {
const authError = new URLSearchParams(window.location.search).get('auth_error')
return (
<div className="center-page">
<div className="login-card">
<h1>Photos</h1>
<p>Photographer sign-in</p>
<a className="btn btn-primary" href="/api/auth/login">
Sign in
</a>
{authError && <p className="error">{authError}</p>}
</div>
</div>
)
}
return (
<>
<header className="topbar">
<Link to="/" className="brand">
Photos
</Link>
<span className="topbar-right">
<span className="muted">{me.email}</span>
<button
className="btn btn-ghost"
onClick={async () => {
await api('/api/auth/logout', { method: 'POST' })
setMe(null)
}}
>
Sign out
</button>
</span>
</header>
<main className="page">{children}</main>
</>
)
}
export default function App() {
return (
<Routes>
<Route path="/s/:token" element={<SharePage />} />
<Route
path="/"
element={
<AdminLayout>
<AlbumsPage />
</AdminLayout>
}
/>
<Route
path="/albums/:id"
element={
<AdminLayout>
<AlbumPage />
</AdminLayout>
}
/>
<Route path="*" element={<div className="center-page">Not found</div>} />
</Routes>
)
}
+100
View File
@@ -0,0 +1,100 @@
export async function api(path, opts = {}) {
const { body, ...rest } = opts
const res = await fetch(path, {
...rest,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
let message = res.statusText
try {
message = (await res.json()).error || message
} catch {
/* not json */
}
const err = new Error(message)
err.status = res.status
throw err
}
if (res.status === 204) return null
return res.json()
}
// Image URL with a cache-buster tied to the last processing run, so
// reprocessed photos bypass the long-lived immutable browser cache.
// Auth rides on cookies (session or share-access), never in the URL.
export function imgUrl(photo, size) {
const version = photo.processed_at ? `?v=${encodeURIComponent(photo.processed_at)}` : ''
return `/api/img/${photo.id}/${size}${version}`
}
// Trigger a browser-native download from a POST endpoint (e.g. zip streams)
// via a hidden form — fetch+blob would buffer the whole file in memory.
// Targets a hidden iframe so an error response can't navigate away from the
// app (which would lose selection/rating state); errors surface as an alert.
export function postDownload(url, ids = '') {
let frame = document.getElementById('download-frame')
if (!frame) {
frame = document.createElement('iframe')
frame.id = 'download-frame'
frame.name = 'download-frame'
frame.style.display = 'none'
document.body.appendChild(frame)
}
frame.onload = () => {
// load only fires when the response rendered (i.e. an error body);
// successful attachment downloads never trigger it.
let message = 'download failed'
try {
const text = frame.contentDocument?.body?.textContent
if (!text) return
try {
message = JSON.parse(text).error || message
} catch {
/* not json */
}
} catch {
return
}
alert(`Download failed: ${message}`)
}
const form = document.createElement('form')
form.method = 'POST'
form.action = url
form.target = 'download-frame'
form.style.display = 'none'
const input = document.createElement('input')
input.type = 'hidden'
input.name = 'ids'
input.value = ids
form.appendChild(input)
document.body.appendChild(form)
form.submit()
form.remove()
}
export function uploadFile(url, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', url)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total)
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
let message = `upload failed (${xhr.status})`
try {
message = JSON.parse(xhr.responseText).error || message
} catch {
/* not json */
}
reject(new Error(message))
}
}
xhr.onerror = () => reject(new Error('network error during upload'))
xhr.send(file)
})
}
+42
View File
@@ -0,0 +1,42 @@
import { imgUrl } from '../api'
// Justified gallery: rows are built with flexbox, each tile's flex-grow is
// proportional to its aspect ratio so rows fill the container edge to edge.
// When `selected`/`onToggleSelect` are provided, tiles get a select checkmark;
// selection state lives in the parent so it survives lightbox open/close.
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
if (photos.length === 0) return null
const selecting = selected && selected.size > 0
return (
<div className={`gallery${selecting ? ' selecting' : ''}`}>
{photos.map((p, i) => {
const ar = p.width && p.height ? p.width / p.height : 1.5
const isSelected = selected ? selected.has(p.id) : false
return (
<div
key={p.id}
className={`g-item${isSelected ? ' selected' : ''}`}
style={{ '--ar': ar }}
onClick={() => onOpen && onOpen(i)}
>
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
{onToggleSelect && (
<button
className="g-check"
title={isSelected ? 'Deselect' : 'Select'}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(p.id)
}}
>
</button>
)}
{overlay && overlay(p)}
</div>
)
})}
<div className="g-spacer" />
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect } from 'react'
import { imgUrl } from '../api'
export default function Lightbox({ photos, index, onClose, onNav, footer }) {
const photo = photos[index]
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(index + 1)
if (e.key === 'ArrowLeft' && index > 0) onNav(index - 1)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [index, photos.length, onClose, onNav])
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = ''
}
}, [])
if (!photo) return null
return (
<div className="lightbox" onClick={onClose}>
<div className="lb-top" onClick={(e) => e.stopPropagation()}>
<span className="lb-name">{photo.filename}</span>
<span className="lb-count">
{index + 1} / {photos.length}
</span>
<button className="lb-btn" onClick={onClose} title="Close (Esc)">
</button>
</div>
<button
className="lb-nav lb-prev"
disabled={index === 0}
onClick={(e) => {
e.stopPropagation()
onNav(index - 1)
}}
>
</button>
<div className="lb-stage">
<img
className="lb-img"
src={imgUrl(photo, 'preview')}
alt={photo.filename}
onClick={(e) => e.stopPropagation()}
/>
</div>
<button
className="lb-nav lb-next"
disabled={index === photos.length - 1}
onClick={(e) => {
e.stopPropagation()
onNav(index + 1)
}}
>
</button>
{footer && (
<div className="lb-footer" onClick={(e) => e.stopPropagation()}>
{footer(photo)}
</div>
)}
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
export function fmtBytes(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.min(Math.floor(Math.log2(bytes) / 10), units.length - 1)
const value = bytes / 2 ** (10 * i)
return `${value >= 100 || i === 0 ? Math.round(value) : value.toFixed(1)} ${units[i]}`
}
export default function SelectionBar({
count,
total,
selectedBytes,
totalBytes,
onSelectAll,
onClear,
onDownload,
onDownloadAll,
}) {
if (total === 0) return null
if (count === 0) {
return (
<div className="select-bar">
<span className="select-count">
{total} photo{total === 1 ? '' : 's'} · {fmtBytes(totalBytes)}
</span>
<button className="btn btn-primary" onClick={onDownloadAll}>
Download all as ZIP
</button>
</div>
)
}
return (
<div className="select-bar">
<span className="select-count">
{count} of {total} selected
</span>
{count < total && (
<button className="btn" onClick={onSelectAll}>
Select all
</button>
)}
<button className="btn" onClick={onClear}>
Clear
</button>
<button className="btn btn-primary" onClick={onDownload}>
Download {count} as ZIP ({fmtBytes(selectedBytes)})
</button>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { useState } from 'react'
export default function Stars({ value = 0, onChange, small }) {
const [hover, setHover] = useState(0)
const shown = hover || value || 0
return (
<span
className={`stars${small ? ' stars-small' : ''}${onChange ? '' : ' stars-readonly'}`}
onMouseLeave={() => setHover(0)}
>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={n <= shown ? 'star filled' : 'star'}
disabled={!onChange}
onMouseEnter={() => onChange && setHover(n)}
onClick={(e) => {
e.stopPropagation()
// Clicking the current rating clears it.
onChange(n === value ? 0 : n)
}}
title={onChange ? `${n} star${n > 1 ? 's' : ''}` : undefined}
>
</button>
))}
</span>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from 'react'
export default function TagEditor({ tags, onChange }) {
const [input, setInput] = useState('')
const add = () => {
const tag = input.trim().toLowerCase()
setInput('')
if (tag && !tags.includes(tag)) onChange([...tags, tag])
}
return (
<div className="tag-editor">
{tags.map((tag) => (
<span key={tag} className="chip">
{tag}
<button
type="button"
onClick={() => onChange(tags.filter((t) => t !== tag))}
title="Remove tag"
>
×
</button>
</span>
))}
<input
value={input}
placeholder="add tag…"
maxLength={40}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add()
}
}}
onBlur={add}
/>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
+459
View File
@@ -0,0 +1,459 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, postDownload, uploadFile } from '../api'
import Gallery from '../components/Gallery'
import Lightbox from '../components/Lightbox'
import SelectionBar from '../components/SelectionBar'
import Stars from '../components/Stars'
import useSelection from '../useSelection'
const UPLOAD_CONCURRENCY = 3
function UploadZone({ albumId, onUploaded }) {
const [queue, setQueue] = useState([])
const [dragging, setDragging] = useState(false)
const inputRef = useRef(null)
const running = useRef(0)
const pending = useRef([])
const lastRefresh = useRef(0)
// Refresh the album at most every 5s during a bulk upload (the processing
// poll keeps it fresh anyway), plus once when the queue drains.
const refresh = useCallback(() => {
const drained = running.current === 0 && pending.current.length === 0
if (drained || Date.now() - lastRefresh.current > 5000) {
lastRefresh.current = Date.now()
onUploaded()
}
}, [onUploaded])
const pump = useCallback(() => {
while (running.current < UPLOAD_CONCURRENCY && pending.current.length > 0) {
const item = pending.current.shift()
running.current += 1
setQueue((q) =>
q.map((x) => (x.key === item.key ? { ...x, status: 'uploading' } : x)),
)
const url = `/api/albums/${albumId}/photos?filename=${encodeURIComponent(item.file.name)}`
uploadFile(url, item.file, (p) =>
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, progress: p } : x))),
)
.then(() =>
setQueue((q) =>
q.map((x) => (x.key === item.key ? { ...x, status: 'done', progress: 1 } : x)),
),
)
.catch((e) =>
setQueue((q) =>
q.map((x) => (x.key === item.key ? { ...x, status: 'error', error: e.message } : x)),
),
)
.finally(() => {
running.current -= 1
refresh()
pump()
})
}
}, [albumId, refresh])
const addFiles = (files) => {
const items = [...files].map((file, i) => ({
key: `${Date.now()}-${i}-${file.name}`,
file,
status: 'queued',
progress: 0,
}))
if (items.length === 0) return
setQueue((q) => [...q.filter((x) => x.status !== 'done'), ...items])
pending.current.push(...items)
pump()
}
return (
<div
className={`upload-zone${dragging ? ' dragging' : ''}`}
onDragOver={(e) => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
addFiles(e.dataTransfer.files)
}}
onClick={() => inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
multiple
hidden
onChange={(e) => {
addFiles(e.target.files)
e.target.value = ''
}}
/>
<p>Drop RAWs or JPGs here, or click to select</p>
{queue.length > 0 && (
<ul className="upload-list" onClick={(e) => e.stopPropagation()}>
{queue.map((item) => (
<li key={item.key} className={`upload-item ${item.status}`}>
<span className="upload-name">{item.file.name}</span>
{item.status === 'error' ? (
<span className="error">{item.error}</span>
) : (
<progress value={item.progress} max="1" />
)}
</li>
))}
</ul>
)}
</div>
)
}
function SharesPanel({ albumId }) {
const [shares, setShares] = useState([])
const [form, setForm] = useState({ label: '', password: '', allow_download: true, expires_at: '' })
const [error, setError] = useState(null)
const [copied, setCopied] = useState(null)
const load = useCallback(
() => api(`/api/albums/${albumId}/shares`).then(setShares).catch((e) => setError(e.message)),
[albumId],
)
useEffect(() => {
load()
}, [load])
const create = async (e) => {
e.preventDefault()
try {
await api(`/api/albums/${albumId}/shares`, {
method: 'POST',
body: {
label: form.label,
password: form.password || null,
allow_download: form.allow_download,
// End of the chosen day in the photographer's local timezone —
// date-only strings would parse as UTC midnight and expire a day early.
expires_at: form.expires_at
? new Date(`${form.expires_at}T23:59:59`).toISOString()
: null,
},
})
setForm({ label: '', password: '', allow_download: true, expires_at: '' })
setError(null)
load()
} catch (e) {
setError(e.message)
}
}
const copy = async (share) => {
await navigator.clipboard.writeText(share.url)
setCopied(share.id)
setTimeout(() => setCopied(null), 1500)
}
return (
<section className="panel">
<h2>Client links</h2>
{shares.length === 0 && <p className="muted">No links yet.</p>}
{shares.map((s) => (
<div key={s.id} className="share-row">
<div className="share-info">
<strong>
{s.label || 'unnamed link'}
{s.locked && <span className="error"> locked (too many wrong passwords)</span>}
</strong>
<span className="muted">
{s.has_password ? '🔒 password' : 'no password'}
{' · '}
{s.allow_download ? 'downloads on' : 'downloads off'}
{s.expires_at
? ` · expires ${new Date(s.expires_at).toLocaleDateString()}`
: ' · never expires'}
{' · '}
{s.rating_count} ratings, {s.tag_count} tags
</span>
</div>
<div className="row">
{s.locked && (
<button
className="btn"
onClick={async () => {
await api(`/api/shares/${s.id}/reset-lock`, { method: 'POST' })
load()
}}
>
Unlock
</button>
)}
<button className="btn" onClick={() => copy(s)}>
{copied === s.id ? 'Copied!' : 'Copy link'}
</button>
<button
className="btn btn-danger"
onClick={async () => {
if (!confirm(`Delete link "${s.label || s.token}"? Client ratings and tags from this link are removed too.`)) return
await api(`/api/shares/${s.id}`, { method: 'DELETE' })
load()
}}
>
Delete
</button>
</div>
</div>
))}
<form className="share-form" onSubmit={create}>
<input
placeholder="Label (e.g. client name)"
value={form.label}
onChange={(e) => setForm({ ...form, label: e.target.value })}
/>
<input
placeholder="Password (optional)"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<label className="row field-label">
<span className="muted">Expires</span>
<input
type="date"
value={form.expires_at}
onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
/>
{form.expires_at ? (
<button
type="button"
className="btn btn-ghost"
title="Remove expiry — link never expires"
onClick={() => setForm({ ...form, expires_at: '' })}
>
</button>
) : (
<span className="muted">never</span>
)}
</label>
<label className="row">
<input
type="checkbox"
checked={form.allow_download}
onChange={(e) => setForm({ ...form, allow_download: e.target.checked })}
/>
allow downloads
</label>
<button className="btn btn-primary" type="submit">
Create link
</button>
</form>
{error && <p className="error">{error}</p>}
</section>
)
}
export default function AlbumPage() {
const { id } = useParams()
const navigate = useNavigate()
const [detail, setDetail] = useState(null)
const [error, setError] = useState(null)
// Track the open photo by id, not index — the polling refetch can reorder
// the array underneath an open lightbox.
const [lightboxId, setLightboxId] = useState(null)
const load = useCallback(
() => api(`/api/albums/${id}`).then(setDetail).catch((e) => setError(e.message)),
[id],
)
useEffect(() => {
load()
}, [load])
// Poll while any photo is still being processed by the workers.
const hasPending = detail?.photos.some((p) => p.status === 'uploaded' || p.status === 'processing')
useEffect(() => {
if (!hasPending) return
const t = setInterval(load, 4000)
return () => clearInterval(t)
}, [hasPending, load])
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
const lightboxIndex = ready.findIndex((p) => p.id === lightboxId)
// If the open photo leaves the ready list (deleted elsewhere, reprocess),
// close for good — otherwise the lightbox would pop back open when the
// photo returns to ready.
useEffect(() => {
if (lightboxId && lightboxIndex < 0) setLightboxId(null)
}, [lightboxId, lightboxIndex])
if (error) return <p className="error">{error}</p>
if (!detail) return <p className="muted">Loading</p>
const { album, photos, feedback } = detail
const notReady = photos.filter((p) => p.status !== 'ready')
const avgRating = (photoId) => {
const ratings = feedback[photoId]?.ratings || []
if (ratings.length === 0) return null
return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length
}
const rename = async () => {
const name = prompt('Album name', album.name)
if (name && name.trim()) {
await api(`/api/albums/${id}`, { method: 'PATCH', body: { name: name.trim() } })
load()
}
}
const removeAlbum = async () => {
if (!confirm(`Delete album "${album.name}" and all ${photos.length} photos? This cannot be undone.`)) return
await api(`/api/albums/${id}`, { method: 'DELETE' })
navigate('/')
}
const removePhoto = async (photoId) => {
if (!confirm('Delete this photo?')) return
setLightboxId(null)
await api(`/api/photos/${photoId}`, { method: 'DELETE' })
load()
}
return (
<>
<div className="page-head">
<h1>
<Link to="/" className="muted">
Albums /
</Link>{' '}
{album.name}
</h1>
<div className="row">
<button className="btn" onClick={rename}>
Rename
</button>
<button className="btn btn-danger" onClick={removeAlbum}>
Delete album
</button>
</div>
</div>
<UploadZone albumId={id} onUploaded={load} />
{notReady.length > 0 && (
<section className="panel">
<h2>Processing</h2>
<ul className="pending-list">
{notReady.map((p) => (
<li key={p.id}>
<span className="upload-name">{p.filename}</span>
{p.status === 'error' ? (
<span className="row">
<span className="error">{p.error || 'failed'}</span>
<button
className="btn"
onClick={() => api(`/api/photos/${p.id}/reprocess`, { method: 'POST' }).then(load)}
>
Retry
</button>
<button className="btn btn-danger" onClick={() => removePhoto(p.id)}>
Delete
</button>
</span>
) : (
<span className="muted">{p.status}</span>
)}
</li>
))}
</ul>
</section>
)}
<Gallery
photos={ready}
onOpen={(i) => setLightboxId(ready[i].id)}
selected={selected}
onToggleSelect={toggle}
overlay={(p) => {
const avg = avgRating(p.id)
const tagCount = feedback[p.id]?.tags.length || 0
if (avg === null && tagCount === 0) return null
return (
<div className="g-overlay">
{avg !== null && <span> {avg.toFixed(1)}</span>}
{tagCount > 0 && <span># {tagCount}</span>}
</div>
)
}}
/>
{ready.length === 0 && notReady.length === 0 && (
<p className="muted">No photos yet drop some above.</p>
)}
<SelectionBar
count={selected.size}
total={ready.length}
selectedBytes={selectedBytes}
totalBytes={totalBytes}
onSelectAll={selectAll}
onClear={clear}
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
/>
{lightboxIndex >= 0 && (
<Lightbox
photos={ready}
index={lightboxIndex}
onClose={() => setLightboxId(null)}
onNav={(i) => setLightboxId(ready[i].id)}
footer={(p) => {
const fb = feedback[p.id] || { ratings: [], tags: [] }
return (
<div className="admin-footer">
<div className="feedback">
{fb.ratings.length === 0 && fb.tags.length === 0 && (
<span className="muted">No client feedback yet</span>
)}
{fb.ratings.map((r, i) => (
<span key={`r${i}`} className="feedback-item">
{r.share_label || 'client'}: <Stars value={r.rating} small />
</span>
))}
{fb.tags.map((t, i) => (
<span key={`t${i}`} className="chip">
{t.tag} <em className="muted">({t.share_label || 'client'})</em>
</span>
))}
</div>
<div className="row">
<label className="select-toggle">
<input
type="checkbox"
checked={selected.has(p.id)}
onChange={() => toggle(p.id)}
/>
select
</label>
<a className="btn" href={`/api/photos/${p.id}/original`}>
Download original
</a>
<button className="btn btn-danger" onClick={() => removePhoto(p.id)}>
Delete
</button>
</div>
</div>
)
}}
/>
)}
<SharesPanel albumId={id} />
</>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api, imgUrl } from '../api'
export default function AlbumsPage() {
const [albums, setAlbums] = useState(null)
const [name, setName] = useState('')
const [error, setError] = useState(null)
const load = () => api('/api/albums').then(setAlbums).catch((e) => setError(e.message))
useEffect(() => {
load()
}, [])
const create = async (e) => {
e.preventDefault()
if (!name.trim()) return
try {
await api('/api/albums', { method: 'POST', body: { name: name.trim() } })
setName('')
load()
} catch (e) {
setError(e.message)
}
}
return (
<>
<div className="page-head">
<h1>Albums</h1>
<form className="row" onSubmit={create}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New album name"
/>
<button className="btn btn-primary" type="submit">
Create
</button>
</form>
</div>
{error && <p className="error">{error}</p>}
{albums === null ? (
<p className="muted">Loading</p>
) : albums.length === 0 ? (
<p className="muted">No albums yet create one above.</p>
) : (
<div className="album-grid">
{albums.map((a) => (
<Link key={a.id} to={`/albums/${a.id}`} className="album-card">
<div className="album-cover">
{a.cover_photo_id ? (
<img
src={imgUrl({ id: a.cover_photo_id, processed_at: a.cover_processed_at }, 'thumb')}
loading="lazy"
alt=""
/>
) : (
<div className="album-cover-empty"></div>
)}
</div>
<div className="album-meta">
<strong>{a.name}</strong>
<span className="muted">
{a.photo_count} photo{a.photo_count === 1 ? '' : 's'}
</span>
</div>
</Link>
))}
</div>
)}
</>
)
}
+169
View File
@@ -0,0 +1,169 @@
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { api, postDownload } from '../api'
import Gallery from '../components/Gallery'
import Lightbox from '../components/Lightbox'
import SelectionBar from '../components/SelectionBar'
import Stars from '../components/Stars'
import TagEditor from '../components/TagEditor'
import useSelection from '../useSelection'
export default function SharePage() {
const { token } = useParams()
const [view, setView] = useState(null)
const [error, setError] = useState(null)
const [password, setPassword] = useState('')
const [unlockError, setUnlockError] = useState(null)
const [lightbox, setLightbox] = useState(-1)
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(
view?.photos ?? [],
)
const load = useCallback(
() => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)),
[token],
)
useEffect(() => {
load()
}, [load])
const unlock = async (e) => {
e.preventDefault()
try {
await api(`/api/share/${token}/unlock`, { method: 'POST', body: { password } })
setUnlockError(null)
load()
} catch (err) {
setUnlockError(err.status === 401 ? 'Wrong password' : err.message)
}
}
const patchPhoto = (photoId, patch) => {
setView((v) => ({
...v,
photos: v.photos.map((p) => (p.id === photoId ? { ...p, ...patch } : p)),
}))
}
const setRating = async (photo, rating) => {
patchPhoto(photo.id, { my_rating: rating || null })
try {
await api(`/api/share/${token}/photos/${photo.id}/rating`, {
method: 'PUT',
body: { rating },
})
} catch {
load()
}
}
const setTags = async (photo, tags) => {
patchPhoto(photo.id, { my_tags: tags })
try {
await api(`/api/share/${token}/photos/${photo.id}/tags`, {
method: 'PUT',
body: { tags },
})
} catch {
load()
}
}
if (error) return <div className="center-page">{error}</div>
if (!view) return <div className="center-page">Loading</div>
if (view.locked) {
return (
<div className="center-page">
<form className="login-card" onSubmit={unlock}>
<h1>{view.album_name}</h1>
<p className="muted">This gallery is password protected.</p>
<input
type="password"
autoFocus
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button className="btn btn-primary" type="submit">
Open gallery
</button>
{unlockError && <p className="error">{unlockError}</p>}
</form>
</div>
)
}
return (
<>
<header className="share-head">
<h1>{view.album_name}</h1>
{view.album_description && <p className="muted">{view.album_description}</p>}
<p className="muted">
{view.photos.length} photo{view.photos.length === 1 ? '' : 's'} · click a photo to view,
rate and tag
</p>
</header>
<main className="page">
<Gallery
photos={view.photos}
onOpen={setLightbox}
selected={view.allow_download ? selected : undefined}
onToggleSelect={view.allow_download ? toggle : undefined}
overlay={(p) =>
p.my_rating || p.my_tags.length > 0 ? (
<div className="g-overlay">
{p.my_rating && <span> {p.my_rating}</span>}
{p.my_tags.length > 0 && <span># {p.my_tags.length}</span>}
</div>
) : null
}
/>
{view.photos.length === 0 && (
<p className="center-page muted">Nothing here yet check back soon.</p>
)}
</main>
{view.allow_download && (
<SelectionBar
count={selected.size}
total={view.photos.length}
selectedBytes={selectedBytes}
totalBytes={totalBytes}
onSelectAll={selectAll}
onClear={clear}
onDownload={() => postDownload(`/api/share/${token}/zip`, [...selected].join(','))}
onDownloadAll={() => postDownload(`/api/share/${token}/zip`)}
/>
)}
{lightbox >= 0 && (
<Lightbox
photos={view.photos}
index={lightbox}
onClose={() => setLightbox(-1)}
onNav={setLightbox}
footer={(p) => (
<div className="client-footer">
<Stars value={p.my_rating || 0} onChange={(r) => setRating(p, r)} />
<TagEditor tags={p.my_tags} onChange={(tags) => setTags(p, tags)} />
{view.allow_download && (
<label className="select-toggle">
<input
type="checkbox"
checked={selected.has(p.id)}
onChange={() => toggle(p.id)}
/>
select
</label>
)}
{view.allow_download && (
<a className="btn" href={`/api/photos/${p.id}/original`}>
Download original
</a>
)}
</div>
)}
/>
)}
</>
)
}
+570
View File
@@ -0,0 +1,570 @@
* {
box-sizing: border-box;
}
:root {
--bg: #101216;
--panel: #191c22;
--panel-2: #22262e;
--text: #e8e6e1;
--muted: #9a978f;
--accent: #d9a441;
--danger: #e5645a;
--radius: 8px;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
}
h1 {
font-size: 1.4rem;
font-weight: 600;
margin: 0;
}
h1 a {
text-decoration: none;
}
h2 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.75rem;
}
a {
color: var(--text);
}
.muted {
color: var(--muted);
font-weight: 400;
}
.error {
color: var(--danger);
}
.page {
max-width: 1400px;
margin: 0 auto;
padding: 1rem 1.25rem 4rem;
}
.center-page {
min-height: 80vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin: 1rem 0 1.25rem;
}
.row {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* top bar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1.25rem;
border-bottom: 1px solid var(--panel-2);
}
.brand {
font-weight: 700;
font-size: 1rem;
text-decoration: none;
color: var(--accent);
}
.topbar-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* inputs & buttons */
input {
background: var(--panel);
border: 1px solid var(--panel-2);
color: var(--text);
border-radius: var(--radius);
padding: 0.45rem 0.7rem;
font-size: 0.95rem;
}
input:focus {
outline: 1px solid var(--accent);
}
.btn {
display: inline-block;
background: var(--panel-2);
color: var(--text);
border: none;
border-radius: var(--radius);
padding: 0.45rem 0.9rem;
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.btn:hover {
filter: brightness(1.15);
}
.btn-primary {
background: var(--accent);
color: #1a1408;
font-weight: 600;
}
.btn-danger {
background: transparent;
color: var(--danger);
border: 1px solid var(--danger);
}
.btn-ghost {
background: transparent;
color: var(--muted);
}
/* login */
.login-card {
background: var(--panel);
border-radius: 12px;
padding: 2.5rem 3rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.9rem;
color: var(--text);
}
/* album grid */
.album-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.album-card {
background: var(--panel);
border-radius: var(--radius);
overflow: hidden;
text-decoration: none;
transition: transform 0.1s;
}
.album-card:hover {
transform: translateY(-2px);
}
.album-cover {
aspect-ratio: 3 / 2;
background: var(--panel-2);
}
.album-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.album-cover-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--muted);
}
.album-meta {
padding: 0.6rem 0.8rem;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
/* justified gallery */
.gallery {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 1rem 0;
}
.g-item {
position: relative;
height: 240px;
flex-grow: calc(var(--ar) * 100);
flex-basis: calc(var(--ar) * 240px);
border-radius: 4px;
overflow: hidden;
cursor: pointer;
background: var(--panel);
}
.g-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.g-spacer {
flex-grow: 1000000;
flex-basis: 0;
height: 0;
}
.g-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 0.6rem;
padding: 0.35rem 0.55rem;
font-size: 0.8rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
color: #ffd97a;
}
/* selection */
.g-check {
position: absolute;
top: 8px;
left: 8px;
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.85);
background: rgba(0, 0, 0, 0.35);
color: transparent;
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s;
}
.g-item:hover .g-check,
.gallery.selecting .g-check,
.g-item.selected .g-check {
opacity: 1;
}
.g-item.selected .g-check {
background: var(--accent);
border-color: var(--accent);
color: #1a1408;
}
.g-item.selected img {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
.select-bar {
position: fixed;
bottom: 1.25rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 999px;
padding: 0.5rem 1rem;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
z-index: 50;
}
.select-count {
font-size: 0.9rem;
white-space: nowrap;
}
.select-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--muted);
cursor: pointer;
}
.select-toggle input {
accent-color: var(--accent);
width: 16px;
height: 16px;
}
/* upload zone */
.upload-zone {
border: 2px dashed var(--panel-2);
border-radius: var(--radius);
padding: 1.25rem;
text-align: center;
color: var(--muted);
cursor: pointer;
margin-bottom: 1rem;
}
.upload-zone.dragging {
border-color: var(--accent);
color: var(--accent);
}
.upload-zone p {
margin: 0;
}
.upload-list {
list-style: none;
margin: 1rem 0 0;
padding: 0;
text-align: left;
max-height: 220px;
overflow-y: auto;
cursor: default;
}
.upload-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.2rem 0;
font-size: 0.85rem;
}
.upload-item.done {
color: var(--muted);
}
.upload-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
progress {
width: 160px;
accent-color: var(--accent);
}
/* panels */
.panel {
background: var(--panel);
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin: 1.5rem 0;
}
.pending-list {
list-style: none;
margin: 0;
padding: 0;
}
.pending-list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.25rem 0;
font-size: 0.9rem;
}
/* shares */
.share-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--panel-2);
}
.share-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.share-info .muted {
font-size: 0.82rem;
}
.share-form {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 1rem;
}
.share-form label {
font-size: 0.85rem;
color: var(--muted);
}
.field-label {
gap: 0.4rem;
}
.field-label .btn-ghost {
padding: 0.2rem 0.4rem;
}
/* share (client) page */
.share-head {
text-align: center;
padding: 2.5rem 1rem 0.5rem;
}
.share-head h1 {
font-size: 1.8rem;
font-weight: 300;
}
.share-head p {
margin: 0.4rem 0 0;
}
/* lightbox */
.lightbox {
position: fixed;
inset: 0;
background: rgba(8, 9, 11, 0.96);
z-index: 100;
display: flex;
flex-direction: column;
}
.lb-top {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
color: var(--muted);
font-size: 0.85rem;
}
.lb-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-btn {
background: none;
border: none;
color: var(--text);
font-size: 1.1rem;
cursor: pointer;
}
.lb-stage {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 3.5rem;
}
.lb-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
cursor: default;
}
.lb-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.06);
border: none;
color: var(--text);
font-size: 2rem;
line-height: 1;
padding: 0.6rem 0.8rem;
border-radius: 50%;
cursor: pointer;
z-index: 101;
}
.lb-nav:disabled {
opacity: 0.25;
cursor: default;
}
.lb-prev {
left: 0.75rem;
}
.lb-next {
right: 0.75rem;
}
.lb-footer {
padding: 0.7rem 1rem 1rem;
}
.client-footer,
.admin-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
flex-wrap: wrap;
}
.feedback {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
font-size: 0.85rem;
}
.feedback-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
/* stars */
.stars {
display: inline-flex;
}
.star {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: #4a4a45;
cursor: pointer;
padding: 0 0.1rem;
}
.star.filled {
color: var(--accent);
}
.stars-small .star {
font-size: 0.95rem;
cursor: default;
}
.stars-readonly .star {
cursor: default;
}
/* tags */
.tag-editor {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.tag-editor input {
width: 110px;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--panel-2);
border-radius: 999px;
padding: 0.15rem 0.6rem;
font-size: 0.82rem;
}
.chip button {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 0.95rem;
padding: 0;
}
@media (max-width: 700px) {
.g-item {
height: 160px;
flex-basis: calc(var(--ar) * 160px);
}
.lb-stage {
padding: 0 0.5rem;
}
.login-card {
padding: 2rem 1.5rem;
margin: 0 1rem;
}
}
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
// Multi-select over a photo list. Selection lives here (not in the gallery)
// so it survives lightbox open/close, and is pruned automatically when
// photos disappear from the list (deletes, polling refreshes).
export default function useSelection(photos) {
const [selected, setSelected] = useState(() => new Set())
useEffect(() => {
setSelected((prev) => {
if (prev.size === 0) return prev
const valid = new Set(photos.map((p) => p.id))
const next = new Set([...prev].filter((id) => valid.has(id)))
return next.size === prev.size ? prev : next
})
}, [photos])
const toggle = (photoId) =>
setSelected((prev) => {
const next = new Set(prev)
if (next.has(photoId)) next.delete(photoId)
else next.add(photoId)
return next
})
const selectAll = () => setSelected(new Set(photos.map((p) => p.id)))
const clear = () => setSelected(new Set())
const selectedBytes = photos.reduce((sum, p) => sum + (selected.has(p.id) ? p.size_bytes : 0), 0)
const totalBytes = photos.reduce((sum, p) => sum + p.size_bytes, 0)
return { selected, toggle, selectAll, clear, selectedBytes, totalBytes }
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
})
+89
View File
@@ -0,0 +1,89 @@
create extension if not exists pgcrypto;
create table users (
id uuid primary key default gen_random_uuid(),
oidc_subject text not null unique,
email text not null,
display_name text,
created_at timestamptz not null default now()
);
create table albums (
id uuid primary key default gen_random_uuid(),
name text not null,
description text not null default '',
created_at timestamptz not null default now()
);
create table photos (
id uuid primary key default gen_random_uuid(),
album_id uuid not null references albums(id) on delete cascade,
filename text not null,
content_type text not null,
size_bytes bigint not null default 0,
status text not null default 'uploaded', -- uploaded | processing | ready | error
error text,
width int,
height int,
taken_at timestamptz,
-- Bumped on every (re)process; cache-buster for preview/thumb URLs.
processed_at timestamptz,
created_at timestamptz not null default now()
);
create index photos_album_idx on photos (album_id);
create table shares (
id uuid primary key default gen_random_uuid(),
album_id uuid not null references albums(id) on delete cascade,
token text not null unique,
label text not null default '',
password_hash text,
allow_download boolean not null default true,
expires_at timestamptz,
-- Brute-force lockout state for password-protected shares.
failed_attempts int not null default 0,
locked_until timestamptz,
created_at timestamptz not null default now()
);
create index shares_album_idx on shares (album_id);
create table ratings (
share_id uuid not null references shares(id) on delete cascade,
photo_id uuid not null references photos(id) on delete cascade,
rating int not null check (rating between 1 and 5),
updated_at timestamptz not null default now(),
primary key (share_id, photo_id)
);
-- Cascaded photo deletes fire per-row FK triggers; without this each one
-- sequential-scans the table.
create index ratings_photo_idx on ratings (photo_id);
create table tags (
id uuid primary key default gen_random_uuid(),
share_id uuid not null references shares(id) on delete cascade,
photo_id uuid not null references photos(id) on delete cascade,
tag text not null,
created_at timestamptz not null default now(),
unique (share_id, photo_id, tag)
);
create index tags_photo_idx on tags (photo_id);
create table jobs (
id uuid primary key default gen_random_uuid(),
kind text not null,
payload jsonb not null default '{}',
status text not null default 'queued', -- queued | running | done | failed
attempts int not null default 0,
max_attempts int not null default 5,
run_at timestamptz not null default now(),
locked_by text,
locked_at timestamptz,
last_error text,
created_at timestamptz not null default now()
);
create index jobs_poll_idx on jobs (status, run_at);
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
# aws-sdk-s3 (and friends) currently require rustc >= 1.94.1
channel = "1.94.1"
+312
View File
@@ -0,0 +1,312 @@
use axum::extract::{FromRequestParts, Query, State};
use axum::http::request::Parts;
use axum::response::Redirect;
use axum::Json;
use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar};
use chrono::Utc;
use serde::Deserialize;
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
pub const SESSION_COOKIE: &str = "photos_session";
const STATE_COOKIE: &str = "photos_oauth_state";
const SESSION_DAYS: i64 = 30;
#[derive(Debug, Clone, Deserialize)]
pub struct OidcDiscovery {
pub authorization_endpoint: String,
pub token_endpoint: String,
pub userinfo_endpoint: String,
}
pub async fn discovery(state: &AppState) -> anyhow::Result<OidcDiscovery> {
let discovered = state
.oidc
.get_or_try_init(|| async {
let url = format!(
"{}/.well-known/openid-configuration",
state.config.oidc_issuer
);
let resp = state.http.get(&url).send().await?.error_for_status()?;
Ok::<_, anyhow::Error>(resp.json::<OidcDiscovery>().await?)
})
.await?;
Ok(discovered.clone())
}
#[derive(Debug, Clone)]
pub struct AuthUser {
pub id: Uuid,
pub email: String,
}
fn parse_session(value: &str) -> Option<(Uuid, i64, String)> {
let mut parts = value.splitn(3, '|');
let id = Uuid::parse_str(parts.next()?).ok()?;
let exp: i64 = parts.next()?.parse().ok()?;
let email = parts.next()?.to_string();
Some((id, exp, email))
}
/// Sessions are only honored while the email is still in ALLOWED_EMAILS, so
/// removing an address from the allowlist revokes access immediately.
pub fn user_from_jar(state: &AppState, jar: &SignedCookieJar) -> Option<AuthUser> {
let cookie = jar.get(SESSION_COOKIE)?;
let (id, exp, email) = parse_session(cookie.value())?;
if exp < Utc::now().timestamp() {
return None;
}
if !state.config.allowed_emails.contains(&email) {
return None;
}
Some(AuthUser { id, email })
}
/// A fresh session cookie when the current one has used up more than half its
/// lifetime — appended to responses by the admin middleware so an active
/// photographer's session slides instead of hard-expiring 30 days after login.
pub fn refreshed_session(state: &AppState, jar: &SignedCookieJar) -> Option<Cookie<'static>> {
let cookie = jar.get(SESSION_COOKIE)?;
let (id, exp, email) = parse_session(cookie.value())?;
let remaining = exp - Utc::now().timestamp();
if remaining > SESSION_DAYS * 86400 / 2 {
return None;
}
Some(session_cookie(state, id, &email))
}
impl FromRequestParts<AppState> for AuthUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
_state: &AppState,
) -> Result<Self, Self::Rejection> {
// Only valid behind the require_photographer layer, which validates
// the session and stashes the user. Routes outside the admin router
// must not use this extractor — session validation lives in the
// middleware (and user_from_jar for the dual-auth image routes).
parts
.extensions
.get::<AuthUser>()
.cloned()
.ok_or_else(ApiError::unauthorized)
}
}
pub fn random_token(len: usize) -> String {
use rand::Rng;
rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(len)
.map(char::from)
.collect()
}
pub(crate) fn base_cookie(name: &'static str, value: String, secure: bool) -> Cookie<'static> {
Cookie::build((name, value))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(secure)
.build()
}
fn session_cookie(state: &AppState, user_id: Uuid, email: &str) -> Cookie<'static> {
let exp = Utc::now().timestamp() + SESSION_DAYS * 86400;
let mut cookie = base_cookie(
SESSION_COOKIE,
format!("{user_id}|{exp}|{email}"),
state.config.cookie_secure(),
);
cookie.set_max_age(time::Duration::days(SESSION_DAYS));
cookie
}
async fn upsert_user(
state: &AppState,
oidc_subject: &str,
email: &str,
display_name: &str,
) -> Result<Uuid, sqlx::Error> {
let (user_id,): (Uuid,) = sqlx::query_as(
"insert into users (oidc_subject, email, display_name) values ($1, $2, $3)
on conflict (oidc_subject) do update
set email = excluded.email, display_name = excluded.display_name
returning id",
)
.bind(oidc_subject)
.bind(email)
.bind(display_name)
.fetch_one(&state.db)
.await?;
Ok(user_id)
}
/// Login/callback are top-level browser navigations — errors must land the
/// user back on the SPA login card, never on a raw JSON body.
fn error_redirect(error: &ApiError) -> Redirect {
let message = if error.0.is_server_error() {
"sign-in failed — please try again"
} else {
error.1.as_str()
};
Redirect::to(&format!("/?auth_error={}", urlencoding::encode(message)))
}
pub async fn login(
State(state): State<AppState>,
jar: SignedCookieJar,
) -> (SignedCookieJar, Redirect) {
match login_inner(&state, jar.clone()).await {
Ok(ok) => ok,
Err(e) => {
tracing::warn!("login failed: {} {}", e.0, e.1);
(jar, error_redirect(&e))
}
}
}
async fn login_inner(
state: &AppState,
jar: SignedCookieJar,
) -> ApiResult<(SignedCookieJar, Redirect)> {
if let Some(email) = state.config.dev_autologin_email.clone() {
tracing::warn!("DEV_AUTOLOGIN_EMAIL is set — signing in {email} without OIDC");
let email = email.to_lowercase();
if !state.config.allowed_emails.contains(&email) {
return Err(ApiError::forbidden(
"DEV_AUTOLOGIN_EMAIL must also be in ALLOWED_EMAILS",
));
}
let user_id = upsert_user(state, &format!("dev:{email}"), &email, &email).await?;
let cookie = session_cookie(state, user_id, &email);
return Ok((jar.add(cookie), Redirect::to("/")));
}
let discovered = discovery(state).await?;
let oauth_state = random_token(24);
let redirect_uri = format!("{}/api/auth/callback", state.config.public_url);
let separator = if discovered.authorization_endpoint.contains('?') {
'&'
} else {
'?'
};
let url = format!(
"{}{}response_type=code&client_id={}&redirect_uri={}&scope=openid%20email%20profile&state={}",
discovered.authorization_endpoint,
separator,
urlencoding::encode(&state.config.oidc_client_id),
urlencoding::encode(&redirect_uri),
oauth_state
);
let mut cookie = base_cookie(STATE_COOKIE, oauth_state, state.config.cookie_secure());
cookie.set_max_age(time::Duration::minutes(10));
Ok((jar.add(cookie), Redirect::to(&url)))
}
#[derive(Deserialize)]
pub struct CallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
}
#[derive(Deserialize)]
struct UserInfo {
sub: String,
email: Option<String>,
name: Option<String>,
preferred_username: Option<String>,
}
pub async fn callback(
State(state): State<AppState>,
jar: SignedCookieJar,
Query(query): Query<CallbackQuery>,
) -> (SignedCookieJar, Redirect) {
match callback_inner(&state, jar.clone(), query).await {
Ok(ok) => ok,
Err(e) => {
tracing::warn!("oidc callback failed: {} {}", e.0, e.1);
(jar, error_redirect(&e))
}
}
}
async fn callback_inner(
state: &AppState,
jar: SignedCookieJar,
query: CallbackQuery,
) -> ApiResult<(SignedCookieJar, Redirect)> {
if let Some(err) = query.error {
let detail = query.error_description.unwrap_or_default();
return Err(ApiError::bad_request(format!("oidc error: {err} {detail}")));
}
let code = query
.code
.ok_or_else(|| ApiError::bad_request("missing code"))?;
let returned_state = query.state.unwrap_or_default();
let cookie_state = jar.get(STATE_COOKIE).map(|c| c.value().to_string());
if returned_state.is_empty() || cookie_state.as_deref() != Some(returned_state.as_str()) {
return Err(ApiError::bad_request("oauth state mismatch"));
}
let jar = jar.remove(Cookie::build((STATE_COOKIE, "")).path("/").build());
let discovered = discovery(state).await?;
let redirect_uri = format!("{}/api/auth/callback", state.config.public_url);
let token: TokenResponse = state
.http
.post(&discovered.token_endpoint)
.form(&[
("grant_type", "authorization_code"),
("code", code.as_str()),
("redirect_uri", redirect_uri.as_str()),
("client_id", state.config.oidc_client_id.as_str()),
("client_secret", state.config.oidc_client_secret.as_str()),
])
.send()
.await?
.error_for_status()?
.json()
.await?;
let info: UserInfo = state
.http
.get(&discovered.userinfo_endpoint)
.bearer_auth(&token.access_token)
.send()
.await?
.error_for_status()?
.json()
.await?;
let email = info.email.clone().unwrap_or_default().to_lowercase();
if email.is_empty() || !state.config.allowed_emails.contains(&email) {
return Err(ApiError::forbidden("this account is not allowed to sign in"));
}
let display_name = info
.name
.or(info.preferred_username)
.unwrap_or_else(|| email.clone());
let user_id = upsert_user(state, &info.sub, &email, &display_name).await?;
let cookie = session_cookie(state, user_id, &email);
Ok((jar.add(cookie), Redirect::to("/")))
}
pub async fn logout(jar: SignedCookieJar) -> (SignedCookieJar, Json<serde_json::Value>) {
let jar = jar.remove(Cookie::build((SESSION_COOKIE, "")).path("/").build());
(jar, Json(serde_json::json!({ "ok": true })))
}
pub async fn me(user: AuthUser) -> Json<serde_json::Value> {
Json(serde_json::json!({ "email": user.email }))
}
+33
View File
@@ -0,0 +1,33 @@
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
use photos::config::Config;
use photos::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,sqlx=warn".into()),
)
.init();
let config = Config::from_env()?;
let state = AppState::new(config).await?;
let static_dir = state.config.static_dir.clone();
let index = std::path::Path::new(&static_dir).join("index.html");
// .fallback (not .not_found_service) so SPA routes get index.html with a 200
let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(index));
let app = photos::routes::router(&state)
.fallback_service(spa)
.layer(TraceLayer::new_for_http())
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind(&state.config.bind_addr).await?;
tracing::info!("listening on http://{}", state.config.bind_addr);
axum::serve(listener, app).await?;
Ok(())
}
+19
View File
@@ -0,0 +1,19 @@
use tracing_subscriber::EnvFilter;
use photos::config::Config;
use photos::jobs;
use photos::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,sqlx=warn".into()),
)
.init();
let config = Config::from_env()?;
let state = AppState::new(config).await?;
jobs::run_worker(state).await;
Ok(())
}
+81
View File
@@ -0,0 +1,81 @@
use anyhow::Context;
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub bind_addr: String,
/// External base URL of the app, e.g. https://photos.example.com (no trailing slash).
pub public_url: String,
pub session_secret: String,
pub s3_bucket: String,
pub s3_endpoint: Option<String>,
pub s3_region: String,
pub s3_access_key: String,
pub s3_secret_key: String,
pub s3_force_path_style: bool,
pub oidc_issuer: String,
pub oidc_client_id: String,
pub oidc_client_secret: String,
/// Lowercased email addresses allowed to sign in as photographer.
pub allowed_emails: Vec<String>,
pub static_dir: String,
pub worker_concurrency: usize,
/// DEV ONLY: if set, /api/auth/login skips OIDC entirely and signs in as
/// this email. Never set in production.
pub dev_autologin_email: Option<String>,
}
fn required(name: &str) -> anyhow::Result<String> {
std::env::var(name).with_context(|| format!("missing required env var {name}"))
}
fn optional(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
let session_secret = required("SESSION_SECRET")?;
anyhow::ensure!(
session_secret.len() >= 32,
"SESSION_SECRET must be at least 32 characters"
);
let public_url = required("PUBLIC_URL")?.trim_end_matches('/').to_string();
let dev_autologin_email = optional("DEV_AUTOLOGIN_EMAIL");
anyhow::ensure!(
dev_autologin_email.is_none() || !public_url.starts_with("https://"),
"DEV_AUTOLOGIN_EMAIL must not be set when PUBLIC_URL is https:// — it disables login"
);
Ok(Self {
database_url: required("DATABASE_URL")?,
bind_addr: optional("BIND_ADDR").unwrap_or_else(|| "0.0.0.0:8080".into()),
public_url,
session_secret,
s3_bucket: required("S3_BUCKET")?,
s3_endpoint: optional("S3_ENDPOINT"),
s3_region: optional("S3_REGION").unwrap_or_else(|| "us-east-1".into()),
s3_access_key: required("S3_ACCESS_KEY")?,
s3_secret_key: required("S3_SECRET_KEY")?,
s3_force_path_style: optional("S3_FORCE_PATH_STYLE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false),
oidc_issuer: required("OIDC_ISSUER")?.trim_end_matches('/').to_string(),
oidc_client_id: required("OIDC_CLIENT_ID")?,
oidc_client_secret: required("OIDC_CLIENT_SECRET")?,
allowed_emails: required("ALLOWED_EMAILS")?
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect(),
static_dir: optional("STATIC_DIR").unwrap_or_else(|| "frontend/dist".into()),
worker_concurrency: optional("WORKER_CONCURRENCY")
.and_then(|v| v.parse().ok())
.unwrap_or(2),
dev_autologin_email,
})
}
pub fn cookie_secure(&self) -> bool {
self.public_url.starts_with("https://")
}
}
+55
View File
@@ -0,0 +1,55 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
pub struct ApiError(pub StatusCode, pub String);
pub type ApiResult<T> = Result<T, ApiError>;
impl ApiError {
pub fn bad_request(msg: impl Into<String>) -> Self {
Self(StatusCode::BAD_REQUEST, msg.into())
}
pub fn unauthorized() -> Self {
Self(StatusCode::UNAUTHORIZED, "authentication required".into())
}
pub fn forbidden(msg: impl Into<String>) -> Self {
Self(StatusCode::FORBIDDEN, msg.into())
}
pub fn not_found() -> Self {
Self(StatusCode::NOT_FOUND, "not found".into())
}
pub fn gone(msg: impl Into<String>) -> Self {
Self(StatusCode::GONE, msg.into())
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.0, Json(serde_json::json!({ "error": self.1 }))).into_response()
}
}
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
if matches!(e, sqlx::Error::RowNotFound) {
return Self::not_found();
}
tracing::error!("database error: {e}");
Self(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
}
impl From<anyhow::Error> for ApiError {
fn from(e: anyhow::Error) -> Self {
tracing::error!("internal error: {e:#}");
Self(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
}
impl From<reqwest::Error> for ApiError {
fn from(e: reqwest::Error) -> Self {
tracing::error!("upstream http error: {e}");
Self(StatusCode::BAD_GATEWAY, "upstream error".into())
}
}
+253
View File
@@ -0,0 +1,253 @@
use std::io::Cursor;
use std::path::Path;
use anyhow::Context;
use chrono::{DateTime, NaiveDateTime, Utc};
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use image::DynamicImage;
use serde::Deserialize;
use uuid::Uuid;
use crate::models::{Photo, PhotoStatus};
use crate::s3;
use crate::state::AppState;
const PREVIEW_EDGE: u32 = 2048;
const THUMB_EDGE: u32 = 512;
pub const RAW_EXTENSIONS: &[&str] = &[
"3fr", "arw", "cr2", "cr3", "dng", "erf", "iiq", "kdc", "mef", "mos", "nef", "nrw", "orf",
"pef", "raf", "raw", "rw2", "rwl", "srw", "x3f",
];
pub fn is_raw_filename(filename: &str) -> bool {
Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.map(|e| RAW_EXTENSIONS.contains(&e.to_lowercase().as_str()))
.unwrap_or(false)
}
#[derive(Deserialize)]
struct ProcessPayload {
photo_id: Uuid,
}
pub async fn process_photo_job(state: &AppState, payload: &serde_json::Value) -> anyhow::Result<()> {
let payload: ProcessPayload = serde_json::from_value(payload.clone())?;
process_photo(state, payload.photo_id).await
}
pub async fn process_photo(state: &AppState, photo_id: Uuid) -> anyhow::Result<()> {
let photo: Option<Photo> = sqlx::query_as("select * from photos where id = $1")
.bind(photo_id)
.fetch_optional(&state.db)
.await?;
let Some(photo) = photo else {
tracing::warn!("photo {photo_id} no longer exists, skipping");
return Ok(());
};
sqlx::query("update photos set status = $2, error = null where id = $1")
.bind(photo_id)
.bind(PhotoStatus::Processing.as_str())
.execute(&state.db)
.await?;
let dir = tempfile::tempdir().context("creating temp dir")?;
let extension = Path::new(&photo.filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin")
.to_lowercase();
let src_path = dir.path().join(format!("original.{extension}"));
download(state, &s3::original_key(photo_id, &photo.filename), &src_path).await?;
let meta = exif_metadata(&src_path).await?;
let render_input = if RAW_EXTENSIONS.contains(&extension.as_str()) {
extract_embedded_jpeg(&src_path).await?
} else {
tokio::fs::read(&src_path).await.context("reading original")?
};
let orientation = meta.orientation;
let (preview, thumb, width, height) =
tokio::task::spawn_blocking(move || render(&render_input, orientation))
.await
.context("render task panicked")??;
s3::put_bytes(state, &s3::preview_key(photo_id), preview, "image/jpeg").await?;
s3::put_bytes(state, &s3::thumb_key(photo_id), thumb, "image/jpeg").await?;
let updated = sqlx::query(
"update photos
set status = $5, error = null, width = $2, height = $3,
taken_at = coalesce($4, taken_at), processed_at = now()
where id = $1",
)
.bind(photo_id)
.bind(width as i32)
.bind(height as i32)
.bind(meta.taken_at)
.bind(PhotoStatus::Ready.as_str())
.execute(&state.db)
.await?;
if updated.rows_affected() == 0 {
// Photo was deleted while we were processing; its delete_s3_prefix job
// may already have run, so remove the derivatives we just re-created.
tracing::warn!("photo {photo_id} deleted during processing; cleaning up derivatives");
s3::delete_prefix(state, &s3::photo_prefix(photo_id)).await?;
}
Ok(())
}
async fn download(state: &AppState, key: &str, path: &Path) -> anyhow::Result<()> {
let object = state
.s3
.get_object()
.bucket(&state.config.s3_bucket)
.key(key)
.send()
.await
.with_context(|| format!("fetching s3://{}/{key}", state.config.s3_bucket))?;
let mut reader = object.body.into_async_read();
let mut file = tokio::fs::File::create(path).await?;
tokio::io::copy(&mut reader, &mut file).await?;
Ok(())
}
#[derive(Default)]
struct ExifMeta {
orientation: u32,
taken_at: Option<DateTime<Utc>>,
}
async fn exif_metadata(path: &Path) -> anyhow::Result<ExifMeta> {
let output = tokio::process::Command::new("exiftool")
.args([
"-j",
"-d",
"%Y-%m-%dT%H:%M:%S",
"-Orientation#",
"-DateTimeOriginal",
"-CreateDate",
"-OffsetTimeOriginal",
"-OffsetTime",
])
.arg(path)
.output()
.await;
let output = match output {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
anyhow::bail!("exiftool is not installed or not on PATH")
}
other => other.context("running exiftool")?,
};
if !output.status.success() {
tracing::warn!(
"exiftool metadata read failed: {}",
String::from_utf8_lossy(&output.stderr)
);
return Ok(ExifMeta::default());
}
let parsed: Vec<serde_json::Value> =
serde_json::from_slice(&output.stdout).context("parsing exiftool json")?;
let entry = parsed.first().cloned().unwrap_or_default();
let orientation = entry
.get("Orientation")
.and_then(|v| v.as_u64())
.map(|v| v as u32)
.unwrap_or(1);
let offset = ["OffsetTimeOriginal", "OffsetTime"]
.iter()
.find_map(|field| entry.get(*field).and_then(|v| v.as_str()));
let taken_at = ["DateTimeOriginal", "CreateDate"]
.iter()
.filter_map(|field| entry.get(*field).and_then(|v| v.as_str()))
.find_map(|s| parse_exif_datetime(s, offset));
Ok(ExifMeta {
orientation,
taken_at,
})
}
/// EXIF datetimes are camera-local wall-clock time; apply the EXIF offset tag
/// when the camera recorded one, otherwise fall back to treating it as UTC.
fn parse_exif_datetime(s: &str, offset: Option<&str>) -> Option<DateTime<Utc>> {
if let Some(offset) = offset {
if let Ok(dt) = DateTime::parse_from_str(&format!("{s}{offset}"), "%Y-%m-%dT%H:%M:%S%:z") {
return Some(dt.with_timezone(&Utc));
}
}
NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
.ok()
.map(|naive| naive.and_utc())
}
/// Extract the largest embedded JPEG preview from a raw file using exiftool.
async fn extract_embedded_jpeg(path: &Path) -> anyhow::Result<Vec<u8>> {
let mut best: Vec<u8> = Vec::new();
for tag in ["-JpgFromRaw", "-PreviewImage", "-OtherImage", "-ThumbnailImage"] {
let output = tokio::process::Command::new("exiftool")
.args(["-b", tag])
.arg(path)
.output()
.await
.context("running exiftool")?;
if output.status.success() && output.stdout.len() > best.len() {
best = output.stdout;
}
// A full-size embedded preview is comfortably above this; stop early.
if best.len() > 200_000 {
break;
}
}
anyhow::ensure!(
best.len() > 1_000,
"no usable embedded preview found in raw file"
);
Ok(best)
}
fn render(bytes: &[u8], orientation: u32) -> anyhow::Result<(Vec<u8>, Vec<u8>, u32, u32)> {
let img = image::load_from_memory(bytes).context("decoding image")?;
let img = apply_orientation(img, orientation);
let (width, height) = (img.width(), img.height());
let preview = if width.max(height) > PREVIEW_EDGE {
img.resize(PREVIEW_EDGE, PREVIEW_EDGE, FilterType::Triangle)
} else {
img
};
let thumb = preview.resize(THUMB_EDGE, THUMB_EDGE, FilterType::Lanczos3);
Ok((
encode_jpeg(&preview, 86)?,
encode_jpeg(&thumb, 82)?,
width,
height,
))
}
fn encode_jpeg(img: &DynamicImage, quality: u8) -> anyhow::Result<Vec<u8>> {
let rgb = img.to_rgb8();
let mut buf = Cursor::new(Vec::new());
let encoder = JpegEncoder::new_with_quality(&mut buf, quality);
rgb.write_with_encoder(encoder).context("encoding jpeg")?;
Ok(buf.into_inner())
}
fn apply_orientation(img: DynamicImage, orientation: u32) -> DynamicImage {
match orientation {
2 => img.fliph(),
3 => img.rotate180(),
4 => img.flipv(),
5 => img.rotate90().fliph(),
6 => img.rotate90(),
7 => img.rotate270().fliph(),
8 => img.rotate270(),
_ => img,
}
}
+329
View File
@@ -0,0 +1,329 @@
use std::time::Duration;
use uuid::Uuid;
use crate::models::{JobKind, JobStatus, PhotoStatus};
use crate::state::AppState;
/// Hard ceiling on a single job run; the sole bound for a live-but-hung worker
/// (a hung S3 read or exiftool child), since the heartbeat keeps the reaper away.
const JOB_TIMEOUT: Duration = Duration::from_secs(30 * 60);
/// How often a running job refreshes its lock. Must stay well below the
/// reaper's staleness threshold.
const HEARTBEAT_EVERY: Duration = Duration::from_secs(300);
/// A 'running' job whose lock is older than this had its worker die.
const STALE_AFTER: &str = "15 minutes";
#[derive(Debug, sqlx::FromRow)]
pub struct Job {
pub id: Uuid,
pub kind: String,
pub payload: serde_json::Value,
pub attempts: i32,
pub max_attempts: i32,
}
pub async fn enqueue<'e, E>(
executor: E,
kind: JobKind,
payload: serde_json::Value,
) -> Result<(), sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query("insert into jobs (kind, payload) values ($1, $2)")
.bind(kind.as_str())
.bind(payload)
.execute(executor)
.await?;
Ok(())
}
/// Make sure a process_photo job will (re)run for this photo: bump a queued
/// one to run now with fresh attempts; leave a running one alone (resetting
/// its attempts wouldn't reach the in-flight worker, which decides exhaustion
/// from its claim-time copy — it will finish or fail on its own and the photo
/// can be retried again); enqueue fresh otherwise.
pub async fn ensure_process_photo(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
photo_id: Uuid,
) -> Result<(), sqlx::Error> {
let requeued = sqlx::query(
"update jobs set run_at = now(), attempts = 0
where kind = $2 and status = $3 and payload->>'photo_id' = $1",
)
.bind(photo_id.to_string())
.bind(JobKind::ProcessPhoto.as_str())
.bind(JobStatus::Queued.as_str())
.execute(&mut **tx)
.await?;
if requeued.rows_affected() > 0 {
return Ok(());
}
let running: Option<(Uuid,)> = sqlx::query_as(
"select id from jobs
where kind = $2 and status = $3 and payload->>'photo_id' = $1",
)
.bind(photo_id.to_string())
.bind(JobKind::ProcessPhoto.as_str())
.bind(JobStatus::Running.as_str())
.fetch_optional(&mut **tx)
.await?;
if running.is_some() {
return Ok(());
}
enqueue(
&mut **tx,
JobKind::ProcessPhoto,
serde_json::json!({ "photo_id": photo_id }),
)
.await
}
pub async fn run_worker(state: AppState) {
let concurrency = state.config.worker_concurrency.max(1);
// Unique per process so locked_by distinguishes workers across replicas.
let instance = crate::auth::random_token(6);
tracing::info!("starting worker {instance} with concurrency {concurrency}");
let mut handles = Vec::new();
handles.push(tokio::spawn(reaper_loop(state.clone())));
for i in 0..concurrency {
let state = state.clone();
handles.push(tokio::spawn(worker_loop(state, format!("worker-{instance}-{i}"))));
}
for handle in handles {
let _ = handle.await;
}
}
/// Requeue stale jobs whose worker died mid-run — but only while they have
/// attempts left; exhausted stale jobs are failed outright so a job that
/// crashes its worker (e.g. OOM during decode) cannot crash-loop forever.
async fn reaper_loop(state: AppState) {
loop {
let failed: Result<Vec<(String, serde_json::Value)>, sqlx::Error> = sqlx::query_as(&format!(
"update jobs set status = $1, locked_by = null,
last_error = coalesce(last_error, 'worker lost repeatedly (crash loop?)')
where (status = $2 and locked_at < now() - interval '{STALE_AFTER}'
or status = $3)
and attempts >= max_attempts
returning kind, payload"
))
.bind(JobStatus::Failed.as_str())
.bind(JobStatus::Running.as_str())
.bind(JobStatus::Queued.as_str())
.fetch_all(&state.db)
.await;
match failed {
Ok(jobs) => {
for (kind, payload) in jobs {
tracing::error!(kind, "reaper failed exhausted job");
mark_photo_error(&state, &kind, &payload, "processing failed repeatedly").await;
}
}
Err(e) => tracing::error!("job reaper (fail pass) errored: {e}"),
}
let requeued = sqlx::query(&format!(
"update jobs set status = $1, locked_by = null, locked_at = null
where status = $2 and locked_at < now() - interval '{STALE_AFTER}'
and attempts < max_attempts"
))
.bind(JobStatus::Queued.as_str())
.bind(JobStatus::Running.as_str())
.execute(&state.db)
.await;
match requeued {
Ok(r) if r.rows_affected() > 0 => {
tracing::warn!("requeued {} stale running job(s)", r.rows_affected())
}
Ok(_) => {}
Err(e) => tracing::error!("job reaper (requeue pass) errored: {e}"),
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
/// Terminal-failure side effect for process_photo jobs: surface the error on
/// the photo, but never overwrite a photo a newer job already finished.
async fn mark_photo_error(state: &AppState, kind: &str, payload: &serde_json::Value, message: &str) {
if kind != JobKind::ProcessPhoto.as_str() {
return;
}
let Some(photo_id) = payload
.get("photo_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
else {
return;
};
// A job can die before its first status write, so rescue photos stuck in
// 'uploaded' as well as 'processing' — but never overwrite 'ready'.
let _ = sqlx::query(
"update photos set status = $3, error = $2
where id = $1 and status in ($4, $5)",
)
.bind(photo_id)
.bind(message)
.bind(PhotoStatus::Error.as_str())
.bind(PhotoStatus::Processing.as_str())
.bind(PhotoStatus::Uploaded.as_str())
.execute(&state.db)
.await;
}
async fn worker_loop(state: AppState, name: String) {
loop {
match claim(&state, &name).await {
Ok(Some(job)) => execute(&state, job, &name).await,
Ok(None) => tokio::time::sleep(Duration::from_secs(2)).await,
Err(e) => {
tracing::error!("failed to claim job: {e}");
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
}
}
async fn claim(state: &AppState, name: &str) -> Result<Option<Job>, sqlx::Error> {
sqlx::query_as(
"update jobs
set status = $2, locked_by = $1, locked_at = now(), attempts = attempts + 1
where id = (
select id from jobs
where status = $3 and run_at <= now() and attempts < max_attempts
order by created_at
limit 1
for update skip locked
)
returning id, kind, payload, attempts, max_attempts",
)
.bind(name)
.bind(JobStatus::Running.as_str())
.bind(JobStatus::Queued.as_str())
.fetch_optional(&state.db)
.await
}
/// Keep locked_at fresh while a job runs so the reaper never requeues a job
/// whose worker is alive. Never completes; raced against the job in select!.
async fn heartbeat(state: &AppState, job_id: Uuid, name: &str) {
loop {
tokio::time::sleep(HEARTBEAT_EVERY).await;
let _ = sqlx::query(
"update jobs set locked_at = now()
where id = $1 and locked_by = $2 and status = $3",
)
.bind(job_id)
.bind(name)
.bind(JobStatus::Running.as_str())
.execute(&state.db)
.await;
}
}
async fn execute(state: &AppState, job: Job, name: &str) {
tracing::info!(job_id = %job.id, kind = %job.kind, attempt = job.attempts, "job started");
// Parse the kind here (not at claim decode) so an unknown kind — e.g.
// enqueued by a newer deploy — fails THIS job normally instead of
// poisoning the claim loop.
let run = async {
match job.kind.parse::<JobKind>() {
Ok(JobKind::ProcessPhoto) => {
crate::imaging::process_photo_job(state, &job.payload).await
}
Ok(JobKind::DeleteS3Prefix) => delete_s3_prefix_job(state, &job.payload).await,
Err(e) => Err(anyhow::anyhow!(e)),
}
};
let result = tokio::select! {
result = tokio::time::timeout(JOB_TIMEOUT, run) => match result {
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!(
"job timed out after {}s",
JOB_TIMEOUT.as_secs()
)),
},
_ = heartbeat(state, job.id, name) => unreachable!("heartbeat never completes"),
};
// Finalization is guarded on locked_by so a worker whose job was reclaimed
// (reaper) cannot overwrite the state written by the new owner.
match result {
Ok(()) => {
let updated = sqlx::query(
"update jobs set status = $3, locked_by = null, last_error = null
where id = $1 and locked_by = $2 and status = $4",
)
.bind(job.id)
.bind(name)
.bind(JobStatus::Done.as_str())
.bind(JobStatus::Running.as_str())
.execute(&state.db)
.await;
match updated {
Ok(r) if r.rows_affected() == 0 => {
tracing::warn!(job_id = %job.id, "job was reclaimed by another worker; result discarded")
}
Ok(_) => tracing::info!(job_id = %job.id, kind = %job.kind, "job done"),
Err(e) => tracing::error!(job_id = %job.id, "failed to finalize job: {e}"),
}
}
Err(e) => {
let message = format!("{e:#}");
let exhausted = job.attempts >= job.max_attempts;
tracing::error!(job_id = %job.id, kind = %job.kind, exhausted, "job failed: {message}");
// Two self-contained query+bind branches — the placeholder lists
// and bind chains must never be shared across branches.
let finalize = if exhausted {
sqlx::query(
"update jobs set status = $4, locked_by = null, last_error = $2
where id = $1 and locked_by = $3 and status = $5",
)
.bind(job.id)
.bind(&message)
.bind(name)
.bind(JobStatus::Failed.as_str())
.bind(JobStatus::Running.as_str())
} else {
let backoff = 30.0 * f64::from(job.attempts * job.attempts);
sqlx::query(
"update jobs
set status = $4, locked_by = null, last_error = $2,
run_at = now() + make_interval(secs => $6)
where id = $1 and locked_by = $3 and status = $5",
)
.bind(job.id)
.bind(&message)
.bind(name)
.bind(JobStatus::Queued.as_str())
.bind(JobStatus::Running.as_str())
.bind(backoff)
};
let owned = match finalize.execute(&state.db).await {
Ok(r) => r.rows_affected() > 0,
Err(e) => {
tracing::error!(job_id = %job.id, "failed to finalize job: {e}");
false
}
};
if owned && exhausted {
mark_photo_error(state, &job.kind, &job.payload, &message).await;
}
}
}
}
async fn delete_s3_prefix_job(
state: &AppState,
payload: &serde_json::Value,
) -> anyhow::Result<()> {
let prefix = payload
.get("prefix")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing prefix in payload"))?;
anyhow::ensure!(
prefix.starts_with("photos/") && prefix.ends_with('/'),
"refusing to delete suspicious prefix {prefix:?}"
);
crate::s3::delete_prefix(state, prefix).await
}
+9
View File
@@ -0,0 +1,9 @@
pub mod auth;
pub mod config;
pub mod error;
pub mod imaging;
pub mod jobs;
pub mod models;
pub mod routes;
pub mod s3;
pub mod state;
+136
View File
@@ -0,0 +1,136 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
use uuid::Uuid;
/// Stored as text in Postgres; decoded via TryFrom so an unknown value is a
/// loud decode error instead of a silently misbehaving string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PhotoStatus {
Uploaded,
Processing,
Ready,
Error,
}
impl PhotoStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Uploaded => "uploaded",
Self::Processing => "processing",
Self::Ready => "ready",
Self::Error => "error",
}
}
}
impl std::str::FromStr for PhotoStatus {
type Err = String;
fn from_str(value: &str) -> Result<Self, String> {
match value {
"uploaded" => Ok(Self::Uploaded),
"processing" => Ok(Self::Processing),
"ready" => Ok(Self::Ready),
"error" => Ok(Self::Error),
other => Err(format!("unknown photo status: {other}")),
}
}
}
// #[sqlx(try_from = "String")] needs TryFrom; delegate to FromStr.
impl TryFrom<String> for PhotoStatus {
type Error = String;
fn try_from(value: String) -> Result<Self, String> {
value.parse()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobKind {
ProcessPhoto,
DeleteS3Prefix,
}
impl JobKind {
pub fn as_str(self) -> &'static str {
match self {
Self::ProcessPhoto => "process_photo",
Self::DeleteS3Prefix => "delete_s3_prefix",
}
}
}
impl std::str::FromStr for JobKind {
type Err = String;
fn from_str(value: &str) -> Result<Self, String> {
match value {
"process_photo" => Ok(Self::ProcessPhoto),
"delete_s3_prefix" => Ok(Self::DeleteS3Prefix),
other => Err(format!("unknown job kind: {other}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobStatus {
Queued,
Running,
Done,
Failed,
}
impl JobStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Done => "done",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct Album {
pub id: Uuid,
pub name: String,
pub description: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct Photo {
pub id: Uuid,
pub album_id: Uuid,
pub filename: String,
pub content_type: String,
pub size_bytes: i64,
#[sqlx(try_from = "String")]
pub status: PhotoStatus,
pub error: Option<String>,
pub width: Option<i32>,
pub height: Option<i32>,
pub taken_at: Option<DateTime<Utc>>,
pub processed_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct Share {
pub id: Uuid,
pub album_id: Uuid,
pub token: String,
pub label: String,
#[serde(skip_serializing)]
pub password_hash: Option<String>,
pub allow_download: bool,
pub expires_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
pub failed_attempts: i32,
#[serde(skip_serializing)]
pub locked_until: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
+216
View File
@@ -0,0 +1,216 @@
use std::collections::HashMap;
use axum::extract::{Path, State};
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{Album, JobKind, Photo, PhotoStatus};
use crate::state::AppState;
#[derive(Serialize, sqlx::FromRow)]
pub struct AlbumListItem {
pub id: Uuid,
pub name: String,
pub description: String,
pub created_at: DateTime<Utc>,
pub photo_count: i64,
pub cover_photo_id: Option<Uuid>,
pub cover_processed_at: Option<DateTime<Utc>>,
}
pub async fn list(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<AlbumListItem>>> {
let albums: Vec<AlbumListItem> = sqlx::query_as(
"select a.id, a.name, a.description, a.created_at,
(select count(*) from photos p where p.album_id = a.id) as photo_count,
c.id as cover_photo_id, c.processed_at as cover_processed_at
from albums a
left join lateral (
select p.id, p.processed_at from photos p
where p.album_id = a.id and p.status = $1
order by coalesce(p.taken_at, p.created_at), p.filename
limit 1
) c on true
order by a.created_at desc",
)
.bind(PhotoStatus::Ready.as_str())
.fetch_all(&state.db)
.await?;
Ok(Json(albums))
}
#[derive(Deserialize)]
pub struct CreateAlbum {
name: String,
#[serde(default)]
description: String,
}
pub async fn create(
State(state): State<AppState>,
Json(body): Json<CreateAlbum>,
) -> ApiResult<Json<Album>> {
let name = body.name.trim();
if name.is_empty() {
return Err(ApiError::bad_request("album name is required"));
}
let album: Album =
sqlx::query_as("insert into albums (name, description) values ($1, $2) returning *")
.bind(name)
.bind(body.description.trim())
.fetch_one(&state.db)
.await?;
Ok(Json(album))
}
#[derive(Serialize)]
pub struct ShareRating {
pub share_label: String,
pub rating: i32,
}
#[derive(Serialize)]
pub struct ShareTag {
pub share_label: String,
pub tag: String,
}
#[derive(Serialize, Default)]
pub struct PhotoFeedback {
pub ratings: Vec<ShareRating>,
pub tags: Vec<ShareTag>,
}
#[derive(Serialize)]
pub struct AlbumDetail {
pub album: Album,
pub photos: Vec<Photo>,
pub feedback: HashMap<Uuid, PhotoFeedback>,
}
pub async fn get_one(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<AlbumDetail>> {
let album: Album = sqlx::query_as("select * from albums where id = $1")
.bind(album_id)
.fetch_one(&state.db)
.await?;
let photos: Vec<Photo> = sqlx::query_as(
"select * from photos where album_id = $1
order by coalesce(taken_at, created_at), filename",
)
.bind(album_id)
.fetch_all(&state.db)
.await?;
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as(
"select r.photo_id, s.label, r.rating
from ratings r join shares s on s.id = r.share_id
where s.album_id = $1",
)
.bind(album_id)
.fetch_all(&state.db)
.await?;
for (photo_id, share_label, rating) in ratings {
feedback
.entry(photo_id)
.or_default()
.ratings
.push(ShareRating {
share_label,
rating,
});
}
let tags: Vec<(Uuid, String, String)> = sqlx::query_as(
"select t.photo_id, s.label, t.tag
from tags t join shares s on s.id = t.share_id
where s.album_id = $1
order by t.created_at",
)
.bind(album_id)
.fetch_all(&state.db)
.await?;
for (photo_id, share_label, tag) in tags {
feedback
.entry(photo_id)
.or_default()
.tags
.push(ShareTag { share_label, tag });
}
Ok(Json(AlbumDetail {
album,
photos,
feedback,
}))
}
#[derive(Deserialize)]
pub struct UpdateAlbum {
name: Option<String>,
description: Option<String>,
}
pub async fn update(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
Json(body): Json<UpdateAlbum>,
) -> ApiResult<Json<Album>> {
if let Some(name) = &body.name {
if name.trim().is_empty() {
return Err(ApiError::bad_request("album name cannot be empty"));
}
}
let album: Album = sqlx::query_as(
"update albums
set name = coalesce($2, name), description = coalesce($3, description)
where id = $1
returning *",
)
.bind(album_id)
.bind(body.name.as_deref().map(str::trim))
.bind(body.description.as_deref().map(str::trim))
.fetch_one(&state.db)
.await?;
Ok(Json(album))
}
pub async fn delete(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let mut tx = state.db.begin().await?;
// Lock the album row: concurrent uploads block on their FK check against
// it, then fail once it's gone and clean up their own S3 objects — so no
// photo can slip in between the cleanup enqueue and the cascade delete.
let locked: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1 for update")
.bind(album_id)
.fetch_optional(&mut *tx)
.await?;
if locked.is_none() {
return Err(ApiError::not_found());
}
// Delete photos and enqueue their S3 cleanup atomically, in one statement.
sqlx::query(
"with deleted as (delete from photos where album_id = $1 returning id)
insert into jobs (kind, payload)
select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
from deleted",
)
.bind(album_id)
.bind(JobKind::DeleteS3Prefix.as_str())
.execute(&mut *tx)
.await?;
sqlx::query("delete from albums where id = $1")
.bind(album_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+398
View File
@@ -0,0 +1,398 @@
use std::collections::HashMap;
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use axum_extra::extract::cookie::SignedCookieJar;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{PhotoStatus, Share};
use crate::state::AppState;
pub async fn load_share(state: &AppState, token: &str) -> Result<Share, ApiError> {
let share: Option<Share> = sqlx::query_as("select * from shares where token = $1")
.bind(token)
.fetch_optional(&state.db)
.await?;
let share = share.ok_or_else(ApiError::not_found)?;
if share.expires_at.map(|e| e < Utc::now()).unwrap_or(false) {
return Err(ApiError::gone("this link has expired"));
}
Ok(share)
}
/// One signed cookie lists every share id this browser has been granted
/// (by viewing a passwordless share or unlocking a protected one). Image and
/// download requests are authorized from it, so URLs carry no token.
const SHARE_ACCESS_COOKIE: &str = "photos_shares";
const MAX_REMEMBERED_SHARES: usize = 20;
pub fn share_ids_from_jar(jar: &SignedCookieJar) -> Vec<Uuid> {
jar.get(SHARE_ACCESS_COOKIE)
.map(|c| {
c.value()
.split(',')
.filter_map(|s| Uuid::parse_str(s).ok())
.collect()
})
.unwrap_or_default()
}
/// Add a share to the browser's access cookie (most recent first). Always
/// re-issues the cookie so the 30-day expiry slides on every visit instead of
/// being fixed at the first one.
fn grant_access(state: &AppState, jar: SignedCookieJar, share_id: Uuid) -> SignedCookieJar {
let mut ids = share_ids_from_jar(&jar);
ids.retain(|id| *id != share_id);
ids.insert(0, share_id);
ids.truncate(MAX_REMEMBERED_SHARES);
let value = ids
.iter()
.map(Uuid::to_string)
.collect::<Vec<_>>()
.join(",");
let mut cookie =
crate::auth::base_cookie(SHARE_ACCESS_COOKIE, value, state.config.cookie_secure());
cookie.set_max_age(time::Duration::days(30));
jar.add(cookie)
}
pub fn is_unlocked(jar: &SignedCookieJar, share: &Share) -> bool {
share.password_hash.is_none() || share_ids_from_jar(jar).contains(&share.id)
}
/// Cookie-based authorization for image/download requests, which carry no
/// share token: any remembered, still-valid share covering the album grants
/// access (and must allow downloads when `need_download`).
pub async fn authorize_album_via_cookie(
state: &AppState,
jar: &SignedCookieJar,
album_id: Uuid,
need_download: bool,
) -> Result<(), ApiError> {
let ids = share_ids_from_jar(jar);
if ids.is_empty() {
return Err(ApiError::unauthorized());
}
let shares: Vec<Share> =
sqlx::query_as("select * from shares where album_id = $1 and id = any($2)")
.bind(album_id)
.bind(&ids)
.fetch_all(&state.db)
.await?;
let now = Utc::now();
let valid: Vec<&Share> = shares
.iter()
.filter(|s| s.expires_at.map(|e| e > now).unwrap_or(true))
.collect();
if valid.is_empty() {
return Err(ApiError::unauthorized());
}
if need_download && !valid.iter().any(|s| s.allow_download) {
return Err(ApiError::forbidden("downloads are disabled for this link"));
}
Ok(())
}
/// The one place client-share access policy lives: token valid + not expired,
/// unlocked, and (for download endpoints) downloads enabled.
pub async fn authorize_share(
state: &AppState,
jar: &SignedCookieJar,
token: &str,
need_download: bool,
) -> Result<Share, ApiError> {
let share = load_share(state, token).await?;
if !is_unlocked(jar, &share) {
return Err(ApiError::unauthorized());
}
if need_download && !share.allow_download {
return Err(ApiError::forbidden("downloads are disabled for this link"));
}
Ok(share)
}
fn require_unlocked(jar: &SignedCookieJar, share: &Share) -> Result<(), ApiError> {
if is_unlocked(jar, share) {
Ok(())
} else {
Err(ApiError(
StatusCode::UNAUTHORIZED,
"password required".into(),
))
}
}
#[derive(Serialize, sqlx::FromRow)]
struct ClientPhotoRow {
id: Uuid,
filename: String,
size_bytes: i64,
width: Option<i32>,
height: Option<i32>,
taken_at: Option<DateTime<Utc>>,
processed_at: Option<DateTime<Utc>>,
my_rating: Option<i32>,
}
#[derive(Serialize)]
struct ClientPhoto {
#[serde(flatten)]
row: ClientPhotoRow,
my_tags: Vec<String>,
}
#[derive(Serialize)]
pub struct ShareView {
label: String,
album_name: String,
album_description: String,
locked: bool,
allow_download: bool,
photos: Vec<ClientPhoto>,
}
pub async fn get_share(
State(state): State<AppState>,
Path(token): Path<String>,
jar: SignedCookieJar,
) -> ApiResult<(SignedCookieJar, Json<ShareView>)> {
let share = load_share(&state, &token).await?;
let (album_name, album_description): (String, String) =
sqlx::query_as("select name, description from albums where id = $1")
.bind(share.album_id)
.fetch_one(&state.db)
.await?;
if !is_unlocked(&jar, &share) {
return Ok((
jar,
Json(ShareView {
label: share.label,
album_name,
album_description,
locked: true,
allow_download: share.allow_download,
photos: vec![],
}),
));
}
// Grant this browser image/download access for the share's album.
let jar = grant_access(&state, jar, share.id);
let rows: Vec<ClientPhotoRow> = sqlx::query_as(
"select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating
from photos p
left join ratings r on r.photo_id = p.id and r.share_id = $2
where p.album_id = $1 and p.status = $3
order by coalesce(p.taken_at, p.created_at), p.filename",
)
.bind(share.album_id)
.bind(share.id)
.bind(PhotoStatus::Ready.as_str())
.fetch_all(&state.db)
.await?;
let tag_rows: Vec<(Uuid, String)> =
sqlx::query_as("select photo_id, tag from tags where share_id = $1 order by created_at")
.bind(share.id)
.fetch_all(&state.db)
.await?;
let mut tag_map: HashMap<Uuid, Vec<String>> = HashMap::new();
for (photo_id, tag) in tag_rows {
tag_map.entry(photo_id).or_default().push(tag);
}
let photos = rows
.into_iter()
.map(|row| {
let my_tags = tag_map.remove(&row.id).unwrap_or_default();
ClientPhoto { row, my_tags }
})
.collect();
Ok((
jar,
Json(ShareView {
label: share.label,
album_name,
album_description,
locked: false,
allow_download: share.allow_download,
photos,
}),
))
}
#[derive(Deserialize)]
pub struct UnlockBody {
password: String,
}
const MAX_UNLOCK_ATTEMPTS: i32 = 10;
pub async fn unlock(
State(state): State<AppState>,
Path(token): Path<String>,
jar: SignedCookieJar,
Json(body): Json<UnlockBody>,
) -> ApiResult<(SignedCookieJar, StatusCode)> {
let share = load_share(&state, &token).await?;
let Some(hash) = share.password_hash.clone() else {
return Ok((jar, StatusCode::NO_CONTENT));
};
if let Some(locked_until) = share.locked_until {
if locked_until > Utc::now() {
return Err(ApiError(
StatusCode::TOO_MANY_REQUESTS,
"too many attempts — try again in a few minutes".into(),
));
}
// The lock window has passed: grant a fresh set of attempts, so a
// legitimate client isn't re-locked by their next single typo.
sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
.bind(share.id)
.execute(&state.db)
.await?;
}
// Argon2 is deliberately slow; keep it off the async runtime threads.
let password = body.password;
let verified = tokio::task::spawn_blocking(move || {
let parsed = PasswordHash::new(&hash).map_err(|e| anyhow::anyhow!("bad hash: {e}"))?;
Ok::<_, anyhow::Error>(
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok(),
)
})
.await
.map_err(|e| anyhow::anyhow!("verify task failed: {e}"))??;
if !verified {
sqlx::query(
"update shares
set failed_attempts = failed_attempts + 1,
locked_until = case when failed_attempts + 1 >= $2
then now() + interval '15 minutes'
else locked_until end
where id = $1",
)
.bind(share.id)
.bind(MAX_UNLOCK_ATTEMPTS)
.execute(&state.db)
.await?;
return Err(ApiError(StatusCode::UNAUTHORIZED, "wrong password".into()));
}
sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
.bind(share.id)
.execute(&state.db)
.await?;
Ok((grant_access(&state, jar, share.id), StatusCode::NO_CONTENT))
}
async fn share_photo(
state: &AppState,
jar: &SignedCookieJar,
token: &str,
photo_id: Uuid,
) -> Result<Share, ApiError> {
let share = load_share(state, token).await?;
require_unlocked(jar, &share)?;
let exists: Option<(Uuid,)> = sqlx::query_as(
"select id from photos where id = $1 and album_id = $2 and status = $3",
)
.bind(photo_id)
.bind(share.album_id)
.bind(PhotoStatus::Ready.as_str())
.fetch_optional(&state.db)
.await?;
if exists.is_none() {
return Err(ApiError::not_found());
}
Ok(share)
}
#[derive(Deserialize)]
pub struct RatingBody {
rating: i32,
}
pub async fn set_rating(
State(state): State<AppState>,
Path((token, photo_id)): Path<(String, Uuid)>,
jar: SignedCookieJar,
Json(body): Json<RatingBody>,
) -> ApiResult<Json<serde_json::Value>> {
if !(0..=5).contains(&body.rating) {
return Err(ApiError::bad_request("rating must be between 0 and 5"));
}
let share = share_photo(&state, &jar, &token, photo_id).await?;
if body.rating == 0 {
sqlx::query("delete from ratings where share_id = $1 and photo_id = $2")
.bind(share.id)
.bind(photo_id)
.execute(&state.db)
.await?;
} else {
sqlx::query(
"insert into ratings (share_id, photo_id, rating) values ($1, $2, $3)
on conflict (share_id, photo_id)
do update set rating = excluded.rating, updated_at = now()",
)
.bind(share.id)
.bind(photo_id)
.bind(body.rating)
.execute(&state.db)
.await?;
}
Ok(Json(serde_json::json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct TagsBody {
tags: Vec<String>,
}
pub async fn set_tags(
State(state): State<AppState>,
Path((token, photo_id)): Path<(String, Uuid)>,
jar: SignedCookieJar,
Json(body): Json<TagsBody>,
) -> ApiResult<Json<serde_json::Value>> {
let share = share_photo(&state, &jar, &token, photo_id).await?;
let mut tags: Vec<String> = Vec::new();
for tag in body.tags {
let tag = tag.trim().to_lowercase();
if tag.is_empty() || tag.chars().count() > 40 {
continue;
}
if !tags.contains(&tag) {
tags.push(tag);
}
if tags.len() >= 20 {
break;
}
}
let mut tx = state.db.begin().await?;
sqlx::query("delete from tags where share_id = $1 and photo_id = $2")
.bind(share.id)
.bind(photo_id)
.execute(&mut *tx)
.await?;
if !tags.is_empty() {
sqlx::query("insert into tags (share_id, photo_id, tag) select $1, $2, unnest($3::text[])")
.bind(share.id)
.bind(photo_id)
.bind(&tags)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true, "tags": tags })))
}
+105
View File
@@ -0,0 +1,105 @@
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::{header, StatusCode};
use axum::response::Response;
use axum_extra::extract::cookie::SignedCookieJar;
use tokio_util::io::ReaderStream;
use uuid::Uuid;
use crate::auth::user_from_jar;
use crate::error::{ApiError, ApiResult};
use crate::models::{Photo, PhotoStatus};
use crate::routes::client::authorize_album_via_cookie;
use crate::s3;
use crate::state::AppState;
/// Allow access if the requester is the signed-in photographer, or holds a
/// share-access cookie (set when viewing/unlocking a share) covering the
/// photo's album.
async fn authorize_photo(
state: &AppState,
jar: &SignedCookieJar,
photo_id: Uuid,
need_download: bool,
) -> Result<Photo, ApiError> {
let photo: Option<Photo> = sqlx::query_as("select * from photos where id = $1")
.bind(photo_id)
.fetch_optional(&state.db)
.await?;
let photo = photo.ok_or_else(ApiError::not_found)?;
if user_from_jar(state, jar).is_some() {
return Ok(photo);
}
authorize_album_via_cookie(state, jar, photo.album_id, need_download).await?;
// Clients may only reach photos the share listing exposes.
if photo.status != PhotoStatus::Ready {
return Err(ApiError::not_found());
}
Ok(photo)
}
async fn stream_object(
state: &AppState,
key: &str,
content_type: &str,
attachment_name: Option<&str>,
) -> Result<Response, ApiError> {
let object = state
.s3
.get_object()
.bucket(&state.config.s3_bucket)
.key(key)
.send()
.await
.map_err(|e| {
tracing::warn!("s3 get {key} failed: {e}");
ApiError::not_found()
})?;
let mut builder = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(
header::CACHE_CONTROL,
"private, max-age=31536000, immutable",
);
if let Some(length) = object.content_length() {
builder = builder.header(header::CONTENT_LENGTH, length);
}
if let Some(name) = attachment_name {
let safe = name.replace(['"', '\\'], "_");
builder = builder.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{safe}\""),
);
}
let stream = ReaderStream::new(object.body.into_async_read());
builder
.body(Body::from_stream(stream))
.map_err(|e| anyhow::anyhow!("building response: {e}").into())
}
pub async fn serve(
State(state): State<AppState>,
Path((photo_id, size)): Path<(Uuid, String)>,
jar: SignedCookieJar,
) -> ApiResult<Response> {
let key = match size.as_str() {
"thumb" => s3::thumb_key(photo_id),
"preview" => s3::preview_key(photo_id),
_ => return Err(ApiError::bad_request("size must be thumb or preview")),
};
authorize_photo(&state, &jar, photo_id, false).await?;
stream_object(&state, &key, "image/jpeg", None).await
}
pub async fn original(
State(state): State<AppState>,
Path(photo_id): Path<Uuid>,
jar: SignedCookieJar,
) -> ApiResult<Response> {
let photo = authorize_photo(&state, &jar, photo_id, true).await?;
let key = s3::original_key(photo_id, &photo.filename);
stream_object(&state, &key, &photo.content_type, Some(&photo.filename)).await
}
+114
View File
@@ -0,0 +1,114 @@
pub mod albums;
pub mod client;
pub mod images;
pub mod photos;
pub mod shares;
pub mod zip;
use axum::extract::{DefaultBodyLimit, Request, State};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get, post, put};
use axum::{Json, Router};
use axum_extra::extract::cookie::SignedCookieJar;
use crate::auth;
use crate::error::ApiError;
use crate::state::AppState;
const MAX_UPLOAD_BYTES: usize = 4 * 1024 * 1024 * 1024;
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({ "ok": true }))
}
/// Unknown /api paths must 404 as JSON, not fall through to the SPA's index.html.
async fn api_not_found() -> ApiError {
ApiError::not_found()
}
/// Wrong method on a known route gets a JSON 405 (not an empty body).
async fn method_not_allowed() -> ApiError {
ApiError(
axum::http::StatusCode::METHOD_NOT_ALLOWED,
"method not allowed".into(),
)
}
/// Every route in the admin router passes through this layer, so photographer
/// auth is structural — a new admin endpoint cannot be forgotten open. The
/// authenticated user is stored in request extensions for handlers that need
/// the identity.
async fn require_photographer(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, ApiError> {
let jar = SignedCookieJar::from_headers(request.headers(), state.cookie_key.clone());
let user = auth::user_from_jar(&state, &jar).ok_or_else(ApiError::unauthorized)?;
request.extensions_mut().insert(user);
// Slide the session: past half-life, responses carry a fresh cookie.
let refreshed = auth::refreshed_session(&state, &jar);
let response = next.run(request).await;
Ok(match refreshed {
Some(cookie) => (jar.add(cookie), response).into_response(),
None => response,
})
}
pub fn router(state: &AppState) -> Router<AppState> {
// Photographer-only surface. Add new admin endpoints HERE — the auth
// layer covers them automatically.
let admin = Router::new()
.route("/api/me", get(auth::me))
.route("/api/albums", get(albums::list).post(albums::create))
.route(
"/api/albums/{id}",
get(albums::get_one)
.patch(albums::update)
.delete(albums::delete),
)
.route(
"/api/albums/{id}/photos",
post(photos::upload).layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)),
)
.route(
"/api/albums/{id}/shares",
get(shares::list).post(shares::create),
)
.route("/api/albums/{id}/zip", post(zip::album_zip))
.route("/api/photos/{id}", delete(photos::delete))
.route("/api/photos/{id}/reprocess", post(photos::reprocess))
.route("/api/shares/{id}", delete(shares::delete))
.route("/api/shares/{id}/reset-lock", post(shares::reset_lock))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_photographer,
));
// Public surface: health, the auth flow itself, and client-share
// endpoints (self-authorizing via token or share-access cookie).
Router::new()
.route("/api/health", get(health))
.route("/api/auth/login", get(auth::login))
.route("/api/auth/callback", get(auth::callback))
.route("/api/auth/logout", post(auth::logout))
.route("/api/share/{token}", get(client::get_share))
.route("/api/share/{token}/unlock", post(client::unlock))
.route(
"/api/share/{token}/photos/{photo_id}/rating",
put(client::set_rating),
)
.route(
"/api/share/{token}/photos/{photo_id}/tags",
put(client::set_tags),
)
.route("/api/share/{token}/zip", post(zip::share_zip))
// Dual-auth (photographer session OR share cookie), checked in-handler:
.route("/api/img/{id}/{size}", get(images::serve))
.route("/api/photos/{id}/original", get(images::original))
.merge(admin)
.route("/api", any(api_not_found))
.route("/api/{*path}", any(api_not_found))
.method_not_allowed_fallback(method_not_allowed)
}
+174
View File
@@ -0,0 +1,174 @@
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::header::CONTENT_TYPE;
use axum::http::HeaderMap;
use axum::Json;
use futures::StreamExt;
use serde::Deserialize;
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::jobs;
use crate::models::{JobKind, Photo, PhotoStatus};
use crate::s3;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct UploadQuery {
filename: String,
}
fn sanitize_filename(raw: &str) -> Result<String, ApiError> {
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
let cleaned: String = base
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ' ' | '(' | ')') {
c
} else {
'_'
}
})
.collect();
let cleaned = cleaned.trim().trim_start_matches('.').to_string();
if cleaned.is_empty() {
return Err(ApiError::bad_request("invalid filename"));
}
Ok(cleaned.chars().take(150).collect())
}
pub async fn upload(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
Query(query): Query<UploadQuery>,
headers: HeaderMap,
body: Body,
) -> ApiResult<Json<Photo>> {
let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
.bind(album_id)
.fetch_optional(&state.db)
.await?;
if album_exists.is_none() {
return Err(ApiError::not_found());
}
let filename = sanitize_filename(&query.filename)?;
let content_type = headers
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
// Stream the request body to a temp file so large raws never sit in memory.
let dir = tempfile::tempdir().map_err(anyhow::Error::from)?;
let path = dir.path().join("upload.bin");
let mut file = tokio::fs::File::create(&path)
.await
.map_err(anyhow::Error::from)?;
let mut stream = body.into_data_stream();
let mut size: i64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| ApiError::bad_request(format!("upload aborted: {e}")))?;
size += chunk.len() as i64;
file.write_all(&chunk).await.map_err(anyhow::Error::from)?;
}
file.flush().await.map_err(anyhow::Error::from)?;
drop(file);
if size == 0 {
return Err(ApiError::bad_request("empty upload"));
}
// Upload to S3 first, then create the row and enqueue processing in one
// transaction — a photo row can never exist without its job, and a failed
// transaction (e.g. album deleted mid-upload) cleans up the S3 object.
let photo_id = Uuid::new_v4();
let key = s3::original_key(photo_id, &filename);
s3::put_file(&state, &key, &path, &content_type).await?;
let result: ApiResult<(sqlx::Transaction<'static, sqlx::Postgres>, Photo)> = async {
let mut tx = state.db.begin().await?;
let photo: Photo = sqlx::query_as(
"insert into photos (id, album_id, filename, content_type, size_bytes, status)
values ($1, $2, $3, $4, $5, $6)
returning *",
)
.bind(photo_id)
.bind(album_id)
.bind(&filename)
.bind(&content_type)
.bind(size)
.bind(PhotoStatus::Uploaded.as_str())
.fetch_one(&mut *tx)
.await?;
jobs::enqueue(
&mut *tx,
JobKind::ProcessPhoto,
serde_json::json!({ "photo_id": photo_id }),
)
.await?;
Ok((tx, photo))
}
.await;
let (tx, photo) = match result {
Ok(pair) => pair,
Err(e) => {
// Nothing committed — safe to remove the freshly stored original.
if let Err(cleanup) = s3::delete_prefix(&state, &s3::photo_prefix(photo_id)).await {
tracing::error!("failed to clean up s3 after aborted upload: {cleanup:#}");
}
return Err(e);
}
};
if let Err(e) = tx.commit().await {
// A failed COMMIT is ambiguous (it may have been applied); deleting
// the S3 object here could destroy a committed photo's original, so
// leave it — an orphaned object beats data loss.
tracing::error!(
"commit failed after upload of photo {photo_id}; leaving s3 object in place: {e}"
);
return Err(anyhow::Error::from(e).context("saving upload").into());
}
Ok(Json(photo))
}
pub async fn delete(
State(state): State<AppState>,
Path(photo_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let mut tx = state.db.begin().await?;
let deleted = sqlx::query("delete from photos where id = $1")
.bind(photo_id)
.execute(&mut *tx)
.await?;
if deleted.rows_affected() == 0 {
return Err(ApiError::not_found());
}
jobs::enqueue(
&mut *tx,
JobKind::DeleteS3Prefix,
serde_json::json!({ "prefix": s3::photo_prefix(photo_id) }),
)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn reprocess(
State(state): State<AppState>,
Path(photo_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let mut tx = state.db.begin().await?;
let updated = sqlx::query("update photos set status = $2, error = null where id = $1")
.bind(photo_id)
.bind(PhotoStatus::Uploaded.as_str())
.execute(&mut *tx)
.await?;
if updated.rows_affected() == 0 {
return Err(ApiError::not_found());
}
jobs::ensure_process_photo(&mut tx, photo_id).await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+159
View File
@@ -0,0 +1,159 @@
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHasher};
use axum::extract::{Path, State};
use axum::Json;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::random_token;
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
#[derive(sqlx::FromRow)]
struct ShareAdminRow {
id: Uuid,
token: String,
label: String,
password_hash: Option<String>,
allow_download: bool,
expires_at: Option<DateTime<Utc>>,
locked_until: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
rating_count: i64,
tag_count: i64,
}
fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
serde_json::json!({
"id": row.id,
"token": row.token,
"url": format!("{}/s/{}", state.config.public_url, row.token),
"label": row.label,
"has_password": row.password_hash.is_some(),
"allow_download": row.allow_download,
"expires_at": row.expires_at,
"locked": row.locked_until.map(|t| t > Utc::now()).unwrap_or(false),
"created_at": row.created_at,
"rating_count": row.rating_count,
"tag_count": row.tag_count,
})
}
const SHARE_COLUMNS: &str = "s.id, s.token, s.label, s.password_hash, s.allow_download,
s.expires_at, s.locked_until, s.created_at,
(select count(*) from ratings r where r.share_id = s.id) as rating_count,
(select count(*) from tags t where t.share_id = s.id) as tag_count";
pub async fn list(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<Vec<serde_json::Value>>> {
let rows: Vec<ShareAdminRow> = sqlx::query_as(&format!(
"select {SHARE_COLUMNS} from shares s where s.album_id = $1 order by s.created_at desc"
))
.bind(album_id)
.fetch_all(&state.db)
.await?;
Ok(Json(rows.iter().map(|r| share_json(&state, r)).collect()))
}
#[derive(Deserialize)]
pub struct CreateShare {
#[serde(default)]
label: String,
password: Option<String>,
#[serde(default = "default_true")]
allow_download: bool,
expires_at: Option<DateTime<Utc>>,
}
fn default_true() -> bool {
true
}
pub async fn create(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
Json(body): Json<CreateShare>,
) -> ApiResult<Json<serde_json::Value>> {
let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
.bind(album_id)
.fetch_optional(&state.db)
.await?;
if album_exists.is_none() {
return Err(ApiError::not_found());
}
let password_hash = match body.password.as_deref().map(str::trim) {
Some(pw) if !pw.is_empty() => {
// Argon2 is deliberately slow; keep it off the async runtime threads.
let pw = pw.to_string();
let hash = tokio::task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(pw.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| anyhow::anyhow!("hashing password: {e}"))
})
.await
.map_err(|e| anyhow::anyhow!("hash task failed: {e}"))??;
Some(hash)
}
_ => None,
};
let token = random_token(24);
let (share_id,): (Uuid,) = sqlx::query_as(
"insert into shares (album_id, token, label, password_hash, allow_download, expires_at)
values ($1, $2, $3, $4, $5, $6)
returning id",
)
.bind(album_id)
.bind(&token)
.bind(body.label.trim())
.bind(&password_hash)
.bind(body.allow_download)
.bind(body.expires_at)
.fetch_one(&state.db)
.await?;
let row: ShareAdminRow =
sqlx::query_as(&format!("select {SHARE_COLUMNS} from shares s where s.id = $1"))
.bind(share_id)
.fetch_one(&state.db)
.await?;
Ok(Json(share_json(&state, &row)))
}
/// Clear a share's password-lockout state (e.g. after a client fat-fingered
/// their way into the 15-minute lock).
pub async fn reset_lock(
State(state): State<AppState>,
Path(share_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let updated =
sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
.bind(share_id)
.execute(&state.db)
.await?;
if updated.rows_affected() == 0 {
return Err(ApiError::not_found());
}
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn delete(
State(state): State<AppState>,
Path(share_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let deleted = sqlx::query("delete from shares where id = $1")
.bind(share_id)
.execute(&state.db)
.await?;
if deleted.rows_affected() == 0 {
return Err(ApiError::not_found());
}
Ok(Json(serde_json::json!({ "ok": true })))
}
+423
View File
@@ -0,0 +1,423 @@
//! Streaming ZIP downloads, hand-written for spec compliance.
//!
//! Every entry is Stored (raws/jpegs don't compress) with its exact size and
//! CRC-32 in the local file header — no data descriptors — so the archives
//! work with strict *streaming* extractors (Java ZipInputStream, bsdtar from
//! a pipe), not just central-directory readers. Sizes are known up front,
//! which also makes the total byte length deterministic: the response carries
//! a real Content-Length, so browsers show progress and flag truncated
//! downloads as failed.
//!
//! Each file is spooled from S3 to an anonymous temp file to compute its CRC
//! before its header is written; the next file spools while the current one
//! streams out, so S3 latency doesn't stall the download.
use std::collections::HashSet;
use axum::body::{Body, Bytes};
use axum::extract::{Form, Path, State};
use axum::http::{header, StatusCode};
use axum::response::Response;
use axum_extra::extract::cookie::SignedCookieJar;
use chrono::{DateTime, Datelike, Timelike, Utc};
use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{Photo, PhotoStatus};
use crate::routes::client::authorize_share;
use crate::s3;
use crate::state::AppState;
const U32_SENTINEL: u64 = 0xFFFF_FFFF;
/// Sent as a plain form POST (not fetch) so the browser streams the download
/// natively; `ids` is a comma-separated list, empty = every ready photo.
#[derive(Deserialize)]
pub struct ZipRequest {
#[serde(default)]
ids: String,
}
/// Strict: a present-but-malformed id is a 400, never silently reinterpreted
/// as the download-everything sentinel.
fn parse_ids(raw: &str) -> Result<Vec<Uuid>, ApiError> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| {
Uuid::parse_str(s).map_err(|_| ApiError::bad_request(format!("invalid photo id: {s}")))
})
.collect()
}
async fn ready_photos(
state: &AppState,
album_id: Uuid,
ids: &[Uuid],
) -> Result<Vec<Photo>, sqlx::Error> {
sqlx::query_as(
"select * from photos
where album_id = $1 and status = $3
and (cardinality($2::uuid[]) = 0 or id = any($2))
order by coalesce(taken_at, created_at), filename",
)
.bind(album_id)
.bind(ids)
.bind(PhotoStatus::Ready.as_str())
.fetch_all(&state.db)
.await
}
async fn album_name(state: &AppState, album_id: Uuid) -> Result<String, ApiError> {
let name: Option<(String,)> = sqlx::query_as("select name from albums where id = $1")
.bind(album_id)
.fetch_optional(&state.db)
.await?;
Ok(name.ok_or_else(ApiError::not_found)?.0)
}
pub async fn album_zip(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
Form(request): Form<ZipRequest>,
) -> ApiResult<Response> {
let name = album_name(&state, album_id).await?;
let photos = ready_photos(&state, album_id, &parse_ids(&request.ids)?).await?;
stream_zip(state, photos, &name)
}
pub async fn share_zip(
State(state): State<AppState>,
Path(token): Path<String>,
jar: SignedCookieJar,
Form(request): Form<ZipRequest>,
) -> ApiResult<Response> {
let share = authorize_share(&state, &jar, &token, true).await?;
let name = album_name(&state, share.album_id).await?;
let photos = ready_photos(&state, share.album_id, &parse_ids(&request.ids)?).await?;
stream_zip(state, photos, &name)
}
/// Dedupe case-insensitively — archives are extracted onto case-insensitive
/// filesystems (macOS/Windows), where "DSC1.JPG" and "dsc1.jpg" would collide.
fn unique_entry_name(used: &mut HashSet<String>, filename: &str) -> String {
if used.insert(filename.to_lowercase()) {
return filename.to_string();
}
let (stem, ext) = match filename.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
_ => (filename, String::new()),
};
for n in 2.. {
let candidate = format!("{stem} ({n}){ext}");
if used.insert(candidate.to_lowercase()) {
return candidate;
}
}
unreachable!()
}
/// MS-DOS timestamp (2-second resolution, no timezone; years 1980+ only —
/// callers clamp earlier dates).
fn dos_datetime(t: DateTime<Utc>) -> (u16, u16) {
let time = ((t.hour() as u16) << 11) | ((t.minute() as u16) << 5) | (t.second() as u16 / 2);
let date = (((t.year() - 1980) as u16) << 9) | ((t.month() as u16) << 5) | (t.day() as u16);
(time, date)
}
struct Entry {
photo_id: Uuid,
s3_key: String,
name: Vec<u8>,
size: u64,
offset: u64,
dos_time: u16,
dos_date: u16,
}
struct ZipPlan {
entries: Vec<Entry>,
cd_offset: u64,
cd_size: u64,
zip64_eocd: bool,
total_len: u64,
}
fn plan_zip(photos: &[Photo]) -> Result<ZipPlan, ApiError> {
let mut used_names = HashSet::new();
let mut entries = Vec::with_capacity(photos.len());
let mut offset: u64 = 0;
for photo in photos {
let size = photo.size_bytes as u64;
if size >= U32_SENTINEL {
return Err(ApiError::bad_request(format!(
"{} is too large for a zip download",
photo.filename
)));
}
let name = unique_entry_name(&mut used_names, &photo.filename).into_bytes();
// DOS timestamps can't represent pre-1980 dates (cameras with unset
// clocks); fall back to the upload time.
let timestamp = photo
.taken_at
.filter(|t| t.year() >= 1980)
.unwrap_or(photo.created_at);
let (dos_time, dos_date) = dos_datetime(timestamp);
let entry_offset = offset;
offset += 30 + name.len() as u64 + size;
entries.push(Entry {
photo_id: photo.id,
s3_key: s3::original_key(photo.id, &photo.filename),
name,
size,
offset: entry_offset,
dos_time,
dos_date,
});
}
let cd_offset = offset;
let cd_size: u64 = entries
.iter()
.map(|e| 46 + e.name.len() as u64 + if e.offset >= U32_SENTINEL { 12 } else { 0 })
.sum();
let zip64_eocd = entries.len() >= 0xFFFF
|| cd_size >= U32_SENTINEL
|| cd_offset >= U32_SENTINEL;
let total_len = cd_offset + cd_size + 22 + if zip64_eocd { 56 + 20 } else { 0 };
Ok(ZipPlan {
entries,
cd_offset,
cd_size,
zip64_eocd,
total_len,
})
}
fn stream_zip(state: AppState, photos: Vec<Photo>, album_name: &str) -> ApiResult<Response> {
if photos.is_empty() {
return Err(ApiError::bad_request("no downloadable photos selected"));
}
let plan = plan_zip(&photos)?;
let permit = state.zip_permits.clone().try_acquire_owned().map_err(|_| {
ApiError(
StatusCode::SERVICE_UNAVAILABLE,
"too many downloads in progress — try again in a moment".into(),
)
})?;
let zip_name: String = album_name
.trim()
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' })
.take(80)
.collect();
let zip_name = if zip_name.is_empty() { "photos".to_string() } else { zip_name };
let total_len = plan.total_len;
let (writer, reader) = tokio::io::duplex(256 * 1024);
let write_task = tokio::spawn(async move {
let result = write_zip(&state, plan, writer).await;
drop(permit);
result
});
// Forward bytes to the response; when the writer finishes, surface any
// zip error as a stream error so hyper ABORTS the connection — combined
// with the exact Content-Length, browsers report truncation as a failed
// download instead of keeping a silently corrupt file.
struct Pump {
reader: tokio::io::DuplexStream,
task: Option<JoinHandle<anyhow::Result<()>>>,
}
let pump = Pump {
reader,
task: Some(write_task),
};
let stream = futures::stream::unfold(pump, |mut pump| async move {
pump.task.as_ref()?; // stream is over after a terminal item
let mut buf = vec![0u8; 64 * 1024];
match pump.reader.read(&mut buf).await {
Ok(0) => {
let task = pump.task.take()?;
match task.await {
Ok(Ok(())) => None,
Ok(Err(e)) => Some((
Err(std::io::Error::other(format!("zip stream failed: {e:#}"))),
pump,
)),
Err(e) => Some((
Err(std::io::Error::other(format!("zip task panicked: {e}"))),
pump,
)),
}
}
Ok(n) => {
buf.truncate(n);
Some((Ok(Bytes::from(buf)), pump))
}
Err(e) => {
pump.task = None;
Some((Err(e), pump))
}
}
});
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/zip")
.header(header::CONTENT_LENGTH, total_len)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{zip_name}.zip\""),
)
.body(Body::from_stream(stream))
.map_err(|e| anyhow::anyhow!("building response: {e}").into())
}
/// Download an original into an anonymous temp file, computing its CRC-32 and
/// verifying the byte count matches what the zip plan promised.
fn spool(state: &AppState, key: String, expected_size: u64) -> JoinHandle<anyhow::Result<(tokio::fs::File, u32)>> {
let state = state.clone();
tokio::spawn(async move {
let object = state
.s3
.get_object()
.bucket(&state.config.s3_bucket)
.key(&key)
.send()
.await
.map_err(|e| anyhow::anyhow!("fetching {key}: {e}"))?;
let mut file = tokio::fs::File::from_std(tempfile::tempfile()?);
let mut reader = object.body.into_async_read();
let mut hasher = crc32fast::Hasher::new();
let mut written: u64 = 0;
let mut buf = vec![0u8; 128 * 1024];
loop {
let n = reader.read(&mut buf).await?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
file.write_all(&buf[..n]).await?;
written += n as u64;
}
anyhow::ensure!(
written == expected_size,
"{key} is {written} bytes in s3 but {expected_size} in the database"
);
file.flush().await?;
file.seek(std::io::SeekFrom::Start(0)).await?;
Ok((file, hasher.finalize()))
})
}
async fn write_zip(
state: &AppState,
plan: ZipPlan,
mut out: tokio::io::DuplexStream,
) -> anyhow::Result<()> {
// UTF-8 filename flag; no data descriptors (bit 3 unset).
const FLAGS: u16 = 0x0800;
let mut crcs = Vec::with_capacity(plan.entries.len());
// Prefetch: spool the next object from S3 while streaming the current one.
let mut pending: Option<JoinHandle<anyhow::Result<(tokio::fs::File, u32)>>> = None;
for (i, entry) in plan.entries.iter().enumerate() {
let current = match pending.take() {
Some(handle) => handle,
None => spool(state, entry.s3_key.clone(), entry.size),
};
if let Some(next) = plan.entries.get(i + 1) {
pending = Some(spool(state, next.s3_key.clone(), next.size));
}
let (mut file, crc) = current
.await
.map_err(|e| anyhow::anyhow!("spool task failed: {e}"))?
.map_err(|e| anyhow::anyhow!("photo {}: {e:#}", entry.photo_id))?;
crcs.push(crc);
let mut lfh = Vec::with_capacity(30 + entry.name.len());
lfh.extend_from_slice(&0x04034b50u32.to_le_bytes());
lfh.extend_from_slice(&20u16.to_le_bytes()); // version needed
lfh.extend_from_slice(&FLAGS.to_le_bytes());
lfh.extend_from_slice(&0u16.to_le_bytes()); // method: stored
lfh.extend_from_slice(&entry.dos_time.to_le_bytes());
lfh.extend_from_slice(&entry.dos_date.to_le_bytes());
lfh.extend_from_slice(&crc.to_le_bytes());
lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // compressed
lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // uncompressed
lfh.extend_from_slice(&(entry.name.len() as u16).to_le_bytes());
lfh.extend_from_slice(&0u16.to_le_bytes()); // extra len
lfh.extend_from_slice(&entry.name);
out.write_all(&lfh).await?;
tokio::io::copy(&mut file, &mut out).await?;
}
// Central directory.
for (entry, crc) in plan.entries.iter().zip(&crcs) {
let zip64_offset = entry.offset >= U32_SENTINEL;
let mut cdh = Vec::with_capacity(46 + entry.name.len() + 12);
cdh.extend_from_slice(&0x02014b50u32.to_le_bytes());
cdh.extend_from_slice(&0x031Eu16.to_le_bytes()); // made by: unix, 3.0
cdh.extend_from_slice(&(if zip64_offset { 45u16 } else { 20u16 }).to_le_bytes());
cdh.extend_from_slice(&FLAGS.to_le_bytes());
cdh.extend_from_slice(&0u16.to_le_bytes()); // method: stored
cdh.extend_from_slice(&entry.dos_time.to_le_bytes());
cdh.extend_from_slice(&entry.dos_date.to_le_bytes());
cdh.extend_from_slice(&crc.to_le_bytes());
cdh.extend_from_slice(&(entry.size as u32).to_le_bytes());
cdh.extend_from_slice(&(entry.size as u32).to_le_bytes());
cdh.extend_from_slice(&(entry.name.len() as u16).to_le_bytes());
cdh.extend_from_slice(&(if zip64_offset { 12u16 } else { 0u16 }).to_le_bytes()); // extra len
cdh.extend_from_slice(&0u16.to_le_bytes()); // comment len
cdh.extend_from_slice(&0u16.to_le_bytes()); // disk number
cdh.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
cdh.extend_from_slice(&(0o100644u32 << 16).to_le_bytes()); // unix -rw-r--r--
let offset32 = if zip64_offset { U32_SENTINEL as u32 } else { entry.offset as u32 };
cdh.extend_from_slice(&offset32.to_le_bytes());
cdh.extend_from_slice(&entry.name);
if zip64_offset {
cdh.extend_from_slice(&0x0001u16.to_le_bytes()); // zip64 extra field
cdh.extend_from_slice(&8u16.to_le_bytes());
cdh.extend_from_slice(&entry.offset.to_le_bytes());
}
out.write_all(&cdh).await?;
}
// End of central directory (zip64 variants only when values overflow).
let mut tail = Vec::with_capacity(98);
if plan.zip64_eocd {
let entries = plan.entries.len() as u64;
tail.extend_from_slice(&0x06064b50u32.to_le_bytes());
tail.extend_from_slice(&44u64.to_le_bytes()); // record size
tail.extend_from_slice(&0x031Eu16.to_le_bytes()); // made by
tail.extend_from_slice(&45u16.to_le_bytes()); // version needed
tail.extend_from_slice(&0u32.to_le_bytes()); // this disk
tail.extend_from_slice(&0u32.to_le_bytes()); // cd disk
tail.extend_from_slice(&entries.to_le_bytes());
tail.extend_from_slice(&entries.to_le_bytes());
tail.extend_from_slice(&plan.cd_size.to_le_bytes());
tail.extend_from_slice(&plan.cd_offset.to_le_bytes());
// zip64 EOCD locator
tail.extend_from_slice(&0x07064b50u32.to_le_bytes());
tail.extend_from_slice(&0u32.to_le_bytes());
tail.extend_from_slice(&(plan.cd_offset + plan.cd_size).to_le_bytes());
tail.extend_from_slice(&1u32.to_le_bytes());
}
let clamp16 = |v: u64| -> u16 { v.min(0xFFFF) as u16 };
let clamp32 = |v: u64| -> u32 { v.min(U32_SENTINEL) as u32 };
tail.extend_from_slice(&0x06054b50u32.to_le_bytes());
tail.extend_from_slice(&0u16.to_le_bytes()); // this disk
tail.extend_from_slice(&0u16.to_le_bytes()); // cd disk
tail.extend_from_slice(&clamp16(plan.entries.len() as u64).to_le_bytes());
tail.extend_from_slice(&clamp16(plan.entries.len() as u64).to_le_bytes());
tail.extend_from_slice(&clamp32(plan.cd_size).to_le_bytes());
tail.extend_from_slice(&clamp32(plan.cd_offset).to_le_bytes());
tail.extend_from_slice(&0u16.to_le_bytes()); // comment len
out.write_all(&tail).await?;
out.shutdown().await?;
Ok(())
}
+113
View File
@@ -0,0 +1,113 @@
use std::path::Path;
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{Delete, ObjectIdentifier};
use aws_sdk_s3::Client;
use uuid::Uuid;
use crate::config::Config;
use crate::state::AppState;
pub fn client(config: &Config) -> Client {
let credentials = Credentials::new(
&config.s3_access_key,
&config.s3_secret_key,
None,
None,
"photos-config",
);
let mut builder = aws_sdk_s3::Config::builder()
.behavior_version(BehaviorVersion::latest())
.credentials_provider(credentials)
.region(Region::new(config.s3_region.clone()))
.force_path_style(config.s3_force_path_style);
if let Some(endpoint) = &config.s3_endpoint {
builder = builder.endpoint_url(endpoint);
}
Client::from_conf(builder.build())
}
pub fn original_key(photo_id: Uuid, filename: &str) -> String {
format!("photos/{photo_id}/original/{filename}")
}
pub fn preview_key(photo_id: Uuid) -> String {
format!("photos/{photo_id}/preview.jpg")
}
pub fn thumb_key(photo_id: Uuid) -> String {
format!("photos/{photo_id}/thumb.jpg")
}
pub fn photo_prefix(photo_id: Uuid) -> String {
format!("photos/{photo_id}/")
}
pub async fn put_file(
state: &AppState,
key: &str,
path: &Path,
content_type: &str,
) -> anyhow::Result<()> {
let body = ByteStream::from_path(path).await?;
state
.s3
.put_object()
.bucket(&state.config.s3_bucket)
.key(key)
.content_type(content_type)
.body(body)
.send()
.await?;
Ok(())
}
pub async fn put_bytes(
state: &AppState,
key: &str,
bytes: Vec<u8>,
content_type: &str,
) -> anyhow::Result<()> {
state
.s3
.put_object()
.bucket(&state.config.s3_bucket)
.key(key)
.content_type(content_type)
.body(ByteStream::from(bytes))
.send()
.await?;
Ok(())
}
pub async fn delete_prefix(state: &AppState, prefix: &str) -> anyhow::Result<()> {
loop {
let list = state
.s3
.list_objects_v2()
.bucket(&state.config.s3_bucket)
.prefix(prefix)
.send()
.await?;
let objects: Vec<ObjectIdentifier> = list
.contents()
.iter()
.filter_map(|o| o.key())
.map(|k| ObjectIdentifier::builder().key(k).build())
.collect::<Result<_, _>>()?;
if objects.is_empty() {
return Ok(());
}
state
.s3
.delete_objects()
.bucket(&state.config.s3_bucket)
.delete(Delete::builder().set_objects(Some(objects)).build()?)
.send()
.await?;
if !list.is_truncated().unwrap_or(false) {
return Ok(());
}
}
}
+51
View File
@@ -0,0 +1,51 @@
use std::sync::Arc;
use axum::extract::FromRef;
use axum_extra::extract::cookie::Key;
use sha2::{Digest, Sha512};
use sqlx::PgPool;
use tokio::sync::OnceCell;
use crate::auth::OidcDiscovery;
use crate::config::Config;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub s3: aws_sdk_s3::Client,
pub http: reqwest::Client,
pub config: Arc<Config>,
pub cookie_key: Key,
pub oidc: Arc<OnceCell<OidcDiscovery>>,
/// Caps concurrent zip streams — each holds S3 connections for its
/// duration, and slow readers would otherwise pin them indefinitely.
pub zip_permits: Arc<tokio::sync::Semaphore>,
}
impl FromRef<AppState> for Key {
fn from_ref(state: &AppState) -> Key {
state.cookie_key.clone()
}
}
impl AppState {
pub async fn new(config: Config) -> anyhow::Result<Self> {
let db = sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.connect(&config.database_url)
.await?;
sqlx::migrate!("./migrations").run(&db).await?;
let s3 = crate::s3::client(&config);
// SHA-512 digest is exactly the 64 bytes Key::from requires.
let cookie_key = Key::from(&Sha512::digest(config.session_secret.as_bytes()));
Ok(Self {
db,
s3,
http: reqwest::Client::new(),
config: Arc::new(config),
cookie_key,
oidc: Arc::new(OnceCell::new()),
zip_permits: Arc::new(tokio::sync::Semaphore::new(4)),
})
}
}