5c85e0c0e4
* fix(guests): surface duplicate guest registrations, and stop making so many (#1210) Guest registration always inserts. A client whose token expired — or who opens the gallery on a second device — becomes a new gallery_guests row, and their likes and favourites split across the copies. The photographer's 'final selection' is then only trustworthy if somebody notices two Tinas with half the picks each. Two halves, neither of which touches the registration path. **Say which rows are the same person.** Merging already worked, endpoint and UI both; nothing said WHICH rows to merge. The guests list now marks each row with the others sharing its email and returns a count for the banner, and the admin list offers the group straight to the merge mode that already exists. Case-folded and trimmed, because the same person types Tina@ one day and tina@ the next and both read as distinct rows. Email only — two guests called Anna are not evidence of anything, and rows without an email are not grouped at all since require_name_email is off by default and a shared link produces plenty of them. It preselects rather than merges: which row survives decides the name and verification state the merged guest keeps, and that is the admin's call. **Create fewer of them.** The guest token was 24h and every call site took that default, so even the same browser lost its identity after a day of inactivity. Now 30 days, GUEST_TOKEN_TTL to override. A guest token is scoped to one event, carries no admin capability, and the gallery is already behind whatever protects it — 30 days is the shape of a real proofing cycle. Deliberately NOT done: reusing a guest row when a typed email matches, which the report suggests first. It would let anyone who knows an address inherit that person's identity and selections, and answering differently for a known email would leak which addresses are in the gallery — the thing /guest/recover already goes out of its way to avoid. Prevention at the entry path needs the verification round-trip, which is a separate decision about friction. 13 tests; 8 of the 9 backend ones fail without the change. The frontend ones caught a real bug while being written — the new useMemo sat after the loading early-return, so the hook count changed between renders. * fix(guests): merge must not strand a pending invite (#1210) Three findings from external review of #1216. **A merge could kill an emailed invite link.** Creating an invite inserts a real gallery_guests row, so an admin who pre-mints one and then sees the guest self-register has two rows sharing an email — which this feature now points out and offers to merge. Redemption resolves guest_invites.guest_id with is_deleted: false, so merging soft-deleted the row the link pointed at: the client got 404 guest_missing while the invite dialog still showed the invite as Pending. Nothing anywhere said the link was dead. Unredeemed, unrevoked invites now move to the survivor first. Spent ones stay put — a redeemed invite records who redeemed what, and retargeting it would rewrite that. **The preselection silently chose the survivor.** performMerge keeps mergeSelection[0], and the group was handed over in API order, which is newest-first — so Review then Merge discarded an older, email-verified row holding most of the picks in favour of a fresh re-registration. The proposal is now ordered deliberately: verified first, then whoever holds the most feedback, then the oldest. Still only a proposal, and the confirmation now names the survivor by email as well as name, because duplicates share a name and 'Merge 2 guests into Tina?' said nothing. **duplicate_of was quadratic.** Every row carried the other n-1 ids, so a group of n serialised n² of them — and nothing consumed the list: the UI asked only whether a row was in a group, then regrouped by email itself. Replaced with duplicate_group, the normalised email, which keeps the payload linear and the case/whitespace folding in one place instead of reimplemented on the client. Two new backend tests for the invite paths, one frontend test asserting the merge call keeps the verified row. The invite test fails against the un-fixed code. * fix(guests): keep guest-controlled input out of who survives a merge (#1210) Round 2 of external review on #1216. **The survivor ranking used an attacker-controlled signal.** Preferring whoever holds the most feedback looked like the obvious tiebreak and is exactly the wrong one: registration does not verify the address, so anyone who knows a guest's email can register with it, mark enough photos to out-rank the real person, and be preselected as the survivor. An admin accepting a confirmation between two rows with the same name and email would then move the victim's picks onto an identity whose token the visitor still holds. distinct_photos is guest-controlled and has no business deciding this. The ranking is now email_verified_at then created_at — both server-set. **A merge could make the survivor unrecoverable.** Rows are grouped with case and whitespace folded out, so a merge can be proposed between tina@example.com and Tina@Example.com. /guest/recover lowercases what the guest types and then matches on equality, so a survivor left holding the raw value can never be recovered by email again. The kept row's address is now canonicalised during the merge. Both write paths normalise today, so this covers rows that predate that — which are exactly the rows case-folded grouping surfaces. Two more backend tests. The residual, stated plainly: an admin can still merge two unverified rows in either order. What is gone is the tool ranking them by something a visitor controls. * fix(compose): pass GUEST_TOKEN_TTL through to the backend (#1210) The override was documented in .env.example and could never take effect: the backend service takes an explicit environment list, so a variable not named there never reaches the container. An operator following the documentation would have shortened the guest session and seen nothing change. docker-compose.production.yml uses env_file: .env and already passed it through; docker-compose.dev.yml is gitignored, so only this file needs it. * fix(guests): the admin picks the merge survivor, the tool does not (#1210) Fourth review round on the same point, and the right conclusion is that there is no correct automatic answer. Every rule tried was wrong somewhere. Most-feedback is guest-controlled — the address is never verified at registration, so anyone who knows it can register and mark photos until they out-rank the real person. Oldest-first, the replacement, is worse for the ordinary case: when a token expires the OLD row is the dead identity and the new one is the visitor's live session, so keeping the oldest deletes the identity they are actually using, and the frontend holds that deleted guest in sessionStorage without clearing it on a 401. Registration timing is visitor-controlled too. The data does not say which row is really the person. So the UI asks: merge mode gains a Keep column, the button stays disabled until a row is nominated, and only rows included in the merge can be nominated. The group is still preselected — finding the duplicates was always the point — but nothing about who survives is decided by sort order any more. This also makes the claim in the PR description true. It said the admin decides which row survives; until now the preselection quietly decided it for them. Two rewritten frontend tests: the merge is blocked until a survivor is chosen and then keeps exactly that row, and a row outside the group cannot be nominated. The test i18n mock now interpolates, so aria-labels are queryable by their rendered text. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
271 lines
10 KiB
YAML
271 lines
10 KiB
YAML
services:
|
|
# Generates machine secrets (JWT/DB/Redis) on first run when they aren't set
|
|
# in .env (seeds from the env var when provided, else a random value).
|
|
# Idempotent — never overwrites an existing file. See docker-compose.production.yml.
|
|
secrets-init:
|
|
image: alpine:3.20
|
|
container_name: picpeak-secrets-init
|
|
env_file: .env
|
|
entrypoint:
|
|
- sh
|
|
- -c
|
|
- |
|
|
set -e
|
|
mkdir -p /run/secrets
|
|
if [ ! -s /run/secrets/jwt_secret ]; then
|
|
if [ -n "$$JWT_SECRET" ]; then printf '%s' "$$JWT_SECRET" > /run/secrets/jwt_secret;
|
|
else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/jwt_secret; fi
|
|
fi
|
|
if [ ! -s /run/secrets/db_password ]; then
|
|
if [ -n "$$DB_PASSWORD" ]; then printf '%s' "$$DB_PASSWORD" > /run/secrets/db_password;
|
|
else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/db_password; fi
|
|
fi
|
|
if [ ! -s /run/secrets/redis_password ]; then
|
|
if [ -n "$$REDIS_PASSWORD" ]; then printf '%s' "$$REDIS_PASSWORD" > /run/secrets/redis_password;
|
|
else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/redis_password; fi
|
|
fi
|
|
# 644: the readers run as three different users (postgres, redis, nodejs),
|
|
# so a non-root reader must be able to read them. The volume is private to
|
|
# these containers and never host-exposed.
|
|
chmod 644 /run/secrets/jwt_secret /run/secrets/db_password /run/secrets/redis_password
|
|
volumes:
|
|
- picpeak-secrets:/run/secrets
|
|
restart: "no"
|
|
|
|
backend:
|
|
build:
|
|
context: ./backend
|
|
dockerfile: Dockerfile
|
|
container_name: picpeak-backend
|
|
restart: unless-stopped
|
|
environment:
|
|
- NODE_ENV=${NODE_ENV:-production}
|
|
- PORT=3000
|
|
- JWT_SECRET=${JWT_SECRET:-}
|
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
|
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
|
# How long a gallery guest stays recognised (#1210). Unset = 30d.
|
|
# Listed explicitly because this service takes an environment list, so a
|
|
# value in .env that is not named here never reaches the container.
|
|
- GUEST_TOKEN_TTL=${GUEST_TOKEN_TTL:-}
|
|
- DATABASE_CLIENT=pg
|
|
- DB_TYPE=postgresql
|
|
- DB_HOST=postgres
|
|
- DB_PORT=5432
|
|
- DB_USER=${DB_USER}
|
|
- DB_PASSWORD=${DB_PASSWORD:-}
|
|
- DB_NAME=${DB_NAME}
|
|
- EXTERNAL_MEDIA_ROOT=${EXTERNAL_MEDIA_ROOT:-/app/storage/external-media}
|
|
- SMTP_HOST=${SMTP_HOST}
|
|
- SMTP_PORT=${SMTP_PORT}
|
|
- SMTP_SECURE=${SMTP_SECURE:-false}
|
|
- SMTP_USER=${SMTP_USER}
|
|
- SMTP_PASS=${SMTP_PASS}
|
|
- EMAIL_FROM=${EMAIL_FROM:-noreply@picpeak.local}
|
|
# Unset by default (#705): an injected value would always win over the
|
|
# `general_site_url` admin setting, so the setup wizard could never
|
|
# take effect. Set this only to pin the origin from config-as-code.
|
|
- FRONTEND_URL=${FRONTEND_URL:-}
|
|
# Public API origin for split-origin deployments (#798 SSO redirect_uri).
|
|
# Empty = same origin as FRONTEND_URL (the standard proxied setup).
|
|
- API_URL=${API_URL:-}
|
|
# OIDC SSO (#798): key for the client secret at rest (falls back to
|
|
# JWT_SECRET) and the break-glass override that re-enables local
|
|
# password login when the IdP is down while SSO-only mode is active.
|
|
- OIDC_ENCRYPTION_KEY=${OIDC_ENCRYPTION_KEY:-}
|
|
- OIDC_BREAK_GLASS=${OIDC_BREAK_GLASS:-}
|
|
- ADMIN_URL=${ADMIN_URL:-}
|
|
- TZ=${TZ:-UTC}
|
|
- STORAGE_PATH=/app/storage
|
|
# Watch-folder auto-import: max photos processed in parallel (default 2).
|
|
- FILE_WATCHER_CONCURRENCY=${FILE_WATCHER_CONCURRENCY:-2}
|
|
# Face recognition (#1074). The URL defaults to the sidecar's compose
|
|
# service name, so the common case needs no configuration. None of this
|
|
# is touched until the `faces` feature flag is enabled in admin
|
|
# settings — an install without the picpeak-ml container never attempts
|
|
# a connection.
|
|
- FACE_ML_URL=${FACE_ML_URL:-http://picpeak-ml:8000}
|
|
- FACE_ML_TOKEN=${FACE_ML_TOKEN:-}
|
|
- FACE_PROCESSOR_CONCURRENCY=${FACE_PROCESSOR_CONCURRENCY:-}
|
|
# No `user:` directive — as of #484, the container starts as root,
|
|
# chowns the bind mounts to nodejs (UID 1001), then drops privileges
|
|
# via su-exec. PUID/PGID env vars are no longer read; if you need
|
|
# a different runtime UID, pre-chown the host dirs and pin
|
|
# `user: "<uid>:<gid>"` here.
|
|
volumes:
|
|
- ./events:/app/events
|
|
- ./data:/app/data
|
|
- ./logs:/app/logs
|
|
- ./backup:/backup
|
|
- ./storage:/app/storage
|
|
- picpeak-secrets:/run/secrets:ro
|
|
ports:
|
|
- "${BACKEND_PORT:-3001}:3000"
|
|
depends_on:
|
|
secrets-init:
|
|
condition: service_completed_successfully
|
|
postgres:
|
|
condition: service_healthy
|
|
healthcheck:
|
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/health"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 40s
|
|
networks:
|
|
- picpeak-network
|
|
|
|
postgres:
|
|
image: postgres:15-alpine
|
|
container_name: picpeak-postgres
|
|
restart: unless-stopped
|
|
userns_mode: "host"
|
|
environment:
|
|
- POSTGRES_USER=${DB_USER}
|
|
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
|
- POSTGRES_DB=${DB_NAME}
|
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
|
- TZ=${TZ:-UTC}
|
|
volumes:
|
|
- postgres-data:/var/lib/postgresql/data
|
|
- picpeak-secrets:/run/secrets:ro
|
|
depends_on:
|
|
secrets-init:
|
|
condition: service_completed_successfully
|
|
ports:
|
|
- "127.0.0.1:${DB_PORT:-5432}:5432"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 20s
|
|
networks:
|
|
- picpeak-network
|
|
|
|
redis:
|
|
image: redis:7-alpine
|
|
container_name: picpeak-redis
|
|
restart: unless-stopped
|
|
userns_mode: "host"
|
|
command: sh -c 'exec redis-server --appendonly yes --requirepass "$$(cat /run/secrets/redis_password)"'
|
|
volumes:
|
|
- redis-data:/data
|
|
- picpeak-secrets:/run/secrets:ro
|
|
depends_on:
|
|
secrets-init:
|
|
condition: service_completed_successfully
|
|
ports:
|
|
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 3
|
|
networks:
|
|
- picpeak-network
|
|
|
|
# Local mail catcher for development/testing only — never wanted in a real
|
|
# deployment. Gated behind the `dev` profile so a plain `docker compose up -d`
|
|
# does NOT start it; opt in with `docker compose --profile dev up -d`. Nothing
|
|
# depends on it (SMTP_HOST comes from .env), so gating is safe.
|
|
mailhog:
|
|
image: mailhog/mailhog:latest
|
|
container_name: picpeak-mailhog
|
|
restart: unless-stopped
|
|
profiles:
|
|
- dev
|
|
ports:
|
|
- "${MAILHOG_SMTP_PORT:-1025}:1025"
|
|
- "${MAILHOG_UI_PORT:-8025}:8025"
|
|
networks:
|
|
- picpeak-network
|
|
|
|
# Optional face-detection sidecar (#1074). Gated behind the `faces` profile
|
|
# so a plain `docker compose up -d` does NOT start it — opt in with
|
|
# `docker compose --profile faces up -d`. Nothing depends on it: the backend
|
|
# only ever calls it when the `faces` feature flag is on, so an install that
|
|
# skips this service behaves exactly as it did before the feature existed.
|
|
#
|
|
# The service name is `picpeak-ml` (not `ml`) because it doubles as the
|
|
# hostname in FACE_ML_URL's default, `http://picpeak-ml:8000`. Renaming this
|
|
# service silently breaks that default for every install that never set the
|
|
# variable.
|
|
#
|
|
# Requires FACENET_ONNX_URL / FACENET_ONNX_SHA256 at build time — see
|
|
# ml/README.md. The image publishes no host port and mounts no volumes; it
|
|
# is reachable only from the backend on picpeak-network.
|
|
picpeak-ml:
|
|
build:
|
|
context: ./ml
|
|
dockerfile: Dockerfile
|
|
args:
|
|
# Defaults live in ml/Dockerfile and point at the canonical published
|
|
# model. These pass an override through from .env when set; an empty
|
|
# value here would BLANK the Dockerfile default and fail the build,
|
|
# so the fallbacks repeat it deliberately.
|
|
- FACENET_ONNX_URL=${FACENET_ONNX_URL:-https://github.com/PicPeak/picpeak/releases/download/ml-models-v1/facenet512.onnx}
|
|
- FACENET_ONNX_SHA256=${FACENET_ONNX_SHA256:-a1c06dcb79dc17a42af01d5bcbce4822caa148b9c24bf7eb8b8e556b4fd0d5db}
|
|
container_name: picpeak-ml
|
|
restart: unless-stopped
|
|
profiles:
|
|
- faces
|
|
environment:
|
|
# Shared secret with the backend. The container refuses to start
|
|
# without it rather than serving anonymously.
|
|
- FACE_ML_TOKEN=${FACE_ML_TOKEN:-}
|
|
- FACE_ORT_THREADS=${FACE_ORT_THREADS:-1}
|
|
- TZ=${TZ:-UTC}
|
|
healthcheck:
|
|
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=4).status == 200 else 1)"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 40s
|
|
networks:
|
|
- picpeak-network
|
|
|
|
frontend:
|
|
build:
|
|
context: ./frontend
|
|
dockerfile: Dockerfile
|
|
args:
|
|
- VITE_API_URL=${VITE_API_URL:-/api}
|
|
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
|
|
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
|
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
|
container_name: picpeak-frontend
|
|
restart: unless-stopped
|
|
environment:
|
|
- NODE_ENV=${NODE_ENV:-production}
|
|
# Static social-preview brand (#521) — substituted into
|
|
# index.html at container start; see frontend/docker-entrypoint.sh.
|
|
- BRAND_TITLE=${BRAND_TITLE:-PicPeak}
|
|
- BRAND_DESCRIPTION=${BRAND_DESCRIPTION:-Photo gallery shared with PicPeak.}
|
|
ports:
|
|
- "${FRONTEND_PORT:-3000}:80"
|
|
depends_on:
|
|
- backend
|
|
healthcheck:
|
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/health"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
networks:
|
|
- picpeak-network
|
|
|
|
volumes:
|
|
postgres-data:
|
|
driver: local
|
|
redis-data:
|
|
driver: local
|
|
# Auto-generated machine secrets (jwt/db/redis). Keep it — deleting it orphans
|
|
# the DB password from the Postgres volume.
|
|
picpeak-secrets:
|
|
driver: local
|
|
|
|
networks:
|
|
picpeak-network:
|
|
driver: bridge
|