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:
@@ -0,0 +1,7 @@
|
|||||||
|
target
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
deploy
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
node_modules
|
||||||
|
frontend/dist
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
Generated
+4697
File diff suppressed because it is too large
Load Diff
+49
@@ -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
@@ -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"]
|
||||||
@@ -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 (1–5 stars) and free-form tags are stored **per link**, so create
|
||||||
|
one link per client to keep feedback separate. The album view shows all
|
||||||
|
feedback grouped by link label.
|
||||||
|
- Clients (and you) can multi-select photos and download them — or the whole
|
||||||
|
album — as a ZIP. Archives are streamed (each file spools briefly through a
|
||||||
|
temp file for its checksum, then pipelines while the next one prefetches),
|
||||||
|
stored uncompressed with real capture-date timestamps, and are fully
|
||||||
|
spec-compliant — they extract with strict streaming readers (Java
|
||||||
|
`ZipInputStream`, 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).
|
||||||
@@ -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"
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+1761
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
# aws-sdk-s3 (and friends) currently require rustc >= 1.94.1
|
||||||
|
channel = "1.94.1"
|
||||||
+312
@@ -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 }))
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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://")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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 })))
|
||||||
|
}
|
||||||
@@ -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 })))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 })))
|
||||||
|
}
|
||||||
@@ -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 })))
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user