9431b9f094
* feat(setup): configure the public address and SMTP in the wizard, not .env
A fresh install could not configure its own public address. `general_site_url`
and the `email_configs` row already existed as admin settings, but nothing
could reach them:
- docker-compose.yml injected FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
and Dockerfile.aio baked in ENV FRONTEND_URL=http://localhost:3000, so
getFrontendBaseUrl() returned on its first branch every time and the setting
was never read. .env.example shipped the same value as an uncommented
placeholder for FRONTEND_URL / ADMIN_URL / API_URL.
- the wizard never asked for the address at all, and skipped its whole config
step unless a CRM-ish feature was selected — so a gallery-only install was
also never offered SMTP, despite gallery links, guest invites and expiry
warnings all going out through email_configs.
- eleven call sites read process.env.FRONTEND_URL directly rather than the
resolver, three of them defaulting to placeholder hosts that reached real
recipients: https://app.example.com in payment-reminder emails, localhost:3005
in admin invitation emails, https://app.example.com in dev template previews.
Stop injecting a default anywhere, and resolve the origin instead:
FRONTEND_URL -> general_site_url -> the origin the request arrived on ->
whichever exists -> ''. A loopback candidate is treated as unconfigured so the
installs that already have http://localhost:3000 baked into their environment
self-heal; the same guard previously lived inline in routes/gallery.js for the
slideshow QR (#848) and is now shared. The empty return is preserved because
shareLinkService and the SSO redirects in routes/auth rely on it to emit
relative urls — callers needing an absolute url use getAbsoluteFrontendUrl(),
which still ends at http://localhost:3000.
The wizard now persists window.location.origin right after the admin account is
created, so an install that skips the rest still has a usable origin for
background jobs that have no request to derive one from, and offers it as an
editable "Public address" field. Settings -> General shows the field read-only
when FRONTEND_URL pins it, instead of silently ignoring edits.
Also drop the `|| 'mailhog'` fallback when seeding email_configs: that host only
exists in the dev compose profile (which does not even start by default), so a
fresh install came up with a live config pointing nowhere while the wizard
showed empty SMTP fields. With no row, blank fields are the truth and
emailProcessor logs "No email configuration found". Developers set
SMTP_HOST=mailhog explicitly.
backend/src/services/emailService.js is deleted: nothing in backend/ references
it, and it was the only consumer of the SMTP_* variables, which misrepresented
how mail is configured.
Refs #705
* fix(setup): keep FRONTEND_URL ahead of ADMIN_URL/APP_URL when resolving links
The previous commit routed two call sites through the resolver but put the
site-specific variable FIRST, silently reversing precedence:
userManagementService was: FRONTEND_URL || ADMIN_URL || localhost:3005
became: ADMIN_URL || resolver
adminEvents/crud was: FRONTEND_URL || APP_URL || ''
became: APP_URL || resolver
An install with both variables set would have flipped which one won. Call the
resolver first instead — it starts with FRONTEND_URL, so the original relative
order is preserved and only the final fallback changes: localhost:3005 (not
even the frontend's port) and '' (a relative link inside an email) both become
the resolved origin.
Refs #705
* fix(setup): unpin loopback FRONTEND_URL, keep ADMIN_URL/APP_URL reachable
Review feedback on #1104.
isEnvPinned() reported ANY FRONTEND_URL as authoritative, including the
loopback values getFrontendBaseUrl() deliberately demotes. An install
upgrading with the old compose default FRONTEND_URL=http://localhost:3000
therefore resolved its origin from general_site_url correctly, but got the
Site URL field rendered read-only in Settings and skipped by the wizard's
seeding - locking the exact operators this change exists to unblock out of
configuring a public address anywhere. The predicate now mirrors the
resolver, and the derived general_site_url_effective the General tab reads
comes from the same helper instead of re-normalising process.env inline.
APP_URL and ADMIN_URL had become dead code: getFrontendBaseUrl() only
returns falsy when NOTHING is configured, so `|| process.env.ADMIN_URL`
after it never ran once a site URL existed - which after this PR is the
normal case. A split-origin install pointing ADMIN_URL at a separate admin
host got invite links on the public gallery origin instead. They are now
passed as an explicit `override` that resolves directly below FRONTEND_URL,
preserving the historic FRONTEND_URL-before-ADMIN_URL order while beating
the database- and request-derived fallbacks.
general_site_url now feeds the CORS allowlist and the
Access-Control-Allow-Origin header, not just email links, so a schemeless
value is an allowlist entry no browser origin can match. Validate it
server-side in PUT /general (isURL with require_protocol, require_tld off
so LAN/NAS installs on http://nas:3000 still work) and client-side in both
surfaces that write it - type="url" never fires in either, since neither
input sits inside a form.
Two more wizard fixes: the General tab no longer reposts general_site_url
while it is env-pinned, because the field then holds the effective env
value rather than the stored one and the round-trip read as a change to a
protected key, 403ing a settings.edit-without-settings.domains admin on an
unrelated save. And SetupConfigStep validates the From address before
posting - /admin/email/config rejects a blank one, which used to surface as
a generic warning while the wizard advanced from its finally block anyway,
discarding every SMTP value the user had typed, password included. A failed
save now keeps them on the step.
* fix(setup): surface a rejected public address instead of swallowing it
Review round 2 follow-up on #1104, pushed onto the branch.
saveSiteUrl() caught and discarded every error. That was defensible before
round 2 added a server-side URL check, but PUT /general can now answer 400 —
and the two validators disagreed:
http://my_nas.local client: accepted server: rejected
http://foo_bar:3000 client: accepted server: rejected
validate() let those through, the 400 was swallowed, `failed` stayed false and
onDone() ran. The operator finished the wizard believing the public address was
stored when nothing had been. That is the silent misconfiguration this whole
change exists to remove, landing on the LAN and NAS installs it targets.
Three parts:
- saveSiteUrl() throws. finish() resolves it before anything else is posted and
puts the message on the address field rather than the generic "some settings
could not be saved" warning. Skip for now still always leaves, by contract,
but warns instead of dropping the value in silence.
- allow_underscores on the server check, for the same reason require_tld is
off: browsers resolve http://my_nas.local and the client accepts it, so
rejecting it server-side only produced the mismatch above. Both validators
now agree across the LAN/NAS, IDN, bare-IP and scheme-less cases.
- LOOPBACK_BASE_RE anchors its host token. Bare prefix matching also demoted
https://localhost-nas.example.com, and now that this predicate gates the
whole resolver rather than just the slideshow QR, being demoted means a
configured address is silently ignored. 127. stays a bare prefix on purpose:
all of 127.0.0.0/8 is loopback.
Resolver suite 31 passing, up from 26. Mutation-checked: restoring the
unanchored regex fails the three new host-boundary cases.
* fix(settings): don't lock the General tab on a site URL nobody typed
Review follow-up on #1104, pushed onto the branch.
general_site_url was free-text until this PR added a server-side check, so an
upgraded install can hold something schemeless that predates it. The tab
flagged that on load, and `disabled={!!siteUrlError}` then killed Save for
EVERY General setting.
An admin holding settings.edit but not settings.domains could not clear it
either: correcting the address is a change to a protected key and 403s. The
tab has no permission gating, so that role was simply locked out of the tab
with no self-service way back.
That is the same role adminSettings.js:85-95 documents the no-op round-trip
allowance for. The allowance only helps if the request is made, and this
blocked it in the browser first.
Validation now waits until the field is actually edited, and an unchanged
value is dropped from the payload rather than reposted — matching what the
env-pinned case already does one line above, and for the same reason.
stored value invalid, untouched Save works, key not sent
edited to something unusable Save blocked
edited to a usable absolute url saved
Four tests, first coverage for this feature. Mutation-checked: removing the
dirty gate fails the untouched-value case.
---------
Co-authored-by: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
292 lines
13 KiB
Bash
292 lines
13 KiB
Bash
# PicPeak Environment Configuration
|
|
# Copy this file to .env and update with your values
|
|
|
|
# Environment
|
|
NODE_ENV=production
|
|
|
|
# JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run
|
|
# (Docker: the secrets-init service writes it to a private volume and reuses it
|
|
# across restarts). Set it explicitly only to pin your own value.
|
|
# Generate one with: openssl rand -base64 64
|
|
#JWT_SECRET=your_very_long_random_jwt_secret_here
|
|
|
|
# OIDC SSO for admins (#798) — configured in the admin UI; only these two
|
|
# values live in the environment:
|
|
# Key encrypting the OIDC client secret at rest (defaults to JWT_SECRET).
|
|
#OIDC_ENCRYPTION_KEY=
|
|
# Break-glass: 'true' re-enables local password login even while the SSO
|
|
# settings disable it (recovery when the IdP is down or misconfigured).
|
|
#OIDC_BREAK_GLASS=false
|
|
|
|
# Auth cookie Secure flag
|
|
# unset - default: follows NODE_ENV (production=true, dev=false)
|
|
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
|
|
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
|
# auto - decide per request: Secure on HTTPS, not on HTTP
|
|
#
|
|
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
|
|
# (via reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
|
|
# HTTP (e.g. LAN access at http://192.168.x.x:3010). The backend reads
|
|
# req.secure from Express, which respects the X-Forwarded-Proto header
|
|
# when the proxy is in the trust list.
|
|
#
|
|
# Requirements for auto mode:
|
|
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
|
|
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
|
|
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
|
|
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
|
|
# 192.168.x, link-local). Proxies outside those ranges need custom
|
|
# trust proxy configuration.
|
|
# COOKIE_SECURE=auto
|
|
|
|
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
|
|
# COOKIE_SAMESITE=Lax
|
|
|
|
# Cookie Domain — set this if serving auth cookies across subdomains.
|
|
# Leave unset for same-origin setups.
|
|
# COOKIE_DOMAIN=.example.com
|
|
|
|
# Database Configuration (PostgreSQL)
|
|
DATABASE_CLIENT=pg
|
|
DB_USER=picpeak
|
|
# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run
|
|
# (Docker). Set it explicitly to pin your own, e.g. for an external database.
|
|
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
|
|
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
|
|
#DB_PASSWORD=your_secure_postgres_password_here
|
|
DB_NAME=picpeak_prod
|
|
|
|
# Redis Configuration
|
|
# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker).
|
|
# IMPORTANT: Same warning applies - avoid $ or escape as $$
|
|
#REDIS_PASSWORD=your_secure_redis_password_here
|
|
|
|
# Admin Account (initial setup) — OPTIONAL
|
|
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
|
|
# open /admin and PicPeak shows a setup screen. The one-time setup token is
|
|
# written to data/SETUP_TOKEN with mode 0600 — read it with
|
|
# `docker compose exec backend cat /app/data/SETUP_TOKEN`. It is NOT logged
|
|
# unless that write fails, so it never sits in `docker logs`.
|
|
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
|
|
# credentials written to data/ADMIN_CREDENTIALS.txt).
|
|
#ADMIN_USERNAME=admin
|
|
#ADMIN_EMAIL=admin@yourdomain.com
|
|
#ADMIN_PASSWORD=your_secure_admin_password_here
|
|
|
|
# Email Configuration — OPTIONAL, and normally left alone.
|
|
# SMTP is configured in the setup wizard / Settings -> Email and stored in the
|
|
# database (email_configs); that is what the mail queue actually sends with.
|
|
# These variables are a legacy path kept for config-as-code deployments: when
|
|
# SMTP_HOST is set, the initial migration seeds the database row from it.
|
|
# Developers running the `dev` compose profile want SMTP_HOST=mailhog here so
|
|
# that seed points at the mailhog container.
|
|
# For Gmail: use app-specific password
|
|
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
|
|
#SMTP_HOST=smtp.gmail.com
|
|
#SMTP_PORT=587
|
|
#SMTP_SECURE=false
|
|
#SMTP_USER=your-email@gmail.com
|
|
#SMTP_PASS=your-app-specific-password
|
|
#EMAIL_FROM=noreply@yourdomain.com
|
|
|
|
# Application URLs — OPTIONAL. Leave unset for the normal install.
|
|
# The public origin is captured by the setup wizard (it proposes the address
|
|
# you opened the browser at) and stored as the `general_site_url` setting, so
|
|
# you can change it later in Settings -> General without touching this file.
|
|
# Setting FRONTEND_URL here OVERRIDES that setting and makes the field
|
|
# read-only in the admin UI - use it only for config-as-code deployments.
|
|
# Use full origin with scheme, no trailing slash.
|
|
# Admin UI is served by the frontend at /admin.
|
|
#FRONTEND_URL=https://yourdomain.com
|
|
#ADMIN_URL=https://yourdomain.com
|
|
|
|
# Static HTML title + description used for social link previews when the
|
|
# fetcher doesn't trigger the per-event OG endpoint — most notably the
|
|
# WhatsApp Business API and various 3rd-party preview-service caches
|
|
# (#521). Set these to your brand so link previews aren't generic.
|
|
# Substituted into index.html at frontend-container start, so changes
|
|
# take effect on the next `docker compose up -d frontend` — no rebuild
|
|
# required.
|
|
BRAND_TITLE=PicPeak
|
|
BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
|
|
|
|
# API URL for email assets (logos, images in notification emails)
|
|
# OPTIONAL: when unset this is derived from the resolved public origin + /api,
|
|
# so the wizard's answer covers it. Set it only for split-origin deployments
|
|
# where the API lives on a different host than the gallery.
|
|
#API_URL=https://yourdomain.com/api
|
|
|
|
# Frontend API base
|
|
# For pre-built images and production behind a reverse proxy, keep '/api'.
|
|
# If you rebuild the frontend yourself, you may set a full URL at build time.
|
|
VITE_API_URL=/api
|
|
|
|
# Port Configuration (optional)
|
|
# BACKEND_PORT=3001
|
|
# FRONTEND_PORT=3000
|
|
# DB_PORT=5432
|
|
# REDIS_PORT=6379
|
|
|
|
# File watcher (watch-folder auto-import, local storage only)
|
|
# Max photos processed in parallel — raise on hosts with memory headroom,
|
|
# lower to 1 on very small hosts. Default: 2
|
|
# FILE_WATCHER_CONCURRENCY=2
|
|
|
|
# Release Channel
|
|
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
|
|
# 'stable' uses the :stable tag (same as :latest on main)
|
|
# 'beta' uses the :beta tag for pre-release versions
|
|
PICPEAK_CHANNEL=stable
|
|
|
|
# Update Check Configuration
|
|
# Set to 'false' to disable update notifications in admin UI
|
|
UPDATE_CHECK_ENABLED=true
|
|
|
|
# Timezone
|
|
TZ=UTC
|
|
|
|
# Analytics (Optional - Umami)
|
|
VITE_UMAMI_URL=
|
|
VITE_UMAMI_WEBSITE_ID=
|
|
VITE_UMAMI_SHARE_URL=
|
|
|
|
# Storage variables (host paths)
|
|
# These control where data is stored on the host. Defaults are local folders.
|
|
APP_STORAGE=./storage
|
|
APP_DATA=./data
|
|
LOGS=./logs
|
|
|
|
# ─── Storage Backend ────────────────────────────────────────────────────────
|
|
# PicPeak can store photos, thumbnails and archive zips on the local filesystem
|
|
# (default) or on any S3-compatible object store (AWS S3, MinIO, Cloudflare R2,
|
|
# Backblaze B2, Wasabi, DigitalOcean Spaces, …).
|
|
#
|
|
# STORAGE_BACKEND=local (default)
|
|
# Uses STORAGE_PATH on the local filesystem. Backwards compatible — every
|
|
# existing deployment keeps working unchanged.
|
|
#
|
|
# STORAGE_BACKEND=s3
|
|
# Reads STORAGE_S3_* below. Auto-import via the filesystem watcher is
|
|
# disabled in this mode (S3 has no inotify) — every photo must enter via the
|
|
# admin upload UI/API. Run `node backend/scripts/migrate-storage.js` to copy
|
|
# existing local content to S3 before flipping the env.
|
|
#
|
|
# STORAGE_BACKEND=local
|
|
#
|
|
# STORAGE_S3_BUCKET=picpeak
|
|
# STORAGE_S3_REGION=us-east-1
|
|
# STORAGE_S3_ACCESS_KEY=AKIAxxxxxxxxxxxxxxxx
|
|
# STORAGE_S3_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxx
|
|
# Custom endpoint — set this for MinIO / R2 / B2 / Spaces. Leave unset for AWS.
|
|
# STORAGE_S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com
|
|
# Optional namespace prefix inside the bucket — useful for multi-deployment buckets.
|
|
# STORAGE_S3_PREFIX=picpeak
|
|
# STORAGE_S3_FORCE_PATH_STYLE=false # MinIO needs true; auto-on when endpoint is set
|
|
# STORAGE_S3_SSL=true
|
|
#
|
|
# Minimum IAM policy (AWS S3) for the bucket above:
|
|
# {
|
|
# "Version": "2012-10-17",
|
|
# "Statement": [{
|
|
# "Effect": "Allow",
|
|
# "Action": [
|
|
# "s3:GetObject", "s3:PutObject", "s3:DeleteObject",
|
|
# "s3:ListBucket", "s3:GetBucketLocation"
|
|
# ],
|
|
# "Resource": [
|
|
# "arn:aws:s3:::picpeak",
|
|
# "arn:aws:s3:::picpeak/*"
|
|
# ]
|
|
# }]
|
|
# }
|
|
#
|
|
# EXTERNAL_MEDIA_ROOT (above) always lives on the local filesystem regardless
|
|
# of STORAGE_BACKEND — reference-mode galleries are not migrated to S3 in v1.
|
|
|
|
# ─── Outbound Webhooks (#327) ────────────────────────────────────────────────
|
|
# PicPeak POSTs event/photo lifecycle notifications to URLs you configure
|
|
# under Settings → Webhooks. Each delivery is signed HMAC-SHA256 with a
|
|
# per-webhook secret in the X-PicPeak-Signature header.
|
|
#
|
|
# WEBHOOK_ALLOW_PRIVATE_URLS (default: false)
|
|
# Block URLs resolving to private IPs / loopback / .local etc. as an
|
|
# SSRF mitigation. Set to "true" ONLY in dev when your receiver is on
|
|
# the same docker network or localhost. Production deployments must
|
|
# leave this OFF.
|
|
# WEBHOOK_ALLOW_PRIVATE_URLS=false
|
|
#
|
|
# WEBHOOK_DELIVERY_INTERVAL_MS (default: 5000)
|
|
# How often the worker polls webhook_deliveries for pending rows.
|
|
# WEBHOOK_DELIVERY_INTERVAL_MS=5000
|
|
#
|
|
# WEBHOOK_DELIVERY_CONCURRENCY (default: 5)
|
|
# Maximum in-flight deliveries per worker tick. One slow consumer can
|
|
# monopolize all 5 slots — bump this if your receivers are slow OR ship
|
|
# a separate webhook-only deployment.
|
|
# WEBHOOK_DELIVERY_CONCURRENCY=5
|
|
#
|
|
# WEBHOOK_HTTP_TIMEOUT_MS (default: 10000)
|
|
# Per-request timeout. Beyond this, the delivery is recorded as a
|
|
# network error and retried.
|
|
# WEBHOOK_HTTP_TIMEOUT_MS=10000
|
|
#
|
|
# WEBHOOK_MAX_ATTEMPTS (default: 5)
|
|
# Total attempts before a delivery is marked failed. Backoff between
|
|
# attempts is exponential: 1m, 5m, 30m, 2h, 12h.
|
|
# WEBHOOK_MAX_ATTEMPTS=5
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Face recognition — "People in this gallery" (#1074, optional)
|
|
# -----------------------------------------------------------------------------
|
|
# Requires the optional picpeak-ml sidecar container:
|
|
# docker compose --profile faces up -d
|
|
#
|
|
# NONE of these variables do anything until the `faces` feature flag is
|
|
# enabled in Admin → Settings, AND the per-event "Detect people in this
|
|
# gallery" toggle is switched on. Both default to OFF. With the flag off the
|
|
# backend never contacts the sidecar, so leaving these at their defaults on an
|
|
# install without the container is completely inert.
|
|
#
|
|
# Face embeddings are biometric data (GDPR Art. 9 special category in the EU).
|
|
# The photographer is the controller and needs a lawful basis for the people
|
|
# in their photos — see docs/feature-face-recognition.md before enabling.
|
|
#
|
|
# NOT AVAILABLE ON THE ALL-IN-ONE IMAGE. The single-container build sets
|
|
# PICPEAK_SINGLE_CONTAINER=true and the backend refuses to enable face
|
|
# recognition there regardless of these variables or the feature flag: that
|
|
# image runs the backend, frontend, database and every worker in one
|
|
# container, with no ML sidecar to talk to, and face detection would compete
|
|
# with image processing for the same CPU and memory. Use the standard
|
|
# multi-container deployment if you want this feature.
|
|
#
|
|
# FACE_ML_TOKEN (no default — REQUIRED to run the sidecar)
|
|
# Shared secret between the backend and the sidecar. The sidecar refuses to
|
|
# start without it rather than serving anonymously, so an accidentally
|
|
# published port is never a free face-detection API. Generate with:
|
|
# openssl rand -hex 32
|
|
# FACE_ML_TOKEN=
|
|
#
|
|
# FACE_ML_URL (default: http://picpeak-ml:8000)
|
|
# Defaults to the sidecar's compose service name, so the standard
|
|
# deployment needs no configuration here. Only change it if you run the
|
|
# sidecar outside the default compose network.
|
|
# FACE_ML_URL=http://picpeak-ml:8000
|
|
#
|
|
# FACE_PROCESSOR_CONCURRENCY (default: 1)
|
|
# Face-detection workers in the backend. Defaults to 1 deliberately: face
|
|
# scanning shares a host with Sharp image processing, which is the real
|
|
# memory pressure (see UPLOAD_PROCESSOR_CONCURRENCY). Raise only on hosts
|
|
# with headroom to spare.
|
|
# FACE_PROCESSOR_CONCURRENCY=1
|
|
#
|
|
# FACE_ORT_THREADS (default: 1)
|
|
# ONNX Runtime threads inside the sidecar. More threads mean faster
|
|
# per-photo inference and higher RSS.
|
|
# FACE_ORT_THREADS=1
|
|
|
|
# Note on FRONTEND_API_URL (documentation only):
|
|
# When using pre-built frontend images, runtime env vars cannot override the built JS.
|
|
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
|
|
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
|
|
# should you change VITE_API_URL at build time.
|