Merge pull request #568 from the-luap/release/3.55.0-merge-from-beta

chore(release): promote beta → main as v3.55.0
This commit is contained in:
Paul Nothaft
2026-05-27 21:48:31 +02:00
committed by GitHub
218 changed files with 34152 additions and 4695 deletions
+10 -6
View File
@@ -68,6 +68,16 @@ [email protected]
FRONTEND_URL=https://yourdomain.com FRONTEND_URL=https://yourdomain.com
ADMIN_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) # API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images. # This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails. # If not set, defaults to http://localhost:3001 which will show broken images in emails.
@@ -97,12 +107,6 @@ UPDATE_CHECK_ENABLED=true
# Timezone # Timezone
TZ=UTC TZ=UTC
# Runtime user mapping for Docker (optional)
# Set these to your host user's UID/GID to avoid permission issues on bind mounts.
# Run `id -u` and `id -g` on host to get values. Defaults to 1001.
PUID=1001
PGID=1001
# Analytics (Optional - Umami) # Analytics (Optional - Umami)
VITE_UMAMI_URL= VITE_UMAMI_URL=
VITE_UMAMI_WEBSITE_ID= VITE_UMAMI_WEBSITE_ID=
+89 -36
View File
@@ -45,6 +45,13 @@ env:
# Computing them with bash parameter expansion (${VAR,,}) keeps the workflow # Computing them with bash parameter expansion (${VAR,,}) keeps the workflow
# working on forks regardless of the owner's name casing. # working on forks regardless of the owner's name casing.
# Default GITHUB_TOKEN to read-only at the workflow level. Each job that
# needs to publish to GHCR sets `packages: write` explicitly. This keeps
# the rest of the workflow (and any future steps) from inheriting unneeded
# privileges (CKV2_GHA_1).
permissions:
contents: read
jobs: jobs:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Backend: per-arch build, then merge into a multi-arch manifest # Backend: per-arch build, then merge into a multi-arch manifest
@@ -62,6 +69,11 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
# Trivy uploads its SARIF to the Security tab from this job — see
# the "Run Trivy" step below. Scanning per-arch by digest (#476)
# is reliable; scanning the multi-arch index by tag from the
# merge-* job was not.
security-events: write
steps: steps:
- name: Checkout code - name: Checkout code
@@ -146,13 +158,58 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
# Per-arch vulnerability scan (#476). Scanning the multi-arch
# manifest from the merge-* job by tag is unreliable — Trivy's
# remote resolver crashes intermittently with "no child with
# platform linux/amd64 in index". The fix is to scan each leg
# by its single-platform digest right here, where it just landed
# in GHCR. Tag pinned (was @master) so the action + bundled
# Trivy binary don't float between runs.
#
# exit-code is left unset (=0) for now: Trivy reports findings
# to the Security tab but doesn't fail the build. Flipping that
# to '1' to actually gate CI is a deliberate follow-up — needs an
# audit pass first so the next beta build doesn't surprise red.
- name: Run Trivy vulnerability scanner (per-arch, by digest)
if: steps.push-decision.outputs.push == 'true'
uses: aquasecurity/[email protected]
env:
# docker/build-push-action wraps every push in an OCI index
# (carries the SLSA provenance attestation alongside the
# actual image). Trivy's remote backend defaults to
# linux/amd64 regardless of host arch when resolving an
# index, which makes the arm64 leg crash with "no child
# with platform linux/amd64". Telling Trivy which child to
# scan keeps the provenance attestation intact and fixes
# the resolver crash. Pin to matrix.platform so each leg
# scans its own arch.
TRIVY_PLATFORM: ${{ matrix.platform }}
with:
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
output: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: steps.push-decision.outputs.push == 'true'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
# Distinct category per arch so the Security tab surfaces
# per-platform findings independently — an amd64-only CVE in
# a base layer doesn't get masked by the arm64 scan.
category: 'backend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
merge-backend: merge-backend:
needs: build-backend needs: build-backend
runs-on: ubuntu-latest runs-on: ubuntu-latest
# No security-events permission here — vulnerability scanning moved
# to per-arch build-backend jobs (#476). This job's only job is to
# combine the per-arch digests into a multi-arch manifest.
permissions: permissions:
contents: read contents: read
packages: write packages: write
security-events: write
# Only run when at least one digest was pushed (i.e. not on PRs without push intent). # Only run when at least one digest was pushed (i.e. not on PRs without push intent).
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
@@ -224,23 +281,6 @@ jobs:
run: | run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }} docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
format: 'sarif'
output: 'trivy-backend.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-backend.sarif'
category: 'backend-vulnerabilities'
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Frontend: per-arch build, then merge into a multi-arch manifest # Frontend: per-arch build, then merge into a multi-arch manifest
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -257,6 +297,9 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
# See build-backend for the rationale (#476). Same pattern: per-arch
# vulnerability scan by digest, SARIF uploaded to the Security tab.
security-events: write
steps: steps:
- name: Checkout code - name: Checkout code
@@ -341,13 +384,40 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
# Per-arch vulnerability scan (#476). See build-backend for the
# full rationale; identical pattern here, only the image-ref +
# SARIF filename + category change.
- name: Run Trivy vulnerability scanner (per-arch, by digest)
if: steps.push-decision.outputs.push == 'true'
uses: aquasecurity/[email protected]
env:
# See build-backend for the rationale — pin Trivy's platform
# to the matrix arch so its remote-index resolver picks the
# right child instead of defaulting to linux/amd64 and
# crashing on the arm64 leg.
TRIVY_PLATFORM: ${{ matrix.platform }}
with:
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
output: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: steps.push-decision.outputs.push == 'true'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
category: 'frontend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
merge-frontend: merge-frontend:
needs: build-frontend needs: build-frontend
runs-on: ubuntu-latest runs-on: ubuntu-latest
# See merge-backend — vulnerability scanning moved to the per-arch
# build-frontend matrix (#476). This job only publishes the manifest.
permissions: permissions:
contents: read contents: read
packages: write packages: write
security-events: write
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps: steps:
@@ -418,23 +488,6 @@ jobs:
run: | run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }} docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
format: 'sarif'
output: 'trivy-frontend.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-frontend.sarif'
category: 'frontend-vulnerabilities'
summary: summary:
needs: [build-backend, merge-backend, build-frontend, merge-frontend] needs: [build-backend, merge-backend, build-frontend, merge-frontend]
if: always() if: always()
+227
View File
@@ -0,0 +1,227 @@
name: Fresh-install smoke
# Verifies that a clean Postgres install boots cleanly under the same
# conditions a new user hits on their first `docker compose up -d`. The
# specific scenarios this guards against — see #484 for the original
# reproduction:
#
# 1. Bind-mounted host directories owned by a UID other than 1001
# (the container's nodejs user). The entrypoint must self-chown
# and drop privileges via su-exec.
# 2. Cold-start Postgres with no prior schema (the FK-order bug fixed
# in #494, the index/created_at error fixed in #511, and any
# future migration-order issue that only surfaces on an empty DB).
#
# Triggers only on changes that touch the install path so unrelated PRs
# don't pay the build cost.
on:
push:
branches: [main, beta]
paths:
- 'backend/Dockerfile'
- 'backend/wait-for-db.sh'
- 'backend/migrations/**'
- 'backend/package*.json'
- 'docker-compose.production.yml'
- '.github/workflows/install-smoke.yml'
pull_request:
branches: [main, beta]
paths:
- 'backend/Dockerfile'
- 'backend/wait-for-db.sh'
- 'backend/migrations/**'
- 'backend/package*.json'
- 'docker-compose.production.yml'
- '.github/workflows/install-smoke.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
fresh-install:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Build for the runner's arch only — we just need a runnable image.
# The full multi-arch build is the docker-build workflow's job.
- name: Build backend image
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
load: true
tags: picpeak-backend:smoke
cache-from: type=gha,scope=install-smoke
cache-to: type=gha,mode=max,scope=install-smoke
- name: Create Docker network
run: docker network create picpeak-smoke
# Mount as UID 1000 (the typical GitHub Actions runner user, and a
# common mismatch case on Linux hosts). The entrypoint must chown
# this to 1001 itself — that's the regression we're guarding.
- name: Prepare host bind-mount dirs owned by UID 1000
run: |
mkdir -p smoke-mounts/storage smoke-mounts/data smoke-mounts/logs
chmod 755 smoke-mounts smoke-mounts/*
ls -ld smoke-mounts/*
- name: Start Postgres
run: |
docker run -d --name picpeak-smoke-pg --network picpeak-smoke \
-e POSTGRES_USER=picpeak \
-e POSTGRES_PASSWORD=smokepass \
-e POSTGRES_DB=picpeak_prod \
--health-cmd="pg_isready -U picpeak -d picpeak_prod" \
--health-interval=2s --health-timeout=2s --health-retries=30 \
postgres:15-alpine
- name: Wait for Postgres healthy
run: |
for i in $(seq 1 60); do
status=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-pg 2>/dev/null || echo starting)
if [ "$status" = "healthy" ]; then
echo "postgres healthy after ${i}s"
exit 0
fi
sleep 1
done
echo "postgres did not become healthy in 60s"
docker logs picpeak-smoke-pg
exit 1
- name: Start backend with mismatched-UID bind mounts (fresh install)
run: |
docker run -d --name picpeak-smoke-bk --network picpeak-smoke \
-e NODE_ENV=production \
-e JWT_SECRET=smoketestsecretvalueof32characters \
-e DB_HOST=picpeak-smoke-pg \
-e DB_USER=picpeak \
-e DB_PASSWORD=smokepass \
-e DB_NAME=picpeak_prod \
-e [email protected] \
-e ADMIN_PASSWORD=smokeAdminPass12345 \
-e STORAGE_PATH=/app/storage \
-v "$PWD/smoke-mounts/storage:/app/storage" \
-v "$PWD/smoke-mounts/data:/app/data" \
-v "$PWD/smoke-mounts/logs:/app/logs" \
picpeak-backend:smoke
- name: Wait for backend healthy
run: |
for i in $(seq 1 120); do
status=$(docker inspect -f '{{.State.Status}}' picpeak-smoke-bk 2>/dev/null || echo missing)
health=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-bk 2>/dev/null || echo none)
if [ "$status" = "exited" ]; then
echo "FAIL: backend exited during cold-start (restart loop scenario)"
docker logs picpeak-smoke-bk
echo "--- error.log ---"
cat smoke-mounts/logs/error.log 2>/dev/null || echo "(no error.log)"
exit 1
fi
if [ "$health" = "healthy" ]; then
echo "backend healthy after ${i}s"
exit 0
fi
sleep 1
done
echo "FAIL: backend did not become healthy in 120s"
docker ps -a
docker logs picpeak-smoke-bk
exit 1
- name: Verify chown happened (container view)
run: |
# All three dirs should now be owned by nodejs (UID 1001).
# If the entrypoint's self-chown branch didn't fire, they'd
# still be owned by the runner UID and node would have hit
# EACCES creating storage subdirs.
for d in /app/storage /app/data /app/logs; do
owner_uid=$(docker exec picpeak-smoke-bk stat -c '%u' "$d")
if [ "$owner_uid" != "1001" ]; then
echo "FAIL: $d is owned by UID $owner_uid (expected 1001)"
exit 1
fi
echo "ok: $d owned by UID $owner_uid"
done
- name: Verify app is actually serving
run: |
# /health is what docker's HEALTHCHECK polls, but hit it
# directly to confirm the response shape matches what the
# frontend + reverse proxy expect.
body=$(docker exec picpeak-smoke-bk wget -qO- http://localhost:3000/health)
echo "/health => $body"
echo "$body" | grep -q '"status":"ok"' || {
echo "FAIL: /health did not return status:ok"
exit 1
}
- name: Verify node runs as nodejs (not root)
run: |
# dumb-init runs as root (PID 1), node must be running as
# nodejs (UID 1001) — if su-exec drop didn't happen the app
# would be running as root which is the security regression
# we're guarding against. Alpine ships BusyBox ps, which
# doesn't support `-p PID` or pgrep, so list + awk instead.
user=$(docker exec picpeak-smoke-bk ps -o user,comm | awk '$2=="node" {print $1; exit}')
if [ "$user" != "nodejs" ]; then
echo "FAIL: node running as '$user' (expected nodejs)"
docker exec picpeak-smoke-bk ps -o pid,user,comm
exit 1
fi
echo "ok: node running as $user"
- name: Verify no restart loop
run: |
restart_count=$(docker inspect -f '{{.RestartCount}}' picpeak-smoke-bk)
if [ "$restart_count" -gt 0 ]; then
echo "FAIL: container restarted $restart_count time(s) — install loop bug returning"
docker logs picpeak-smoke-bk
exit 1
fi
echo "ok: 0 restarts"
# Restart with `--user 5005:5005` (no root, can't chown) against
# bind mounts owned by 1000 — entrypoint must fail loud with the
# actionable preflight error, not silently restart-loop.
- name: Verify preflight fails loud on unwritable mounts
run: |
docker rm -f picpeak-smoke-bk2 2>/dev/null || true
set +e
out=$(docker run --rm --user 5005:5005 --network picpeak-smoke \
-e NODE_ENV=production -e JWT_SECRET=x \
-e DB_HOST=picpeak-smoke-pg -e DB_USER=picpeak \
-e DB_PASSWORD=smokepass -e DB_NAME=picpeak_prod \
-e STORAGE_PATH=/app/storage \
-v "$PWD/smoke-mounts/storage:/app/storage" \
-v "$PWD/smoke-mounts/data:/app/data" \
-v "$PWD/smoke-mounts/logs:/app/logs" \
picpeak-backend:smoke 2>&1)
rc=$?
set -e
echo "$out"
if [ $rc -eq 0 ]; then
echo "FAIL: preflight should have exited non-zero"
exit 1
fi
echo "$out" | grep -q "is not writable by UID 5005" || {
echo "FAIL: preflight error message missing or wrong"
exit 1
}
echo "ok: preflight failed loud with actionable error"
- name: Cleanup
if: always()
run: |
docker rm -f picpeak-smoke-bk picpeak-smoke-bk2 picpeak-smoke-pg 2>/dev/null || true
docker network rm picpeak-smoke 2>/dev/null || true
+189
View File
@@ -0,0 +1,189 @@
name: Schema drift (#530)
# Verifies that `migrate:safe` can recover a DB that's been seeded only
# by `initializeDatabase()` — the recovery scenario where the migrations
# tracking table is empty but the schema already has the modern bootstrap.
#
# This is NOT how production reaches its state on normal installs or
# upgrades. The scenario only fires when:
# - A backup was restored that captured tables but not the migrations
# table (manifest divergence),
# - Someone manually invoked initializeDatabase() outside the migration
# runner (recovery / debugging),
# - The DB was moved between systems and the migrations table was not
# copied along.
#
# When `detectExistingSchema()` sees the modern-bootstrap fingerprint
# (photo_categories + cms_pages tables) but an empty migrations table,
# it treats it as an "existing deployment" — which runs the legacy
# chain first. Legacy/008 renames email_templates.subject → subject_en,
# but core/029 (which runs later in this chain) inserts email templates
# referencing the pre-rename column name. The chain dies with a
# "column subject does not exist" error.
#
# Fix (in the same PR as this workflow): when the modern-bootstrap
# fingerprint is detected, mark all legacy migrations as applied so the
# chain matches what a fresh install runs — only core/*, in order.
#
# This workflow boots the failing scenario from scratch on every PR
# that touches the migrations or db.js, so any future migration with
# the same shape is caught before merge.
on:
push:
branches: [main, beta]
paths:
- 'backend/migrations/**'
- 'backend/src/database/db.js'
- 'backend/knexfile.js'
- '.github/workflows/schema-drift.yml'
pull_request:
branches: [main, beta]
paths:
- 'backend/migrations/**'
- 'backend/src/database/db.js'
- 'backend/knexfile.js'
- '.github/workflows/schema-drift.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
upgrade-from-bootstrap:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: picpeak
POSTGRES_PASSWORD: testpass
POSTGRES_DB: picpeak_drift
options: >-
--health-cmd "pg_isready -U picpeak -d picpeak_drift"
--health-interval 2s
--health-timeout 2s
--health-retries 30
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- name: Install backend deps
working-directory: ./backend
run: npm ci
# Step 1: simulate the recovery state — DB has the modern bootstrap
# (post-initializeDatabase) but no migrations recorded. Calling
# initializeDatabase() directly outside the migration runner is the
# one-line repro for backup-restore-lost-migrations and manual-
# invocation paths.
- name: Seed DB with initializeDatabase() only
working-directory: ./backend
env:
NODE_ENV: production
DATABASE_CLIENT: pg
DB_HOST: localhost
DB_PORT: 5432
DB_USER: picpeak
DB_PASSWORD: testpass
DB_NAME: picpeak_drift
run: |
node -e "require('./src/database/db').initializeDatabase().then(() => { console.log('bootstrap ok'); process.exit(0); }).catch(e => { console.error('bootstrap FAILED:', e.message); process.exit(1); })"
# Sanity-check the recovery shape before migrate:safe runs. If
# initializeDatabase() ever stops producing photo_categories +
# cms_pages, the fingerprint check would silently no-op and this
# workflow would lose its teeth — assert the precondition.
- name: Assert recovery-state fingerprint
env:
PGPASSWORD: testpass
run: |
installed=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public' AND tablename IN ('photo_categories', 'cms_pages')")
if [ "$installed" != "2" ]; then
echo "FAIL: expected photo_categories + cms_pages from initializeDatabase(); got $installed."
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
exit 1
fi
# initializeDatabase() doesn't create the `migrations` tracking
# table — that's the migrate:safe runner's job. So in the recovery
# scenario, the table either (a) doesn't exist yet or (b) exists
# but is empty (e.g. someone created it but didn't populate it).
# Both are valid recovery states; check via to_regclass first so
# we don't parse a SELECT against a nonexistent table.
has_migrations_table=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT to_regclass('public.migrations')::text")
if [ -z "$has_migrations_table" ]; then
migrations_count=0
else
migrations_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations")
fi
if [ "$migrations_count" != "0" ]; then
echo "FAIL: migrations table should be empty for the recovery scenario; has $migrations_count rows."
exit 1
fi
echo "ok: recovery state confirmed (bootstrap tables present, migrations table empty or absent)."
# Step 2: run migrate:safe — the test. Before #530's fix in
# detectExistingSchema, this died at core/029 with a "column
# subject does not exist" error. After the fix, it should complete
# cleanly with every migration either applied or marked.
- name: Run migrate:safe against the recovery state
working-directory: ./backend
env:
NODE_ENV: production
DATABASE_CLIENT: pg
DB_HOST: localhost
DB_PORT: 5432
DB_USER: picpeak
DB_PASSWORD: testpass
DB_NAME: picpeak_drift
run: npm run migrate:safe
# Step 3: schema-shape assertion. A fresh install through migrate:
# safe produces 48 tables; the recovery scenario should converge
# to the same number. Off-by-one is fine but a 10+ table delta
# means a migration silently bailed in the recovery path.
- name: Assert final schema matches fresh-install shape
env:
PGPASSWORD: testpass
run: |
tables=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public'")
echo "Final table count: $tables"
# Allow a small drift window — exact count creeps over time as
# new migrations land; tight pin would force a workflow edit
# on every schema PR. 40+ is a healthy floor that catches the
# original bug (which left 17 tables) while staying robust to
# forward changes.
if [ "$tables" -lt 40 ]; then
echo "FAIL: too few tables ($tables) — migrate:safe likely bailed mid-chain."
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
exit 1
fi
echo "ok: schema converged to a fresh-install-equivalent shape."
# Step 4: verify the legacy migrations were all marked applied
# (rather than silently bailing inside the chain). The fix in
# detectExistingSchema marks legacy/* when the modern bootstrap
# is detected — confirm the markings actually landed.
- name: Assert legacy migrations marked applied
env:
PGPASSWORD: testpass
run: |
legacy_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations WHERE filename LIKE '008_%' OR filename LIKE '009_%' OR filename LIKE '013_%' OR filename LIKE '019_%' OR filename LIKE '020_%' OR filename LIKE '026_%' OR filename LIKE '028_%'")
if [ "$legacy_count" -lt 7 ]; then
echo "FAIL: legacy migrations not marked applied ($legacy_count of 7 expected)."
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT filename FROM migrations WHERE filename LIKE '0%' ORDER BY filename"
exit 1
fi
echo "ok: legacy migrations marked applied by detectExistingSchema."
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "3.42.1-beta.0" ".": "3.55.0-beta.0"
} }
+1 -1
View File
@@ -38,7 +38,7 @@ Unsure where to begin? You can start by looking through these issues:
### Pull Requests ### Pull Requests
1. **Fork the repo** and create your branch from `main` 1. **Fork the repo** and create your branch from `beta`
2. **Install dependencies**: 2. **Install dependencies**:
```bash ```bash
cd backend && npm install cd backend && npm install
+22 -9
View File
@@ -10,18 +10,31 @@ PORT=3001
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456 JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
# Auth cookie Secure flag # Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false) # unset - default: 'auto' in production, false in dev (#427)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access) # true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access
# login appears to succeed but the browser silently drops the
# cookie, leaving you in a redirect loop. Only set this if you
# ALWAYS reach the site via HTTPS)
# false - never set Secure (allows HTTP; cookies not protected on HTTPS) # false - never set Secure (allows HTTP; cookies not protected on HTTPS)
# auto - decide per request: Secure on HTTPS, not on HTTP # auto - decide per request: Secure on HTTPS, not on HTTP. Reads
# req.secure from Express which respects X-Forwarded-Proto from a
# trusted reverse proxy. This is the default and is the right
# choice for most deployments.
# #
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS # Why 'auto' is the default in production:
# (via a reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain # - On real HTTPS (reverse proxy with X-Forwarded-Proto), req.secure is
# HTTP (e.g. LAN access at http://192.168.x.x:3001). The backend reads # true → Secure flag is still emitted. No security regression vs. true.
# req.secure from Express, which respects the X-Forwarded-Proto header # - On plain HTTP (LAN access, first-time install before reverse proxy is
# when the proxy is in the trust list. # wired up), req.secure is false → Secure flag is omitted → login works
# instead of silently looping back to /admin/login.
# #
# Requirements for auto mode: # When you'd set this explicitly:
# - COOKIE_SECURE=true → strict HTTPS-only deployments where you want
# defense in depth against accidentally serving over HTTP.
# - COOKIE_SECURE=false → you intentionally only ever serve over HTTP and
# don't want the per-request check (rare).
#
# Requirements for 'auto' mode to detect HTTPS correctly:
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS # 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
# requests. Standard configs for NPM/Traefik/Caddy do this by default. # 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 # 2. The proxy must be on a trusted IP range. By default PicPeak trusts
+16 -4
View File
@@ -35,12 +35,15 @@ RUN apk upgrade --no-cache
RUN npm install -g npm@10 RUN npm install -g npm@10
# Install dumb-init for proper signal handling, postgresql-client for database # Install dumb-init for proper signal handling, postgresql-client for database
# checks, and ffmpeg for video upload support. Alpine's ffmpeg package ships # checks, ffmpeg for video upload support, and su-exec for the root → nodejs
# both `ffmpeg` and `ffprobe` built natively against musl libc — the npm # privilege drop in wait-for-db.sh (see #484: container starts as root so it
# can chown bind-mounted host volumes to UID 1001, then re-execs as nodejs
# before running the app). Alpine's ffmpeg package ships both `ffmpeg` and
# `ffprobe` built natively against musl libc — the npm
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably # `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video # run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
# pipeline calls via fluent-ffmpeg.ffprobe()). # pipeline calls via fluent-ffmpeg.ffprobe()).
RUN apk add --no-cache dumb-init postgresql-client ffmpeg RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec
# Create non-root user # Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
@@ -56,9 +59,18 @@ RUN chmod -R a+r /app && chmod +x wait-for-db.sh
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \ RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
chown -R nodejs:nodejs storage data logs chown -R nodejs:nodejs storage data logs
USER nodejs # No USER directive — the container starts as root so wait-for-db.sh can
# chown bind-mounted host directories to UID 1001 before dropping privs
# via su-exec. See #484 for the fresh-install restart loop this avoids.
EXPOSE 3000 EXPOSE 3000
# Healthcheck hits the same /health endpoint already used by the e2e
# runner and by the docker-compose `depends_on: condition: service_healthy`
# checks. wget is part of the Alpine base image. Long start-period covers
# the wait-for-db.sh delay before the Node process starts listening.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
ENTRYPOINT ["dumb-init", "--"] ENTRYPOINT ["dumb-init", "--"]
CMD ["./wait-for-db.sh", "node", "server.js"] CMD ["./wait-for-db.sh", "node", "server.js"]
+3
View File
@@ -30,5 +30,8 @@ USER nodejs
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
ENTRYPOINT ["dumb-init", "--"] ENTRYPOINT ["dumb-init", "--"]
CMD ["npm", "run", "dev"] CMD ["npm", "run", "dev"]
@@ -137,6 +137,41 @@ describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
expect(await storage.exists(key)).toBe(true); expect(await storage.exists(key)).toBe(true);
}); });
test('generatePreviewImage writes to /previews and skips enlargement of small originals', async () => {
const src = await makeSourceJpeg(tmpDir, 'preview-source.jpg');
const key = await imageProcessor.generatePreviewImage(src);
expect(key).toBe('previews/preview_preview-source.jpg');
expect(await storage.exists(key)).toBe(true);
if (storage.kind() === 'local') {
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
expect(meta.format).toBe('jpeg');
// Source is 800x600 and default longEdge is 1920 with
// withoutEnlargement: true → preview must NOT be upscaled.
expect(meta.width).toBe(800);
expect(meta.height).toBe(600);
}
});
test('generatePreviewImage shrinks oversized images to fit longEdge while preserving aspect', async () => {
const src = await makeSourceJpeg(tmpDir, 'preview-shrink.jpg');
const key = await imageProcessor.generatePreviewImage(src, { longEdge: 400 });
expect(await storage.exists(key)).toBe(true);
if (storage.kind() === 'local') {
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
// 800x600 → fit:'inside' inside 400×400 → 400×300.
expect(meta.width).toBe(400);
expect(meta.height).toBe(300);
}
});
test('isPreviewValid returns true for a real preview and false for a missing key', async () => {
const src = await makeSourceJpeg(tmpDir, 'preview-valid.jpg');
const key = await imageProcessor.generatePreviewImage(src);
expect(await imageProcessor.isPreviewValid(key)).toBe(true);
expect(await imageProcessor.isPreviewValid('previews/does-not-exist.jpg')).toBe(false);
});
test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => { test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => {
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg'); const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
const key = await imageProcessor.generateThumbnail(src); const key = await imageProcessor.generateThumbnail(src);
@@ -0,0 +1,123 @@
/**
* Pin the date-field normalisation in adminUsers transformer (#485).
*
* The Users page crashed on native/SQLite installs because Postgres
* returned ISO strings while SQLite returned epoch-millisecond
* integers, and the frontend `parseISO()` blew up on numbers with
* "e.split is not a function". The transformer now coerces every
* shape to an ISO 8601 string before serialising.
*
* These tests guard the contract so a future refactor can't quietly
* regress and re-break the same page on the same DB.
*/
const adminUsersRoute = require('../../src/routes/adminUsers');
const { toIso, transformUser, transformInvitation } = adminUsersRoute.__test;
describe('toIso', () => {
it('passes null and undefined through unchanged', () => {
expect(toIso(null)).toBeNull();
expect(toIso(undefined)).toBeUndefined();
// Empty string also short-circuits — important so an unset
// last_login renders as "Never" instead of 1970-01-01T00:00:00Z.
expect(toIso('')).toBe('');
});
it('coerces an integer epoch (SQLite shape) to an ISO 8601 string', () => {
// 2026-05-14T10:00:00.000Z, in epoch ms.
const epochMs = 1778752800000;
expect(toIso(epochMs)).toBe('2026-05-14T10:00:00.000Z');
});
it('coerces a stringified large integer to an ISO 8601 string', () => {
// Some SQLite drivers stringify large integers because they
// overflow JS safe-integer in the driver's serialiser. Re-coerce
// so the frontend doesn't try to parseISO('1778752800000').
expect(toIso('1778752800000')).toBe('2026-05-14T10:00:00.000Z');
});
it('coerces a Date instance via toISOString', () => {
const d = new Date('2026-01-01T12:34:56.000Z');
expect(toIso(d)).toBe('2026-01-01T12:34:56.000Z');
});
it('passes an existing ISO string through unchanged', () => {
const iso = '2026-05-14T10:00:00.000Z';
expect(toIso(iso)).toBe(iso);
});
it('passes a non-numeric short string (e.g. truncated date) through unchanged', () => {
// Defensive: anything that isn't a 10+ digit integer string is
// treated as already-stringified — the date library will surface
// the failure cleanly if it's malformed, rather than the
// transformer silently rewriting it.
expect(toIso('2026-05-14')).toBe('2026-05-14');
});
});
describe('transformUser', () => {
it('normalises last_login, created_at, updated_at coming from SQLite', () => {
const sqliteRow = {
id: 1,
username: 'admin',
email: '[email protected]',
is_active: 1,
last_login: 1778752800000, // epoch ms
last_login_ip: '127.0.0.1',
created_at: 1778751144600, // epoch ms
updated_at: 1778751242320, // epoch ms
role_id: 1,
role_name: 'super_admin',
role_display_name: 'Super Admin',
created_by_username: null,
};
const out = transformUser(sqliteRow);
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
expect(out.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(out.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
// Other fields untouched.
expect(out.username).toBe('admin');
expect(out.lastLoginIp).toBe('127.0.0.1');
});
it('leaves Postgres ISO strings intact', () => {
const pgRow = {
id: 2,
username: 'second',
email: '[email protected]',
is_active: true,
last_login: '2026-05-14T10:00:00.000Z',
created_at: '2026-05-13T08:00:00.000Z',
updated_at: '2026-05-14T09:00:00.000Z',
};
const out = transformUser(pgRow);
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
expect(out.createdAt).toBe('2026-05-13T08:00:00.000Z');
expect(out.updatedAt).toBe('2026-05-14T09:00:00.000Z');
});
it('keeps last_login null when the user has never logged in', () => {
const out = transformUser({
id: 3, username: 'fresh', email: '[email protected]',
is_active: 1, last_login: null,
});
expect(out.lastLogin).toBeNull();
});
});
describe('transformInvitation', () => {
it('normalises expires_at and created_at from SQLite epoch-ms', () => {
const out = transformInvitation({
id: 9,
email: '[email protected]',
expires_at: 1779357600000,
created_at: 1778752800000,
role_name: 'admin',
invited_by: 'admin',
});
expect(out.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(out.createdAt).toBe('2026-05-14T10:00:00.000Z');
});
});
@@ -0,0 +1,76 @@
/**
* Pure-logic tests for the #493 download-filename helpers that don't depend
* on the DB (those are covered by the route integration suite).
*/
const {
pickRawDownloadName,
getZipEntryNames,
} = require('../../src/services/downloadFilenameService');
describe('pickRawDownloadName', () => {
it('returns the storage filename when the toggle is off', () => {
expect(
pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, false)
).toBe('slug_001.jpg');
});
it('returns original_filename when the toggle is on', () => {
expect(
pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, true)
).toBe('DSC_1.jpg');
});
it('falls back to storage filename when original_filename is missing', () => {
expect(
pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: null }, true)
).toBe('slug_001.jpg');
});
it('produces a stable last-resort name when both are missing', () => {
expect(pickRawDownloadName({ id: 42 }, true)).toBe('photo-42.jpg');
});
});
describe('getZipEntryNames', () => {
it('uses original filenames with deterministic suffixes on collision', () => {
const photos = [
{ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1234.jpg' },
{ id: 2, filename: 'slug_002.jpg', original_filename: 'DSC_1234.jpg' },
{ id: 3, filename: 'slug_003.jpg', original_filename: 'DSC_1235.jpg' },
];
expect(getZipEntryNames(photos, true)).toEqual([
'DSC_1234.jpg',
'DSC_1234_1.jpg',
'DSC_1235.jpg',
]);
});
it('falls back to storage filename per-photo when original is missing', () => {
const photos = [
{ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' },
{ id: 2, filename: 'slug_002.jpg', original_filename: null },
];
expect(getZipEntryNames(photos, true)).toEqual([
'DSC_1.jpg',
'slug_002.jpg',
]);
});
it('returns storage filenames when the toggle is off, dedup still applies', () => {
const photos = [
{ id: 1, filename: 'a.jpg', original_filename: 'DSC_1.jpg' },
{ id: 2, filename: 'a.jpg', original_filename: 'DSC_2.jpg' },
];
expect(getZipEntryNames(photos, false)).toEqual(['a.jpg', 'a_1.jpg']);
});
it('sanitizes path-traversal attempts that sneak into original_filename', () => {
const photos = [
{ id: 1, filename: 'slug_001.jpg', original_filename: '../etc/passwd' },
];
const [name] = getZipEntryNames(photos, true);
expect(name).not.toContain('..');
expect(name).not.toContain('/');
});
});
Binary file not shown.
-50
View File
@@ -1,50 +0,0 @@
#!/bin/sh
# init-production.sh - Production initialization script
set -e
echo "🚀 Initializing PicPeak Production Environment..."
# Wait for services to be ready
echo "⏳ Waiting for database to be fully ready..."
sleep 3
# Fix permissions if running as root (shouldn't happen with proper Dockerfile)
if [ "$(id -u)" = "0" ]; then
echo "🔧 Fixing file permissions..."
chown -R nodejs:nodejs /app/storage /app/data /app/logs 2>/dev/null || true
fi
# Create required directories
echo "📁 Creating required directories..."
mkdir -p /app/storage/events/active \
/app/storage/events/archived \
/app/storage/thumbnails \
/app/storage/uploads/logos \
/app/storage/uploads/favicons \
/app/data \
/app/logs
# Run migrations with safe runner
echo "🗄️ Running database migrations (safe mode)..."
NODE_ENV=production npm run migrate:safe
# Create admin user if environment variables are set
if [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then
echo "👤 Creating admin user..."
node scripts/create-admin.js \
--email "$ADMIN_EMAIL" \
--username "${ADMIN_USERNAME:-admin}" \
--password "$ADMIN_PASSWORD" || echo "Admin user might already exist"
fi
# Initialize email configuration if variables are set
if [ -n "$SMTP_HOST" ]; then
echo "📧 Email configuration detected via environment variables"
fi
echo "✅ Production initialization complete!"
echo "🌐 Starting application server..."
# Start the application
exec node server.js
@@ -83,11 +83,15 @@ async function up() {
}); });
} }
// Add indexes if they don't exist // Add indexes if they don't exist. backup_runs (created in 029) tracks
// chronology via `started_at` — the original `created_at` reference
// here was a bug that emitted a "column does not exist" ERROR in the
// postgres log on every fresh install (silently caught below). See
// migration 105 for the matching back-fix on already-applied installs.
try { try {
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_mode_status ON backup_runs(backup_mode, status)'); await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_mode_status ON backup_runs(backup_mode, status)');
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_parent ON backup_runs(parent_backup_id)'); await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_parent ON backup_runs(parent_backup_id)');
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_created_mode ON backup_runs(created_at, backup_mode)'); await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_started_mode ON backup_runs(started_at, backup_mode)');
} catch (error) { } catch (error) {
console.log('Note: Some indexes may already exist, continuing...'); console.log('Note: Some indexes may already exist, continuing...');
} }
@@ -144,16 +148,17 @@ async function up() {
}); });
} }
// Add composite indexes for common query patterns // Add composite indexes for common query patterns. Same `started_at`
// correction as above — `created_at` doesn't exist on backup_runs.
try { try {
await db.raw(` await db.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
ON backup_runs(created_at DESC) ON backup_runs(started_at DESC)
WHERE status = 'completed' AND backup_mode = 'full'; WHERE status = 'completed' AND backup_mode = 'full';
`); `);
await db.raw(` await db.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
ON backup_runs(parent_backup_id, created_at) ON backup_runs(parent_backup_id, started_at)
WHERE backup_mode = 'incremental'; WHERE backup_mode = 'incremental';
`); `);
} catch (error) { } catch (error) {
@@ -194,10 +199,13 @@ async function down() {
}); });
} }
// Drop indexes // Drop indexes. `idx_backup_runs_created_mode` is the legacy name
// shipped by an earlier revision of this migration; kept in the drop
// list so a down() against any historic state cleans up either name.
try { try {
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_mode_status'); await db.raw('DROP INDEX IF EXISTS idx_backup_runs_mode_status');
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_parent'); await db.raw('DROP INDEX IF EXISTS idx_backup_runs_parent');
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_started_mode');
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_created_mode'); await db.raw('DROP INDEX IF EXISTS idx_backup_runs_created_mode');
} catch (error) { } catch (error) {
// Ignore errors if indexes don't exist // Ignore errors if indexes don't exist
@@ -0,0 +1,38 @@
/**
* Migration: re-add the configurable upload batch size setting (#509).
*
* Originally shipped via PR #214 (#208 fix) — users behind Cloudflare
* Tunnel and other reverse proxies with per-request size caps need to
* bound the chunked-upload size so they don't lose every batch >100MB.
* That migration + frontend wiring was lost during a `Merge main into
* beta for release/beta-to-main` resolution that picked main's older
* tree over beta's, silently deleting the file and reinstating the
* hardcoded 500MB chunk in PhotoUpload.tsx.
*
* Re-introducing the exact same migration here. Idempotent: skips the
* insert if the row already exists (e.g. installs that did go through
* the original 072 between #214 merge and the main-into-beta merge,
* where the migrations-table row was preserved even after the file
* was deleted).
*/
exports.up = async function(knex) {
const exists = await knex('app_settings')
.where({ setting_key: 'general_max_upload_batch_size_mb' })
.first();
if (!exists) {
await knex('app_settings').insert({
setting_key: 'general_max_upload_batch_size_mb',
setting_value: JSON.stringify(95),
setting_type: 'general',
updated_at: new Date()
});
}
};
exports.down = async function(knex) {
await knex('app_settings')
.where({ setting_key: 'general_max_upload_batch_size_mb' })
.del();
};
@@ -0,0 +1,130 @@
/**
* Migration 087: Add a dedicated email template for the admin "Send Test
* Email" button on the Update Notifications settings page (#418).
*
* Previously the button reused the version_update_available template via
* sendUpdateNotificationNow(), which bailed out early when no real update
* was pending — so admins on the latest version had no way to verify
* their SMTP / recipient list config worked.
*
* The test template makes the intent unambiguous in the inbox ("This is
* a test of your update-notification setup, no action needed") and lets
* the send code run unconditionally regardless of update availability.
*
* Languages: EN + DE only, matching the convention of the existing
* version_update_available template (070). The email_templates table
* doesn't have nl/pt/ru columns; sendTemplateEmail falls back to EN.
*/
exports.up = async function(knex) {
console.log('Running migration: 087_add_update_notification_test_template');
const existing = await knex('email_templates')
.where('template_key', 'version_update_test')
.first();
if (existing) {
console.log(' version_update_test template already exists, skipping insert');
return;
}
await knex('email_templates').insert({
template_key: 'version_update_test',
subject_en: '[TEST] PicPeak Update Notification — configuration check',
subject_de: '[TEST] PicPeak Update-Benachrichtigung — Konfigurationsprüfung',
body_html_en: `
<h2>This is a test email</h2>
<p>You are receiving this message because an administrator clicked
<strong>Send Test Email</strong> on the Update Notifications page of your
PicPeak installation.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Installed version:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Channel:</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Recipient address:</strong> {{recipient_email}}</p>
</div>
<p>If you can read this email, your SMTP configuration and the recipient
list are working correctly. When a real new version becomes available,
PicPeak will send a separate notification with release notes and update
instructions.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">No action is required.
You may safely delete this message.</p>
<p>Best regards,<br>
Your PicPeak Installation</p>`,
body_text_en: `This is a test email
You are receiving this message because an administrator clicked
"Send Test Email" on the Update Notifications page of your PicPeak
installation.
Installed version: {{current_version}}
Channel: {{channel}}
Recipient address: {{recipient_email}}
If you can read this email, your SMTP configuration and the recipient
list are working correctly. When a real new version becomes available,
PicPeak will send a separate notification with release notes and update
instructions.
No action is required. You may safely delete this message.
Best regards,
Your PicPeak Installation`,
body_html_de: `
<h2>Dies ist eine Test-E-Mail</h2>
<p>Sie erhalten diese Nachricht, weil ein Administrator auf der Seite
"Update-Benachrichtigungen" Ihrer PicPeak-Installation auf
<strong>Test-E-Mail senden</strong> geklickt hat.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Installierte Version:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Kanal:</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Empfänger-Adresse:</strong> {{recipient_email}}</p>
</div>
<p>Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration
und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar
ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen
und Update-Anweisungen.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">Es ist keine Aktion
erforderlich. Sie können diese Nachricht gefahrlos löschen.</p>
<p>Mit freundlichen Grüßen,<br>
Ihre PicPeak-Installation</p>`,
body_text_de: `Dies ist eine Test-E-Mail
Sie erhalten diese Nachricht, weil ein Administrator auf der Seite
"Update-Benachrichtigungen" Ihrer PicPeak-Installation auf
"Test-E-Mail senden" geklickt hat.
Installierte Version: {{current_version}}
Kanal: {{channel}}
Empfänger-Adresse: {{recipient_email}}
Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration
und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar
ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen
und Update-Anweisungen.
Es ist keine Aktion erforderlich. Sie können diese Nachricht gefahrlos löschen.
Mit freundlichen Grüßen,
Ihre PicPeak-Installation`,
variables: JSON.stringify(['current_version', 'channel', 'recipient_email'])
});
console.log('Migration 087_add_update_notification_test_template completed');
};
exports.down = async function(knex) {
console.log('Rollback: 087_add_update_notification_test_template');
await knex('email_templates')
.where('template_key', 'version_update_test')
.del();
};
@@ -0,0 +1,88 @@
/**
* Migration 088: Feature flags table.
*
* Backs the Features tab on the admin Settings page. Flags gate which
* product surfaces appear in the main sidebar and (in future PRs) which
* background jobs run.
*
* Existing-vs-fresh detection:
* The Features tab introduces a curated set of "default ON" flags
* (galleries, reminderEmails, analytics, userManagement) and "default
* OFF" flags for surfaces that aren't built yet (calendar, quotes,
* bills, messaging). For a brand-new install those defaults are right
* out of the box. For an existing install, we want every flag ON so
* nothing in the admin's UI silently disappears the moment they
* upgrade — they can opt out later via the Features tab.
*
* Detection rule: if the `events` table has any rows at migration
* time, treat this as an existing install. Empty events = fresh.
* This is single-shot (the migration only runs once) and atomic
* (no race window). It picks up the rare edge case where an admin
* upgrades immediately after running setup but before creating an
* event — they'll get fresh-install defaults, which is acceptable
* (they can flip flags on the Features page).
*
* Schema:
* - key (PK): the flag identifier (matches FeatureKey on the frontend)
* - value: the boolean state
* - updated_at: last-changed timestamp
* - updated_by: admin id of the last person who flipped it (nullable
* for the migration-seeded rows)
*/
exports.up = async function(knex) {
console.log('Running migration: 088_add_feature_flags');
const exists = await knex.schema.hasTable('feature_flags');
if (!exists) {
await knex.schema.createTable('feature_flags', (table) => {
table.string('key', 64).primary();
table.boolean('value').notNullable();
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.integer('updated_by').references('id').inTable('admin_users').onDelete('SET NULL');
});
console.log(' created feature_flags table');
} else {
console.log(' feature_flags table already exists, skipping create');
}
// Detect install age. Use events table — it's user-created content,
// unlike admin_users which is seeded by migration 001.
const eventCountRow = await knex('events').count({ count: '*' }).first();
const eventCount = parseInt(eventCountRow?.count || 0, 10);
const isExistingInstall = eventCount > 0;
console.log(` detected ${isExistingInstall ? 'EXISTING' : 'FRESH'} install (events count: ${eventCount})`);
// Spec defaults (frontend/src/contexts/FeatureFlagsContext.tsx).
// For an existing install every flag becomes TRUE so nothing
// disappears from the admin UI on upgrade.
const FLAGS_FRESH = {
galleries: true, // always-on, locked
reminderEmails: true, // existing cron, locked-on for now
calendar: false, // surface not built yet
calendarBooking: false, // ditto
quotes: false, // surface not built yet
bills: false, // surface not built yet (depends on quotes)
messaging: false, // surface not built yet
analytics: true, // existing surface
userManagement: true, // existing surface
};
const flagsToSeed = isExistingInstall
? Object.fromEntries(Object.keys(FLAGS_FRESH).map((k) => [k, true]))
: FLAGS_FRESH;
for (const [key, value] of Object.entries(flagsToSeed)) {
const existingRow = await knex('feature_flags').where({ key }).first();
if (!existingRow) {
await knex('feature_flags').insert({ key, value });
}
}
console.log(` seeded ${Object.keys(flagsToSeed).length} flags`);
console.log('Migration 088_add_feature_flags completed');
};
exports.down = async function(knex) {
console.log('Rollback: 088_add_feature_flags');
await knex.schema.dropTableIfExists('feature_flags');
};
@@ -0,0 +1,119 @@
/**
* Migration 089: Footer overhaul (#441 + #440).
*
* Three concerns, all in the gallery footer area:
*
* 1. Per-CMS-page "Show in footer" toggle (#441 part a). Lets admins
* hide legal-link entries (Impressum, Datenschutz, etc.) when the
* target jurisdiction doesn't require them. Default TRUE so existing
* installs see no change.
*
* 2. Social links in the footer (#441 part b). Five branding settings
* for the canonical photographer-relevant networks: Facebook,
* Instagram, WhatsApp, X/Twitter, YouTube. All optional strings.
*
* 3. Promotional markdown slot above/below the footer (#440). Global
* default + per-event override with a three-way mode switch:
* - inherit (default): use the global, render nothing if global empty
* - custom: render the per-event markdown
* - off: suppress entirely for this event regardless of global
*
* Markdown only (no raw HTML) — the rendering pipeline is
* `marked → DOMPurify` on the frontend so admins can format text
* without opening an XSS surface.
*/
exports.up = async function(knex) {
console.log('Running migration: 089_footer_overhaul');
// 1. cms_pages.show_in_footer
const hasShowInFooter = await knex.schema.hasColumn('cms_pages', 'show_in_footer');
if (!hasShowInFooter) {
await knex.schema.alterTable('cms_pages', (table) => {
table.boolean('show_in_footer').notNullable().defaultTo(true);
});
console.log(' added cms_pages.show_in_footer (default true)');
} else {
console.log(' cms_pages.show_in_footer already exists, skipping');
}
// 2. events.promo_mode + events.promo_markdown
const hasPromoMode = await knex.schema.hasColumn('events', 'promo_mode');
if (!hasPromoMode) {
await knex.schema.alterTable('events', (table) => {
table.string('promo_mode', 16).notNullable().defaultTo('inherit');
});
console.log(' added events.promo_mode (default "inherit")');
} else {
console.log(' events.promo_mode already exists, skipping');
}
const hasPromoMarkdown = await knex.schema.hasColumn('events', 'promo_markdown');
if (!hasPromoMarkdown) {
await knex.schema.alterTable('events', (table) => {
table.text('promo_markdown').nullable();
});
console.log(' added events.promo_markdown (nullable text)');
} else {
console.log(' events.promo_markdown already exists, skipping');
}
// 3. New branding settings rows. Use the same `branding_<name>`
// convention as the existing 21 branding rows (verified via
// SELECT setting_key FROM app_settings WHERE setting_key LIKE 'branding_%').
const newSettings = [
{ setting_key: 'branding_facebook_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
{ setting_key: 'branding_instagram_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
{ setting_key: 'branding_whatsapp_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
{ setting_key: 'branding_twitter_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
{ setting_key: 'branding_youtube_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
{ setting_key: 'branding_promo_markdown', setting_value: JSON.stringify(''), setting_type: 'branding' },
// 'above_footer' | 'below_footer' (string instead of enum so we can
// expand without a schema change later).
{ setting_key: 'branding_promo_position', setting_value: JSON.stringify('above_footer'), setting_type: 'branding' },
];
for (const setting of newSettings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
}
}
console.log(` ensured ${newSettings.length} branding rows`);
console.log('Migration 089_footer_overhaul completed');
};
exports.down = async function(knex) {
console.log('Rollback: 089_footer_overhaul');
if (await knex.schema.hasColumn('cms_pages', 'show_in_footer')) {
await knex.schema.alterTable('cms_pages', (table) => {
table.dropColumn('show_in_footer');
});
}
if (await knex.schema.hasColumn('events', 'promo_mode')) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('promo_mode');
});
}
if (await knex.schema.hasColumn('events', 'promo_markdown')) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('promo_markdown');
});
}
await knex('app_settings')
.whereIn('setting_key', [
'branding_facebook_url',
'branding_instagram_url',
'branding_whatsapp_url',
'branding_twitter_url',
'branding_youtube_url',
'branding_promo_markdown',
'branding_promo_position',
])
.del();
};
@@ -0,0 +1,313 @@
/**
* Migration: Add Customer Accounts (recurring user logins)
*
* Implements the customer tier from discussion the-luap/picpeak#354.
*
* Three new tables:
* - customer_accounts : the user record (email + bcrypt password)
* - customer_invitations : admin → customer invite handshake (mirrors admin_invitations)
* - event_customer_assignments: many-to-many junction with events
*
* Three new RBAC permissions seeded so super_admin and admin roles can
* manage customers immediately after migrate. Editor / viewer remain
* locked out by design (matches the existing users.* permissions pattern).
*
* Migration is idempotent — every step checks for existing state so a
* partial install can be resumed.
*/
exports.up = async function(knex) {
// ---- customer_accounts -----------------------------------------------
if (!(await knex.schema.hasTable('customer_accounts'))) {
await knex.schema.createTable('customer_accounts', (table) => {
table.increments('id').primary();
table.string('email', 255).unique().notNullable();
// --- auth ---------------------------------------------------------
// password_hash is nullable until the invitation is accepted —
// an unaccepted account row exists only after acceptInvitation, so
// in practice this is always set, but the column is nullable to
// allow for future "admin creates pre-loaded account" flows.
table.string('password_hash', 255);
table.boolean('must_change_password').notNullable().defaultTo(false);
table.boolean('is_active').notNullable().defaultTo(true);
// Tracks password-change time so JWTs issued before a password
// change are rejected by customerAuth middleware. Mirrors the
// admin_users.password_changed_at column.
table.timestamp('password_changed_at');
table.timestamp('last_login');
table.string('last_login_ip', 45);
table.string('preferred_language', 8).defaultTo('en');
// --- contact ------------------------------------------------------
// Salutation honorific (Herr / Frau / Mx / Dr / Other). Stored as
// free text rather than an enum so future locales (German "Frau",
// French "Mme", legal titles "Dr.", etc.) don't need a migration.
table.string('salutation', 32);
table.string('first_name', 80);
table.string('last_name', 80);
// Convenience display name kept separately so the dashboard can
// greet customers without joining first/last (e.g. "Welcome, Luca").
table.string('display_name', 120);
table.string('phone', 40);
table.string('company_name', 120);
// --- billing / address (for future quotes & invoicing) -----------
table.string('billing_email', 255);
table.string('vat_id', 40);
table.string('address_line1', 255);
table.string('address_line2', 255);
table.string('postal_code', 20);
table.string('city', 120);
table.string('state', 120);
table.string('country_code', 2); // ISO 3166-1 alpha-2
// --- audit --------------------------------------------------------
table.text('notes'); // free-text admin notes, never shown to the customer
table.integer('created_by_admin_id').unsigned()
.references('id').inTable('admin_users').onDelete('SET NULL');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.index(['email']);
table.index(['is_active']);
table.index(['last_name']);
table.index(['company_name']);
});
}
// ---- customer_invitations --------------------------------------------
if (!(await knex.schema.hasTable('customer_invitations'))) {
await knex.schema.createTable('customer_invitations', (table) => {
table.increments('id').primary();
table.string('email', 255).notNullable();
// 64 chars = 32 bytes hex = 256 bits — same entropy as admin invites.
table.string('token', 64).unique().notNullable();
table.integer('invited_by').unsigned()
.references('id').inTable('admin_users').onDelete('CASCADE').notNullable();
table.timestamp('expires_at').notNullable();
table.timestamp('accepted_at');
table.integer('accepted_customer_id').unsigned()
.references('id').inTable('customer_accounts').onDelete('SET NULL');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index(['token']);
table.index(['email']);
table.index(['expires_at']);
table.index(['accepted_at']);
});
}
// ---- event_customer_assignments --------------------------------------
if (!(await knex.schema.hasTable('event_customer_assignments'))) {
await knex.schema.createTable('event_customer_assignments', (table) => {
table.increments('id').primary();
table.integer('event_id').unsigned().notNullable()
.references('id').inTable('events').onDelete('CASCADE');
table.integer('customer_account_id').unsigned().notNullable()
.references('id').inTable('customer_accounts').onDelete('CASCADE');
table.integer('assigned_by_admin_id').unsigned()
.references('id').inTable('admin_users').onDelete('SET NULL');
table.timestamp('assigned_at').defaultTo(knex.fn.now());
table.unique(['event_id', 'customer_account_id']);
table.index(['customer_account_id']);
table.index(['event_id']);
});
}
// ---- RBAC permissions -------------------------------------------------
// Insert the three customers.* permissions if they're not already present
// (guards against re-running the migration in dev). The same idempotency
// pattern as 055_add_permissions_table.js.
const existingPermissions = await knex('permissions').select('name');
const existingNames = new Set(existingPermissions.map((p) => p.name));
const newPermissions = [
{
name: 'customers.view',
display_name: 'View Customers',
category: 'customers',
description: 'View customer accounts and their event assignments',
},
{
name: 'customers.create',
display_name: 'Invite Customers',
category: 'customers',
description: 'Issue customer invitations and assign customers to events',
},
{
name: 'customers.delete',
display_name: 'Deactivate Customers',
category: 'customers',
description: 'Deactivate customer accounts and revoke their access',
},
].filter((p) => !existingNames.has(p.name));
if (newPermissions.length > 0) {
await knex('permissions').insert(newPermissions);
}
// Grant the three permissions to super_admin and admin so the feature
// is usable immediately. We look up role / permission ids fresh because
// the inserts above just landed.
const roles = await knex('roles').select('id', 'name')
.whereIn('name', ['super_admin', 'admin']);
const perms = await knex('permissions').select('id', 'name')
.whereIn('name', ['customers.view', 'customers.create', 'customers.delete']);
if (roles.length > 0 && perms.length > 0) {
const existing = await knex('role_permissions').select('role_id', 'permission_id');
const existingSet = new Set(existing.map((m) => `${m.role_id}-${m.permission_id}`));
const inserts = [];
for (const role of roles) {
for (const perm of perms) {
const key = `${role.id}-${perm.id}`;
if (!existingSet.has(key)) {
inserts.push({ role_id: role.id, permission_id: perm.id });
}
}
}
if (inserts.length > 0) {
await knex('role_permissions').insert(inserts);
}
}
// ---- email template seed ---------------------------------------------
// Schema is the post-075 shape: a master `email_templates` row keyed by
// template_key, plus one `email_template_translations` row per language.
// The legacy subject/body_html/body_text columns on email_templates may
// still exist for back-compat, so we populate both wherever the column
// is present — defensive, since some installs may run mid-upgrade.
if (await knex.schema.hasTable('email_templates')) {
const existing = await knex('email_templates')
.where('template_key', 'customer_invitation')
.first();
let templateId = existing?.id;
if (!existing) {
// Build the master row by introspecting the columns that actually
// exist on this install. The schema has drifted across migrations
// (075 adds language-specific columns then 075 normalises into a
// separate translations table; some installs lack `created_at` /
// `updated_at` on the master row). Anything not present is skipped
// silently rather than causing the whole migration to abort and
// taking the backend down with it.
const masterColumns = await knex('email_templates').columnInfo();
const insertRow = {
template_key: 'customer_invitation',
};
if (masterColumns.variables) {
insertRow.variables = JSON.stringify(['invite_link', 'expires_at']);
}
if (masterColumns.created_at) insertRow.created_at = knex.fn.now();
if (masterColumns.updated_at) insertRow.updated_at = knex.fn.now();
// Populate legacy single-language columns when present so older
// email service code paths still find a sensible default body.
if (masterColumns.subject) insertRow.subject = 'You\'ve been invited to access your photo galleries';
if (masterColumns.body_html) {
insertRow.body_html = '<p>You\'ve been invited to create a customer account. <a href="{{invite_link}}">Set up your account</a> (expires {{expires_at}}).</p>';
}
if (masterColumns.body_text) {
insertRow.body_text = 'Set up your customer account: {{invite_link}} (expires {{expires_at}}).';
}
// Some installs have language-specific master columns from migration 075.
if (masterColumns.subject_en) insertRow.subject_en = insertRow.subject || 'You\'ve been invited to access your photo galleries';
if (masterColumns.body_html_en) insertRow.body_html_en = insertRow.body_html || '';
if (masterColumns.body_text_en) insertRow.body_text_en = insertRow.body_text || '';
const [insertedId] = await knex('email_templates').insert(insertRow).returning('id');
templateId = insertedId?.id || insertedId;
}
if (templateId && await knex.schema.hasTable('email_template_translations')) {
const transColumns = await knex('email_template_translations').columnInfo();
const buildTranslationRow = (language, subject, bodyHtml, bodyText) => {
const row = { template_id: templateId, language };
if (transColumns.subject) row.subject = subject;
if (transColumns.body_html) row.body_html = bodyHtml;
if (transColumns.body_text) row.body_text = bodyText;
if (transColumns.created_at) row.created_at = new Date();
if (transColumns.updated_at) row.updated_at = new Date();
return row;
};
// The button uses the wrapper's `.button` class instead of inline
// styles, which inherits the admin-configured `email_primary_color`
// (Settings → Branding → Email palette). Inline `background-color`
// would override it and lock the button to the legacy green
// regardless of branding — that's the bug shipped on the very
// first cut of this template.
const en = buildTranslationRow(
'en',
'You\'ve been invited to access your photo galleries',
`
<h2>Welcome to your photo galleries</h2>
<p>You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Set up your account</a>
</div>
<p>This invitation expires on {{expires_at}}. If the link doesn't work, copy and paste it into your browser:</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>If you weren't expecting this email, you can safely ignore it.</p>`,
`Welcome to your photo galleries
You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.
Set up your account: {{invite_link}}
This invitation expires on {{expires_at}}.
If you weren't expecting this email, you can safely ignore it.`
);
const de = buildTranslationRow(
'de',
'Sie wurden eingeladen, auf Ihre Fotogalerien zuzugreifen',
`
<h2>Willkommen bei Ihren Fotogalerien</h2>
<p>Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Konto einrichten</a>
</div>
<p>Diese Einladung läuft am {{expires_at}} ab. Falls der Link nicht funktioniert, kopieren Sie ihn in Ihren Browser:</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.</p>`,
`Willkommen bei Ihren Fotogalerien
Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.
Konto einrichten: {{invite_link}}
Diese Einladung läuft am {{expires_at}} ab.
Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.`
);
for (const row of [en, de]) {
const exists = await knex('email_template_translations')
.where({ template_id: templateId, language: row.language })
.first();
if (!exists) {
await knex('email_template_translations').insert(row);
}
}
}
}
};
exports.down = async function(knex) {
// Drop in reverse dependency order. Permissions / role grants are left
// alone — they're idempotent on re-run and cleaning them on rollback
// would require deleting role_permissions rows we may not own.
await knex.schema.dropTableIfExists('event_customer_assignments');
await knex.schema.dropTableIfExists('customer_invitations');
await knex.schema.dropTableIfExists('customer_accounts');
// Best-effort cleanup of the three customers.* permissions if no other
// code path inserted them. role_permissions rows cascade via FK.
await knex('permissions').whereIn('name', [
'customers.view',
'customers.create',
'customers.delete',
]).del();
};
@@ -0,0 +1,49 @@
/**
* Migration: Allow admins to pre-fill customer profile fields on invite (#354 follow-up).
*
* Adds a single `prefill_data` JSON column to `customer_invitations`. When an
* admin invites a customer they can optionally pass first/last name, company,
* phone and a billing address — that data is stashed here and copied onto the
* new customer_accounts row by acceptInvitation(). The customer can then
* confirm or edit those values on the accept-invite form before submitting.
*
* JSON instead of one column per field because:
* - the prefill set may grow (vat id, salutation, etc.) and we don't want a
* migration for every UI tweak;
* - the data is only ever read+copied wholesale at accept time, never
* filtered/queried.
*
* Migration is idempotent — bails out if the column already exists.
*/
exports.up = async function(knex) {
const hasTable = await knex.schema.hasTable('customer_invitations');
if (!hasTable) {
// Migration 087 not yet run — nothing to alter. Should never happen in
// practice (knex runs migrations in order), but be defensive.
return;
}
const hasColumn = await knex.schema.hasColumn('customer_invitations', 'prefill_data');
if (hasColumn) {
return;
}
await knex.schema.alterTable('customer_invitations', (table) => {
// Knex's `json` type maps to JSONB on Postgres and TEXT on SQLite, which
// matches how we already store other free-form payloads in this codebase
// (see app_settings.theme_config). Nullable: invitations sent the
// old way (or via the API without a body) should still work.
table.json('prefill_data');
});
};
exports.down = async function(knex) {
const hasTable = await knex.schema.hasTable('customer_invitations');
if (!hasTable) return;
const hasColumn = await knex.schema.hasColumn('customer_invitations', 'prefill_data');
if (!hasColumn) return;
await knex.schema.alterTable('customer_invitations', (table) => {
table.dropColumn('prefill_data');
});
};
@@ -0,0 +1,190 @@
/**
* Migration: Customer-surface feature flags, branding toggles, and password resets (#354 follow-up).
*
* Three things in one migration so a single rollback returns the install
* to the prior state:
*
* 1. Per-customer feature flags on customer_accounts:
* feature_calendar / feature_quotes / feature_bills (BOOLEAN, default
* false). Default false because the matching pages are still
* coming-soon stubs — admin opts a customer in once the feature is
* actually useful for them. Combined with the global toggles below
* via AND-logic in the customer session response.
*
* 2. Global customer-surface toggles seeded into app_settings under
* setting_type='customer_surface':
* customer_feature_calendar_enabled (default false)
* customer_feature_quotes_enabled (default false)
* customer_feature_bills_enabled (default false)
* customer_show_logo (default true — preserves
* current visual behaviour)
* customer_show_company_name (default true — preserves
* current visual behaviour)
*
* 3. customer_password_resets table — admin-triggered password reset
* flow. Distinct from customer_invitations (which is the "create
* account" flow): a reset always points at an existing customer_id
* and updates the existing password_hash on accept.
*
* Plus the customer_password_reset email template, idempotently seeded.
*
* All steps are idempotent — a partial rollout can be resumed by re-running
* `knex migrate:latest`.
*/
exports.up = async function(knex) {
// ---- per-customer feature flags ---------------------------------------
if (await knex.schema.hasTable('customer_accounts')) {
const cols = ['feature_calendar', 'feature_quotes', 'feature_bills'];
for (const col of cols) {
const exists = await knex.schema.hasColumn('customer_accounts', col);
if (!exists) {
// Add as a separate alterTable per column so a half-applied
// migration (column A added, B failing) leaves the table in a
// consistent state on retry.
await knex.schema.alterTable('customer_accounts', (table) => {
table.boolean(col).notNullable().defaultTo(false);
});
}
}
}
// ---- global customer-surface settings ---------------------------------
if (await knex.schema.hasTable('app_settings')) {
const seeds = [
// Features: default false. Admin must explicitly enable on the
// settings page before the corresponding sidebar entry can show
// for any customer.
{ setting_key: 'customer_feature_calendar_enabled', setting_value: false, setting_type: 'customer_surface' },
{ setting_key: 'customer_feature_quotes_enabled', setting_value: false, setting_type: 'customer_surface' },
{ setting_key: 'customer_feature_bills_enabled', setting_value: false, setting_type: 'customer_surface' },
// Branding: default true so existing installs keep their current
// logo + company name in the customer header until the admin
// opts to hide them.
{ setting_key: 'customer_show_logo', setting_value: true, setting_type: 'customer_surface' },
{ setting_key: 'customer_show_company_name', setting_value: true, setting_type: 'customer_surface' },
];
for (const row of seeds) {
const existing = await knex('app_settings').where('setting_key', row.setting_key).first();
if (!existing) {
// Postgres JSONB column accepts both a JSON literal and a
// JSON-stringified value depending on driver version. Stringify
// for SQLite compatibility; Postgres accepts the same shape.
await knex('app_settings').insert({
setting_key: row.setting_key,
setting_value: JSON.stringify(row.setting_value),
setting_type: row.setting_type,
});
}
}
}
// ---- customer_password_resets table -----------------------------------
if (!(await knex.schema.hasTable('customer_password_resets'))) {
await knex.schema.createTable('customer_password_resets', (table) => {
table.increments('id').primary();
// 64-char hex token, same shape as invitations and admin invites.
table.string('token', 64).unique().notNullable();
// Always points at an existing customer_account; if the account is
// deleted, the reset disappears too.
table.integer('customer_account_id').notNullable()
.references('id').inTable('customer_accounts').onDelete('CASCADE');
table.integer('requested_by_admin_id')
.references('id').inTable('admin_users').onDelete('SET NULL');
table.timestamp('expires_at').notNullable();
table.timestamp('used_at');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index('token');
table.index('customer_account_id');
});
}
// ---- customer_password_reset email template ---------------------------
if (await knex.schema.hasTable('email_templates')) {
const existing = await knex('email_templates').where('template_key', 'customer_password_reset').first();
if (!existing) {
// Two email_templates schema variants exist in the wild:
//
// (a) legacy single-locale: subject / body_html / body_text columns
// (b) multi-locale: subject_en / subject_de / body_html_en /
// body_text_en / ... — all NOT NULL on at least one install
// (the maintainer's prod), where the previous version of this
// migration silently produced a 23502 NOT NULL violation and
// crash-looped the backend.
//
// Detect whichever variant is present and populate every matching
// column. For non-en locale columns we fall back to the English
// content so the install isn't left with NULL-violation rows;
// proper translations can be filled in later via the admin UI.
const cols = await knex('email_templates').columnInfo();
const SUBJECT = 'Reset your password';
const BODY_HTML = `<p>Hello,</p>
<p>Your photographer has triggered a password reset for your customer account.</p>
<p><a href="{{reset_link}}">Click here to set a new password</a>. This link expires on {{expires_at}}.</p>
<p>If you didn't expect this, you can ignore the message — your current password will keep working until you click the link.</p>`;
const BODY_TEXT = `Your photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`;
const row = {};
if ('template_key' in cols) row.template_key = 'customer_password_reset';
if ('language' in cols) row.language = 'en';
if ('is_active' in cols) row.is_active = true;
if ('created_at' in cols) row.created_at = new Date();
if ('updated_at' in cols) row.updated_at = new Date();
// Populate every subject/body column that exists, regardless of
// locale suffix. Fallback content == English; safe because email
// templates are user-editable post-install.
for (const colName of Object.keys(cols)) {
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
row[colName] = SUBJECT;
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
row[colName] = BODY_HTML;
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
row[colName] = BODY_TEXT;
}
}
await knex('email_templates').insert(row);
}
}
};
exports.down = async function(knex) {
// ---- table -----------------------------------------------------------
if (await knex.schema.hasTable('customer_password_resets')) {
await knex.schema.dropTable('customer_password_resets');
}
// ---- per-customer flags ---------------------------------------------
if (await knex.schema.hasTable('customer_accounts')) {
const cols = ['feature_calendar', 'feature_quotes', 'feature_bills'];
for (const col of cols) {
if (await knex.schema.hasColumn('customer_accounts', col)) {
await knex.schema.alterTable('customer_accounts', (table) => {
table.dropColumn(col);
});
}
}
}
// ---- settings -------------------------------------------------------
if (await knex.schema.hasTable('app_settings')) {
await knex('app_settings').whereIn('setting_key', [
'customer_feature_calendar_enabled',
'customer_feature_quotes_enabled',
'customer_feature_bills_enabled',
'customer_show_logo',
'customer_show_company_name',
]).del();
}
// ---- template --------------------------------------------------------
if (await knex.schema.hasTable('email_templates')) {
await knex('email_templates').where('template_key', 'customer_password_reset').del();
}
};
@@ -0,0 +1,63 @@
/**
* Migration: Flip the per-customer feature flag semantic to "opt-out".
*
* Original semantic (089): both global toggle AND per-customer flag had
* to be ON for a customer to see Calendar/Quotes/Bills. Defaults: false.
* Result: enabling a feature globally was a no-op until the admin clicked
* through every customer detail page and toggled them on individually.
*
* New semantic (this migration): global toggle is the master; per-customer
* flag defaults to TRUE and only acts as an override-to-hide. So:
*
* - Global ON, per-customer default (true) → visible
* - Global ON, per-customer set to false → hidden for this customer
* - Global OFF, per-customer anything → hidden (master wins)
*
* Migration steps:
* 1. Update column defaults to true so new customer_accounts rows
* auto-opt-in.
* 2. Flip every existing row's feature_* columns from false → true.
* Rows that were never touched (i.e. ALL of them at this stage in
* dev) end up with the new default. If a maintainer had already
* hand-toggled a customer to false to hide a feature, that's
* indistinguishable from the seeded default at this layer — so
* this migration deliberately overwrites. Acceptable because the
* original semantic only shipped for one image and nobody is
* relying on hand-set false values yet.
*
* Idempotent: re-running is a no-op (the UPDATE just confirms current
* values).
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('customer_accounts'))) return;
// Step 1 — change defaults. Knex's .alter() rewrites the column;
// we keep notNullable to match 089.
await knex.schema.alterTable('customer_accounts', (table) => {
table.boolean('feature_calendar').notNullable().defaultTo(true).alter();
table.boolean('feature_quotes').notNullable().defaultTo(true).alter();
table.boolean('feature_bills').notNullable().defaultTo(true).alter();
});
// Step 2 — flip existing rows so they pick up the new default. Without
// this, customers created on the 089-shipped image stay invisible even
// after the admin enables the feature globally.
await knex('customer_accounts').update({
feature_calendar: true,
feature_quotes: true,
feature_bills: true,
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('customer_accounts'))) return;
// Restore the 089 default of false. Don't bulk-update existing rows
// back to false: that would silently hide features for customers the
// admin had explicitly enabled post-090.
await knex.schema.alterTable('customer_accounts', (table) => {
table.boolean('feature_calendar').notNullable().defaultTo(false).alter();
table.boolean('feature_quotes').notNullable().defaultTo(false).alter();
table.boolean('feature_bills').notNullable().defaultTo(false).alter();
});
};
@@ -0,0 +1,90 @@
/**
* Migration: Re-theme the customer_invitation email's CTA button.
*
* The original 087 seed inlined `background-color: #5C8762` on the
* "Set up your account" anchor, which locked the button to the legacy
* green regardless of the admin's `email_primary_color` setting
* (Settings → Branding → Email palette). The wrapper template
* (emailProcessor.wrapEmailHtml) already exposes a `.button` class
* that inherits the configured palette — switching the anchor over
* is a one-line change, but existing installs already have the bad
* HTML in their email_template_translations rows. This migration
* rewrites those rows so the next outbound invitation picks up the
* brand colour.
*
* Idempotent: only updates rows whose stored body still contains the
* old hardcoded anchor markup. If the admin has hand-edited the
* template (typical for non-English locales they translated
* themselves) the row is left alone.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('email_templates'))) return;
if (!(await knex.schema.hasTable('email_template_translations'))) return;
const master = await knex('email_templates')
.where('template_key', 'customer_invitation')
.first();
if (!master) return; // 087 hasn't run on this install — nothing to fix.
// English translation
const enRow = await knex('email_template_translations')
.where({ template_id: master.id, language: 'en' })
.first();
if (enRow && typeof enRow.body_html === 'string'
&& enRow.body_html.includes('background-color: #5C8762')
&& enRow.body_html.includes('Set up your account')) {
const newHtml = `
<h2>Welcome to your photo galleries</h2>
<p>You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Set up your account</a>
</div>
<p>This invitation expires on {{expires_at}}. If the link doesn't work, copy and paste it into your browser:</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>If you weren't expecting this email, you can safely ignore it.</p>`;
await knex('email_template_translations')
.where({ id: enRow.id })
.update({ body_html: newHtml, updated_at: new Date() });
}
// German translation
const deRow = await knex('email_template_translations')
.where({ template_id: master.id, language: 'de' })
.first();
if (deRow && typeof deRow.body_html === 'string'
&& deRow.body_html.includes('background-color: #5C8762')
&& deRow.body_html.includes('Konto einrichten')) {
const newHtml = `
<h2>Willkommen bei Ihren Fotogalerien</h2>
<p>Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Konto einrichten</a>
</div>
<p>Diese Einladung läuft am {{expires_at}} ab. Falls der Link nicht funktioniert, kopieren Sie ihn in Ihren Browser:</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.</p>`;
await knex('email_template_translations')
.where({ id: deRow.id })
.update({ body_html: newHtml, updated_at: new Date() });
}
// Legacy single-language column on the master row, if present.
// Older installs may also have a hardcoded body_html on email_templates
// itself (pre-translations table). Same idempotent rewrite logic.
if (typeof master.body_html === 'string'
&& master.body_html.includes('background-color: #5C8762')) {
await knex('email_templates')
.where({ id: master.id })
.update({
body_html: '<p>You\'ve been invited to create a customer account. <a href="{{invite_link}}" class="button">Set up your account</a> (expires {{expires_at}}).</p>',
updated_at: new Date(),
});
}
};
exports.down = async function(/* knex */) {
// No-op: rolling back the visual fix would intentionally restore the
// bug. Admins who want the old green button can edit the template
// from Settings → Email Templates.
};
@@ -0,0 +1,47 @@
/**
* Migration 095: Add `customerPortal` to feature_flags.
*
* The customer portal (#354) is the foundation feature for the
* customer-side UI surface — login, dashboard, profile, password reset,
* and the admin Customers management page. Subordinate flags
* (calendar, calendarBooking, quotes, bills, messaging) are already
* present in the table from migration 088 and gate the customer-side
* tabs that hang off the dashboard.
*
* Default seeding rule mirrors 088:
* - Existing install (events table has rows) → customerPortal = TRUE.
* The PR ships with the customer-portal foundation already wired,
* so an admin who upgrades shouldn't see admin sidebar entries
* vanish until they explicitly opt out from Settings → Features.
* - Fresh install (no events) → customerPortal = FALSE. Picpeak still
* ships as a focused gallery delivery tool by default; admins flip
* this on when they want recurring-customer logins.
*
* Idempotent: skips the insert when the row already exists. Re-running
* is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('feature_flags'))) return;
const existing = await knex('feature_flags').where({ key: 'customerPortal' }).first();
if (existing) return;
// Same existing-vs-fresh detection 088 uses — count events. The flag
// table is shared with 088's seeded keys; re-running detection keeps
// each new feature flag in lockstep with the install state instead
// of guessing per migration.
const eventCountRow = await knex('events').count({ count: '*' }).first();
const eventCount = parseInt(eventCountRow?.count || 0, 10);
const isExistingInstall = eventCount > 0;
await knex('feature_flags').insert({
key: 'customerPortal',
value: isExistingInstall,
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('feature_flags'))) return;
await knex('feature_flags').where({ key: 'customerPortal' }).del();
};
@@ -0,0 +1,102 @@
/**
* Migration: Backfill photo dimensions (v2)
*
* Re-runs the dimension backfill from migration 064 for any rows that are
* still NULL. Migration 064 only ran once at upgrade time; new photos
* imported via fileWatcher.js or s3AutoImporter.js between then and now
* had their width/height columns left NULL because those code paths did
* not capture metadata on insert. This PR fixes both writers, but
* pre-existing rows still need a backfill — that is what this does.
*
* Without dimensions, MasonryGalleryLayout falls back to a hard-coded
* 800×600 default, which is why every card in masonry mode looks like
* the same 4:3 box (#447).
*
* Local-fs only — S3 deployments cannot read source objects in a
* migration without instantiating the storage backend. Those
* deployments rely on the writer fix in s3AutoImporter.js for new
* photos and can run a one-shot script if a backfill is needed.
*/
const path = require('path');
const fs = require('fs');
exports.up = async function(knex) {
const hasWidth = await knex.schema.hasColumn('photos', 'width');
const hasHeight = await knex.schema.hasColumn('photos', 'height');
if (!hasWidth || !hasHeight) {
console.log('[Migration 090] width/height columns not present, skipping');
return;
}
const backend = (process.env.STORAGE_BACKEND || 'local').toLowerCase();
if (backend !== 'local') {
console.log(`[Migration 090] STORAGE_BACKEND=${backend} — backfill skipped (S3 deployments not supported in-migration)`);
return;
}
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const photos = await knex('photos')
.where(function () {
this.whereNull('width').orWhereNull('height');
})
.andWhere(function () {
// Skip videos — sharp can't handle them; they need ffprobe.
this.where('media_type', '!=', 'video').orWhereNull('media_type');
})
.select('id', 'path', 'filename');
if (photos.length === 0) {
console.log('[Migration 090] no photos missing dimensions');
return;
}
console.log(`[Migration 090] backfilling ${photos.length} photos`);
let sharp;
try {
sharp = require('sharp');
} catch (err) {
console.error('[Migration 090] sharp unavailable, skipping:', err.message);
return;
}
let updated = 0;
let failed = 0;
for (const photo of photos) {
try {
if (!photo.path) {
failed++;
continue;
}
const fullPath = path.join(storagePath, 'events/active', photo.path);
if (!fs.existsSync(fullPath)) {
failed++;
continue;
}
const metadata = await sharp(fullPath).metadata();
if (metadata.width && metadata.height) {
await knex('photos').where('id', photo.id).update({
width: metadata.width,
height: metadata.height,
});
updated++;
if (updated % 100 === 0) {
console.log(`[Migration 090] ${updated}/${photos.length}`);
}
} else {
failed++;
}
} catch (err) {
console.error(`[Migration 090] photo ${photo.id}: ${err.message}`);
failed++;
}
}
console.log(`[Migration 090] done — ${updated} updated, ${failed} skipped`);
};
exports.down = async function() {
// Data-only migration; no rollback action.
};
@@ -0,0 +1,38 @@
/**
* Migration: Add the `clients` top-level feature flag.
*
* Introduces a parent flag for the "Clients" sidebar section, which
* groups customer accounts today and will host calendar / quotes /
* bills / messaging in future PRs. The existing `customerPortal` flag
* is unchanged and continues to gate the /customer/* surface plus the
* Accounts sub-page; it now lives logically beneath `clients` in the
* Features tab.
*
* Initial value: mirrors the install's current `customerPortal` value
* so an admin who had the customer portal enabled keeps seeing the
* Clients sidebar entry after upgrade, and an admin who had it off
* doesn't suddenly see a new sidebar entry.
*
* Idempotent: re-runs are no-ops.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('feature_flags'))) return;
const existing = await knex('feature_flags').where({ key: 'clients' }).first();
if (existing) return;
const portalRow = await knex('feature_flags').where({ key: 'customerPortal' }).first();
let initialValue = false;
if (portalRow) {
const raw = portalRow.value;
initialValue = raw === true || raw === 1 || raw === '1' || raw === 'true';
}
await knex('feature_flags').insert({ key: 'clients', value: initialValue });
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('feature_flags'))) return;
await knex('feature_flags').where({ key: 'clients' }).del();
};
@@ -0,0 +1,128 @@
/**
* Migration: Categorise email templates + link them to feature flags.
*
* Adds three metadata columns to `email_templates`:
*
* - `category` — top-level display group in the admin Templates UI.
* One of:
* 'core' — gallery delivery, admin, system, backups.
* Always visible, no feature flag.
* 'customers' — customer-portal lifecycle (invitation, reset).
* 'billing' — Bills feature (#354, not yet built).
* 'quotes' — Quotes feature (#354, not yet built).
* 'calendar' — Calendar feature (#354, not yet built).
* Values outside this set are accepted (forward-compat) but the
* UI will lump them under 'core' for now.
*
* - `subcategory` — second-level group inside `core` (which is busy
* with 14 templates). One of:
* 'gallery' — gallery delivery lifecycle (created / expiring /
* expired / archived).
* 'admin' — admin lifecycle (invitation, password reset).
* 'backup' — DB + file backups (completed / failed) and
* restores.
* 'system' — version update notifications.
* Only meaningful when category='core'; other categories ignore
* it. NULL on rows that don't need a sub-bucket.
*
* - `feature_flag` — name of the feature flag whose `false` value
* should mark this template as "Feature off" in the admin UI.
* NULL means the template is always active (gallery delivery,
* admin lifecycle, system notifications).
*
* Categorisation does NOT hide templates. Disabled-feature templates
* stay visible and editable so admins can prep them before a feature
* launch; the UI shows a small "Feature off" chip on the entry.
*
* Idempotent: re-runs are no-ops.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('email_templates'))) return;
const hasCategory = await knex.schema.hasColumn('email_templates', 'category');
if (!hasCategory) {
await knex.schema.alterTable('email_templates', (table) => {
// Default 'core' so existing rows aren't NULL; the backfill below
// overrides for templates that belong to a feature group.
table.string('category', 32).notNullable().defaultTo('core');
});
}
const hasSubcategory = await knex.schema.hasColumn('email_templates', 'subcategory');
if (!hasSubcategory) {
await knex.schema.alterTable('email_templates', (table) => {
table.string('subcategory', 32).nullable();
});
}
const hasFeatureFlag = await knex.schema.hasColumn('email_templates', 'feature_flag');
if (!hasFeatureFlag) {
await knex.schema.alterTable('email_templates', (table) => {
table.string('feature_flag', 64).nullable();
});
}
// Backfill — keyed by template_key so we don't accidentally update
// a row that's been renamed. Templates not in this map keep the
// 'core' / NULL defaults from the column definitions above.
const TEMPLATE_METADATA = {
// Core / Galleries — gallery delivery lifecycle.
gallery_created: { category: 'core', subcategory: 'gallery', feature_flag: null },
expiration_warning: { category: 'core', subcategory: 'gallery', feature_flag: null },
gallery_expired: { category: 'core', subcategory: 'gallery', feature_flag: null },
archive_complete: { category: 'core', subcategory: 'gallery', feature_flag: null },
// Core / Admin — admin account lifecycle.
admin_invitation: { category: 'core', subcategory: 'admin', feature_flag: null },
admin_password_reset: { category: 'core', subcategory: 'admin', feature_flag: null },
// Core / Backup — database + file backups + restores.
database_backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
database_backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
restore_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
restore_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
// Core / System — version-update notifications.
version_update_available: { category: 'core', subcategory: 'system', feature_flag: null },
version_update_test: { category: 'core', subcategory: 'system', feature_flag: null },
// Customer portal (#354). Admin-triggered password reset for
// customer accounts ships in the same feature, so both templates
// share the `customers` category and the `customerPortal` flag.
// Future calendar / quotes / bills templates will land here under
// their own categories.
customer_invitation: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' },
customer_password_reset: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' },
};
for (const [key, meta] of Object.entries(TEMPLATE_METADATA)) {
await knex('email_templates')
.where({ template_key: key })
.update({
category: meta.category,
subcategory: meta.subcategory,
feature_flag: meta.feature_flag,
});
}
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('email_templates'))) return;
if (await knex.schema.hasColumn('email_templates', 'feature_flag')) {
await knex.schema.alterTable('email_templates', (table) => {
table.dropColumn('feature_flag');
});
}
if (await knex.schema.hasColumn('email_templates', 'subcategory')) {
await knex.schema.alterTable('email_templates', (table) => {
table.dropColumn('subcategory');
});
}
if (await knex.schema.hasColumn('email_templates', 'category')) {
await knex.schema.alterTable('email_templates', (table) => {
table.dropColumn('category');
});
}
};
@@ -0,0 +1,797 @@
/**
* Migration: Auto-fill missing email-template translations for nl / pt /
* ru / fr — plus the en/de rows for templates that were seeded AFTER
* migration 075 ran (customer_password_reset from 092, version_update_test
* from 087). Those two carry their EN/DE content in the legacy
* subject_en/body_html_en/... columns; without a row in
* email_template_translations, the admin Templates UI shows them as
* empty until an admin clicks save.
*
* Coverage going in:
* - gallery_created / expiration_warning / gallery_expired /
* archive_complete already had en/de/nl/pt/ru from migration 075
* → this migration adds the missing `fr` row.
* - admin_*, backup_*, restore_*, database_backup_*, customer_invitation,
* version_update_available had en/de only → this migration adds
* nl/pt/ru/fr.
* - customer_password_reset + version_update_test had legacy-column
* EN/DE only (post-075 inserts) → this migration adds the full
* en/de/nl/pt/ru/fr set, sourcing en/de from the legacy columns
* when present and falling back to the curated copy below.
*
* The non-EN/DE translations below were generated by an LLM and are
* flagged in the PR description as needing native-speaker review
* before the next stable release. en / de remain hand-translated.
*
* Idempotent: every insert checks (template_id, language) for an
* existing row first and skips if present. Safe to re-run.
*
* Variable placeholders ({{name}}) are preserved verbatim across all
* locales so emailProcessor's safeTemplateReplace continues to wire
* them up unchanged.
*/
const TRANSLATIONS = {
// ────────────────────────────────────────────────────────────────
// Gallery delivery (core) — only fr is missing, the rest landed in
// migration 075.
// ────────────────────────────────────────────────────────────────
gallery_created: {
fr: {
subject: 'Votre galerie photo est prête !',
body_html: `<h2>Galerie créée avec succès</h2>
<p>Bonjour {{host_name}},</p>
<p>Votre galerie photo « {{event_name}} » a été créée avec succès !</p>
<p><strong>Détails de la galerie :</strong></p>
<ul>
<li>Date de l'événement : {{event_date}}</li>
<li>Lien de la galerie : <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Mot de passe : {{gallery_password}}</li>
<li>Expire le : {{expiry_date}}</li>
</ul>
<p>Partagez ce lien et le mot de passe avec vos invités pour qu'ils puissent voir et télécharger les photos.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Galerie créée avec succès\n\nBonjour {{host_name}},\n\nVotre galerie photo « {{event_name}} » a été créée avec succès !\n\nLien de la galerie : {{gallery_link}}\nMot de passe : {{gallery_password}}\nExpire le : {{expiry_date}}`,
},
},
expiration_warning: {
fr: {
subject: 'Votre galerie photo expire bientôt',
body_html: `<h2>La galerie expire bientôt</h2>
<p>Bonjour {{host_name}},</p>
<p>Votre galerie photo « {{event_name}} » expire dans {{days_remaining}} jours.</p>
<p>Après l'expiration, la galerie sera archivée et ne sera plus accessible aux invités.</p>
<p><a href="{{gallery_link}}">Voir la galerie</a></p>`,
body_text: `La galerie expire bientôt\n\nBonjour {{host_name}},\n\nVotre galerie photo « {{event_name}} » expire dans {{days_remaining}} jours.\n\nGalerie : {{gallery_link}}`,
},
},
gallery_expired: {
fr: {
subject: 'Galerie photo expirée et archivée',
body_html: `<h2>Galerie archivée</h2>
<p>Bonjour {{host_name}},</p>
<p>Votre galerie photo « {{event_name}} » a expiré et a été archivée.</p>
<p>Les invités ne peuvent plus accéder à la galerie. Contactez votre photographe si vous avez besoin de restaurer l'accès.</p>`,
body_text: `Galerie archivée\n\nBonjour {{host_name}},\n\nVotre galerie photo « {{event_name}} » a expiré et a été archivée.`,
},
},
archive_complete: {
fr: {
subject: 'Galerie archivée : {{event_name}}',
body_html: `<h2>Galerie archivée avec succès</h2>
<p>La galerie photo « {{event_name}} » a été archivée.</p>
<p><strong>Détails de l'archive :</strong></p>
<ul>
<li>Taille de l'archive : {{archive_size}}</li>
<li>Nombre de photos : {{photo_count}}</li>
<li>Emplacement : {{archive_path}}</li>
</ul>`,
body_text: `Galerie archivée avec succès\n\nLa galerie photo « {{event_name}} » a été archivée.\n\nTaille : {{archive_size}}\nPhotos : {{photo_count}}\nEmplacement : {{archive_path}}`,
},
},
// ────────────────────────────────────────────────────────────────
// Admin / RBAC — invitation + password reset. en/de exist, adding
// nl/pt/ru/fr.
// ────────────────────────────────────────────────────────────────
admin_invitation: {
nl: {
subject: 'U bent uitgenodigd om deel te nemen aan PicPeak als {{role_name}}',
body_html: `<h2>Welkom bij PicPeak</h2>
<p>U bent uitgenodigd door {{inviter_name}} om deel te nemen aan PicPeak als {{role_name}}.</p>
<p>Klik op de onderstaande link om uw account in te stellen:</p>
<p><a href="{{invitation_link}}" class="button">Account instellen</a></p>
<p>Deze uitnodiging verloopt op {{expires_at}}.</p>
<p>Als u deze e-mail niet verwachtte, kunt u deze gerust negeren.</p>`,
body_text: `Welkom bij PicPeak\n\nU bent uitgenodigd door {{inviter_name}} om deel te nemen aan PicPeak als {{role_name}}.\n\nStel uw account in: {{invitation_link}}\n\nDeze uitnodiging verloopt op {{expires_at}}.`,
},
pt: {
subject: 'Você foi convidado para o PicPeak como {{role_name}}',
body_html: `<h2>Bem-vindo(a) ao PicPeak</h2>
<p>Você foi convidado(a) por {{inviter_name}} para participar do PicPeak como {{role_name}}.</p>
<p>Clique no link abaixo para configurar sua conta:</p>
<p><a href="{{invitation_link}}" class="button">Configurar conta</a></p>
<p>Este convite expira em {{expires_at}}.</p>
<p>Se você não esperava este e-mail, pode ignorá-lo com segurança.</p>`,
body_text: `Bem-vindo(a) ao PicPeak\n\nVocê foi convidado(a) por {{inviter_name}} para participar do PicPeak como {{role_name}}.\n\nConfigure sua conta: {{invitation_link}}\n\nEste convite expira em {{expires_at}}.`,
},
ru: {
subject: 'Вас пригласили присоединиться к PicPeak в роли {{role_name}}',
body_html: `<h2>Добро пожаловать в PicPeak</h2>
<p>{{inviter_name}} пригласил(а) вас присоединиться к PicPeak в роли {{role_name}}.</p>
<p>Перейдите по ссылке ниже, чтобы настроить учётную запись:</p>
<p><a href="{{invitation_link}}" class="button">Настроить учётную запись</a></p>
<p>Срок действия приглашения истекает {{expires_at}}.</p>
<p>Если вы не ожидали этого письма, можете его проигнорировать.</p>`,
body_text: `Добро пожаловать в PicPeak\n\n{{inviter_name}} пригласил(а) вас присоединиться к PicPeak в роли {{role_name}}.\n\nНастроить учётную запись: {{invitation_link}}\n\nСрок действия приглашения истекает {{expires_at}}.`,
},
fr: {
subject: 'Vous avez été invité(e) à rejoindre PicPeak en tant que {{role_name}}',
body_html: `<h2>Bienvenue sur PicPeak</h2>
<p>{{inviter_name}} vous a invité(e) à rejoindre PicPeak en tant que {{role_name}}.</p>
<p>Cliquez sur le lien ci-dessous pour configurer votre compte :</p>
<p><a href="{{invitation_link}}" class="button">Configurer le compte</a></p>
<p>Cette invitation expire le {{expires_at}}.</p>
<p>Si vous n'attendiez pas cet e-mail, vous pouvez l'ignorer en toute sécurité.</p>`,
body_text: `Bienvenue sur PicPeak\n\n{{inviter_name}} vous a invité(e) à rejoindre PicPeak en tant que {{role_name}}.\n\nConfigurer le compte : {{invitation_link}}\n\nCette invitation expire le {{expires_at}}.`,
},
},
admin_password_reset: {
nl: {
subject: 'Uw PicPeak-administratorwachtwoord is opnieuw ingesteld',
body_html: `<h2>Wachtwoord opnieuw ingesteld</h2>
<p>Hallo {{admin_name}},</p>
<p>Uw PicPeak-administratorwachtwoord is opnieuw ingesteld door {{reset_by}}.</p>
<p>Klik op de onderstaande link om een nieuw wachtwoord in te stellen:</p>
<p><a href="{{reset_link}}" class="button">Nieuw wachtwoord instellen</a></p>
<p>Deze link verloopt op {{expires_at}}. Heeft u deze actie niet aangevraagd? Neem dan onmiddellijk contact op met uw teambeheerder.</p>`,
body_text: `Wachtwoord opnieuw ingesteld\n\nHallo {{admin_name}},\n\nUw PicPeak-administratorwachtwoord is opnieuw ingesteld door {{reset_by}}.\n\nStel een nieuw wachtwoord in: {{reset_link}}\n\nDeze link verloopt op {{expires_at}}.`,
},
pt: {
subject: 'Sua senha de administrador do PicPeak foi redefinida',
body_html: `<h2>Senha redefinida</h2>
<p>Olá {{admin_name}},</p>
<p>Sua senha de administrador do PicPeak foi redefinida por {{reset_by}}.</p>
<p>Clique no link abaixo para definir uma nova senha:</p>
<p><a href="{{reset_link}}" class="button">Definir nova senha</a></p>
<p>Este link expira em {{expires_at}}. Se você não solicitou esta ação, entre em contato com o administrador da sua equipe imediatamente.</p>`,
body_text: `Senha redefinida\n\nOlá {{admin_name}},\n\nSua senha de administrador do PicPeak foi redefinida por {{reset_by}}.\n\nDefinir nova senha: {{reset_link}}\n\nEste link expira em {{expires_at}}.`,
},
ru: {
subject: 'Ваш пароль администратора PicPeak был сброшен',
body_html: `<h2>Пароль сброшен</h2>
<p>Здравствуйте, {{admin_name}}!</p>
<p>Ваш пароль администратора PicPeak был сброшен пользователем {{reset_by}}.</p>
<p>Перейдите по ссылке ниже, чтобы задать новый пароль:</p>
<p><a href="{{reset_link}}" class="button">Задать новый пароль</a></p>
<p>Срок действия ссылки истекает {{expires_at}}. Если вы не запрашивали это действие, немедленно свяжитесь с администратором вашей команды.</p>`,
body_text: `Пароль сброшен\n\nЗдравствуйте, {{admin_name}}!\n\nВаш пароль администратора PicPeak был сброшен пользователем {{reset_by}}.\n\nЗадать новый пароль: {{reset_link}}\n\nСрок действия ссылки истекает {{expires_at}}.`,
},
fr: {
subject: 'Votre mot de passe administrateur PicPeak a été réinitialisé',
body_html: `<h2>Mot de passe réinitialisé</h2>
<p>Bonjour {{admin_name}},</p>
<p>Votre mot de passe administrateur PicPeak a été réinitialisé par {{reset_by}}.</p>
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :</p>
<p><a href="{{reset_link}}" class="button">Définir un nouveau mot de passe</a></p>
<p>Ce lien expire le {{expires_at}}. Si vous n'êtes pas à l'origine de cette demande, contactez immédiatement votre administrateur.</p>`,
body_text: `Mot de passe réinitialisé\n\nBonjour {{admin_name}},\n\nVotre mot de passe administrateur PicPeak a été réinitialisé par {{reset_by}}.\n\nDéfinir un nouveau mot de passe : {{reset_link}}\n\nCe lien expire le {{expires_at}}.`,
},
},
// ────────────────────────────────────────────────────────────────
// Customer portal (#354). customer_invitation already has hand-tuned
// en/de from migration 090 (themed button via migration 094).
// ────────────────────────────────────────────────────────────────
customer_invitation: {
nl: {
subject: 'U bent uitgenodigd om uw fotogalerijen te bekijken',
body_html: `<h2>Welkom bij uw fotogalerijen</h2>
<p>U bent uitgenodigd om een klantaccount aan te maken, zodat u al uw evenementgalerijen op één plek kunt bekijken — geen aparte links en wachtwoorden meer.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Account instellen</a>
</div>
<p>Deze uitnodiging verloopt op {{expires_at}}. Werkt de link niet? Kopieer hem en plak hem in uw browser:</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>Heeft u deze e-mail niet verwacht? U kunt deze gerust negeren.</p>`,
body_text: `Welkom bij uw fotogalerijen\n\nU bent uitgenodigd om een klantaccount aan te maken zodat u al uw galerijen op één plek kunt bekijken.\n\nAccount instellen: {{invite_link}}\n\nDeze uitnodiging verloopt op {{expires_at}}.`,
},
pt: {
subject: 'Você foi convidado(a) a acessar suas galerias de fotos',
body_html: `<h2>Bem-vindo(a) às suas galerias</h2>
<p>Você foi convidado(a) a criar uma conta de cliente para visualizar todas as suas galerias de eventos em um só lugar — sem mais links e senhas separados.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Configurar conta</a>
</div>
<p>Este convite expira em {{expires_at}}. Se o link não funcionar, copie e cole-o no navegador:</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>Se você não esperava este e-mail, pode ignorá-lo com segurança.</p>`,
body_text: `Bem-vindo(a) às suas galerias\n\nVocê foi convidado(a) a criar uma conta de cliente para acessar todas as suas galerias em um só lugar.\n\nConfigurar conta: {{invite_link}}\n\nEste convite expira em {{expires_at}}.`,
},
ru: {
subject: 'Вас пригласили получить доступ к вашим фотогалереям',
body_html: `<h2>Добро пожаловать в ваши галереи</h2>
<p>Вас пригласили создать учётную запись клиента, чтобы видеть все ваши галереи событий в одном месте — больше никаких отдельных ссылок и паролей.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Настроить учётную запись</a>
</div>
<p>Срок действия приглашения истекает {{expires_at}}. Если ссылка не работает, скопируйте её в адресную строку браузера:</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>Если вы не ожидали этого письма, можете его проигнорировать.</p>`,
body_text: `Добро пожаловать в ваши галереи\n\nВас пригласили создать учётную запись клиента, чтобы видеть все ваши галереи в одном месте.\n\nНастроить учётную запись: {{invite_link}}\n\nСрок действия приглашения истекает {{expires_at}}.`,
},
fr: {
subject: 'Vous avez été invité(e) à accéder à vos galeries photo',
body_html: `<h2>Bienvenue dans vos galeries photo</h2>
<p>Vous avez été invité(e) à créer un compte client pour voir toutes vos galeries d'événements en un seul endroit — plus de liens et mots de passe séparés.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Configurer le compte</a>
</div>
<p>Cette invitation expire le {{expires_at}}. Si le lien ne fonctionne pas, copiez-le et collez-le dans votre navigateur :</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>Si vous n'attendiez pas cet e-mail, vous pouvez l'ignorer en toute sécurité.</p>`,
body_text: `Bienvenue dans vos galeries photo\n\nVous avez été invité(e) à créer un compte client pour accéder à toutes vos galeries en un seul endroit.\n\nConfigurer le compte : {{invite_link}}\n\nCette invitation expire le {{expires_at}}.`,
},
},
// Customer password reset (#354 follow-up). Seeded by migration 092
// in the legacy columns with English-only copy (the EN string was
// broadcast to every subject_*/body_html_* column to satisfy NOT
// NULL on multi-locale schemas). Replacing here with a proper
// per-locale set including hand-tuned EN/DE plus AI-generated
// nl/pt/ru/fr.
customer_password_reset: {
en: {
subject: 'Reset your customer account password',
body_html: `<p>Hello,</p>
<p>Your photographer has triggered a password reset for your customer account.</p>
<p><a href="{{reset_link}}" class="button">Set a new password</a></p>
<p>This link expires on {{expires_at}}.</p>
<p>If you didn't expect this, you can ignore the message — your current password keeps working until you click the link.</p>`,
body_text: `Reset your customer account password\n\nYour photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`,
},
de: {
subject: 'Passwort für dein Kundenkonto zurücksetzen',
body_html: `<p>Hallo,</p>
<p>Dein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.</p>
<p><a href="{{reset_link}}" class="button">Neues Passwort festlegen</a></p>
<p>Dieser Link läuft am {{expires_at}} ab.</p>
<p>Wenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.</p>`,
body_text: `Passwort für dein Kundenkonto zurücksetzen\n\nDein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.\n\nNeues Passwort festlegen: {{reset_link}}\n\nDieser Link läuft am {{expires_at}} ab.\n\nWenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.`,
},
nl: {
subject: 'Wachtwoord van uw klantaccount opnieuw instellen',
body_html: `<p>Hallo,</p>
<p>Uw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.</p>
<p><a href="{{reset_link}}" class="button">Nieuw wachtwoord instellen</a></p>
<p>Deze link verloopt op {{expires_at}}.</p>
<p>Heeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.</p>`,
body_text: `Wachtwoord opnieuw instellen\n\nUw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.\n\nNieuw wachtwoord instellen: {{reset_link}}\n\nDeze link verloopt op {{expires_at}}.\n\nHeeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.`,
},
pt: {
subject: 'Redefina a senha da sua conta de cliente',
body_html: `<p>Olá,</p>
<p>Seu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.</p>
<p><a href="{{reset_link}}" class="button">Definir nova senha</a></p>
<p>Este link expira em {{expires_at}}.</p>
<p>Se você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.</p>`,
body_text: `Redefinir senha\n\nSeu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.\n\nDefinir nova senha: {{reset_link}}\n\nEste link expira em {{expires_at}}.\n\nSe você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.`,
},
ru: {
subject: 'Сброс пароля вашей клиентской учётной записи',
body_html: `<p>Здравствуйте!</p>
<p>Ваш фотограф инициировал сброс пароля для вашей клиентской учётной записи.</p>
<p><a href="{{reset_link}}" class="button">Задать новый пароль</a></p>
<p>Срок действия ссылки истекает {{expires_at}}.</p>
<p>Если вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.</p>`,
body_text: `Сброс пароля\n\nВаш фотограф инициировал сброс пароля для вашей клиентской учётной записи.\n\nЗадать новый пароль: {{reset_link}}\n\nСрок действия ссылки истекает {{expires_at}}.\n\nЕсли вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.`,
},
fr: {
subject: 'Réinitialisez le mot de passe de votre compte client',
body_html: `<p>Bonjour,</p>
<p>Votre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.</p>
<p><a href="{{reset_link}}" class="button">Définir un nouveau mot de passe</a></p>
<p>Ce lien expire le {{expires_at}}.</p>
<p>Si vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.</p>`,
body_text: `Réinitialiser le mot de passe\n\nVotre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.\n\nDéfinir un nouveau mot de passe : {{reset_link}}\n\nCe lien expire le {{expires_at}}.\n\nSi vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.`,
},
},
// ────────────────────────────────────────────────────────────────
// Database backups
// ────────────────────────────────────────────────────────────────
database_backup_completed: {
nl: {
subject: '[PicPeak] Database-back-up succesvol',
body_html: `<h2>Database-back-up voltooid</h2>
<p>De geplande database-back-up is succesvol voltooid.</p>
<ul>
<li>Tijdstip: {{completed_at}}</li>
<li>Grootte: {{backup_size}}</li>
<li>Locatie: {{backup_path}}</li>
</ul>`,
body_text: `Database-back-up voltooid\n\nTijdstip: {{completed_at}}\nGrootte: {{backup_size}}\nLocatie: {{backup_path}}`,
},
pt: {
subject: '[PicPeak] Backup do banco de dados concluído',
body_html: `<h2>Backup do banco de dados concluído</h2>
<p>O backup agendado do banco de dados foi concluído com sucesso.</p>
<ul>
<li>Horário: {{completed_at}}</li>
<li>Tamanho: {{backup_size}}</li>
<li>Localização: {{backup_path}}</li>
</ul>`,
body_text: `Backup do banco de dados concluído\n\nHorário: {{completed_at}}\nTamanho: {{backup_size}}\nLocalização: {{backup_path}}`,
},
ru: {
subject: '[PicPeak] Резервная копия БД успешно создана',
body_html: `<h2>Резервная копия базы данных создана</h2>
<p>Запланированное резервное копирование базы данных успешно завершено.</p>
<ul>
<li>Время: {{completed_at}}</li>
<li>Размер: {{backup_size}}</li>
<li>Расположение: {{backup_path}}</li>
</ul>`,
body_text: `Резервная копия базы данных создана\n\nВремя: {{completed_at}}\nРазмер: {{backup_size}}\nРасположение: {{backup_path}}`,
},
fr: {
subject: '[PicPeak] Sauvegarde de la base de données réussie',
body_html: `<h2>Sauvegarde de la base de données terminée</h2>
<p>La sauvegarde planifiée de la base de données s'est terminée avec succès.</p>
<ul>
<li>Heure : {{completed_at}}</li>
<li>Taille : {{backup_size}}</li>
<li>Emplacement : {{backup_path}}</li>
</ul>`,
body_text: `Sauvegarde de la base de données terminée\n\nHeure : {{completed_at}}\nTaille : {{backup_size}}\nEmplacement : {{backup_path}}`,
},
},
database_backup_failed: {
nl: {
subject: '[PicPeak] Database-back-up MISLUKT',
body_html: `<h2 style="color:#b91c1c;">Database-back-up mislukt</h2>
<p>De geplande database-back-up is mislukt en moet handmatig worden onderzocht.</p>
<ul>
<li>Tijdstip: {{failed_at}}</li>
<li>Foutmelding: {{error_message}}</li>
</ul>
<p>Controleer de serverlogboeken voor meer details.</p>`,
body_text: `Database-back-up mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}\n\nControleer de serverlogboeken voor meer details.`,
},
pt: {
subject: '[PicPeak] FALHA no backup do banco de dados',
body_html: `<h2 style="color:#b91c1c;">Falha no backup do banco de dados</h2>
<p>O backup agendado do banco de dados falhou e precisa de investigação manual.</p>
<ul>
<li>Horário: {{failed_at}}</li>
<li>Erro: {{error_message}}</li>
</ul>
<p>Verifique os logs do servidor para mais detalhes.</p>`,
body_text: `Falha no backup do banco de dados\n\nHorário: {{failed_at}}\nErro: {{error_message}}\n\nVerifique os logs do servidor.`,
},
ru: {
subject: '[PicPeak] ОШИБКА резервного копирования БД',
body_html: `<h2 style="color:#b91c1c;">Ошибка резервного копирования базы данных</h2>
<p>Запланированное резервное копирование базы данных завершилось с ошибкой и требует ручной проверки.</p>
<ul>
<li>Время: {{failed_at}}</li>
<li>Ошибка: {{error_message}}</li>
</ul>
<p>Проверьте журналы сервера для получения дополнительной информации.</p>`,
body_text: `Ошибка резервного копирования базы данных\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}\n\nПроверьте журналы сервера.`,
},
fr: {
subject: '[PicPeak] ÉCHEC de la sauvegarde de la base de données',
body_html: `<h2 style="color:#b91c1c;">Échec de la sauvegarde de la base de données</h2>
<p>La sauvegarde planifiée de la base de données a échoué et nécessite une investigation manuelle.</p>
<ul>
<li>Heure : {{failed_at}}</li>
<li>Erreur : {{error_message}}</li>
</ul>
<p>Consultez les journaux du serveur pour plus de détails.</p>`,
body_text: `Échec de la sauvegarde de la base de données\n\nHeure : {{failed_at}}\nErreur : {{error_message}}\n\nConsultez les journaux du serveur.`,
},
},
restore_completed: {
nl: {
subject: '[PicPeak] Database-herstel succesvol',
body_html: `<h2>Database-herstel voltooid</h2>
<p>De handmatige database-herstel-operatie is succesvol voltooid.</p>
<ul>
<li>Tijdstip: {{completed_at}}</li>
<li>Herstelpunt: {{source_backup}}</li>
</ul>`,
body_text: `Database-herstel voltooid\n\nTijdstip: {{completed_at}}\nHerstelpunt: {{source_backup}}`,
},
pt: {
subject: '[PicPeak] Restauração do banco de dados concluída',
body_html: `<h2>Restauração concluída</h2>
<p>A restauração manual do banco de dados foi concluída com sucesso.</p>
<ul>
<li>Horário: {{completed_at}}</li>
<li>Origem: {{source_backup}}</li>
</ul>`,
body_text: `Restauração concluída\n\nHorário: {{completed_at}}\nOrigem: {{source_backup}}`,
},
ru: {
subject: '[PicPeak] Восстановление БД успешно завершено',
body_html: `<h2>Восстановление базы данных завершено</h2>
<p>Ручная операция восстановления базы данных успешно завершена.</p>
<ul>
<li>Время: {{completed_at}}</li>
<li>Точка восстановления: {{source_backup}}</li>
</ul>`,
body_text: `Восстановление базы данных завершено\n\nВремя: {{completed_at}}\nТочка восстановления: {{source_backup}}`,
},
fr: {
subject: '[PicPeak] Restauration de la base de données réussie',
body_html: `<h2>Restauration de la base terminée</h2>
<p>L'opération manuelle de restauration de la base de données s'est terminée avec succès.</p>
<ul>
<li>Heure : {{completed_at}}</li>
<li>Source : {{source_backup}}</li>
</ul>`,
body_text: `Restauration terminée\n\nHeure : {{completed_at}}\nSource : {{source_backup}}`,
},
},
restore_failed: {
nl: {
subject: '[PicPeak] Database-herstel MISLUKT',
body_html: `<h2 style="color:#b91c1c;">Database-herstel mislukt</h2>
<p>De handmatige database-herstel-operatie is mislukt en moet handmatig worden onderzocht.</p>
<ul>
<li>Tijdstip: {{failed_at}}</li>
<li>Foutmelding: {{error_message}}</li>
</ul>`,
body_text: `Database-herstel mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}`,
},
pt: {
subject: '[PicPeak] FALHA na restauração do banco de dados',
body_html: `<h2 style="color:#b91c1c;">Falha na restauração</h2>
<p>A restauração manual do banco de dados falhou e precisa de investigação.</p>
<ul>
<li>Horário: {{failed_at}}</li>
<li>Erro: {{error_message}}</li>
</ul>`,
body_text: `Falha na restauração\n\nHorário: {{failed_at}}\nErro: {{error_message}}`,
},
ru: {
subject: '[PicPeak] ОШИБКА восстановления БД',
body_html: `<h2 style="color:#b91c1c;">Ошибка восстановления базы данных</h2>
<p>Ручная операция восстановления базы данных завершилась с ошибкой и требует проверки.</p>
<ul>
<li>Время: {{failed_at}}</li>
<li>Ошибка: {{error_message}}</li>
</ul>`,
body_text: `Ошибка восстановления базы данных\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}`,
},
fr: {
subject: '[PicPeak] ÉCHEC de la restauration de la base de données',
body_html: `<h2 style="color:#b91c1c;">Échec de la restauration</h2>
<p>L'opération manuelle de restauration de la base de données a échoué et nécessite une investigation.</p>
<ul>
<li>Heure : {{failed_at}}</li>
<li>Erreur : {{error_message}}</li>
</ul>`,
body_text: `Échec de la restauration\n\nHeure : {{failed_at}}\nErreur : {{error_message}}`,
},
},
// ────────────────────────────────────────────────────────────────
// File backups (legacy backup_completed / backup_failed pair).
// Mostly identical content to database_backup_* but kept separate
// because the legacy keys are still wired to a different code path.
// ────────────────────────────────────────────────────────────────
backup_completed: {
nl: {
subject: '[PicPeak] Bestandsback-up voltooid',
body_html: `<h2>Bestandsback-up voltooid</h2>
<p>De geplande bestandsback-up is succesvol voltooid.</p>
<ul>
<li>Tijdstip: {{completed_at}}</li>
<li>Grootte: {{backup_size}}</li>
<li>Locatie: {{backup_path}}</li>
</ul>`,
body_text: `Bestandsback-up voltooid\n\nTijdstip: {{completed_at}}\nGrootte: {{backup_size}}\nLocatie: {{backup_path}}`,
},
pt: {
subject: '[PicPeak] Backup de arquivos concluído',
body_html: `<h2>Backup de arquivos concluído</h2>
<p>O backup agendado de arquivos foi concluído com sucesso.</p>
<ul>
<li>Horário: {{completed_at}}</li>
<li>Tamanho: {{backup_size}}</li>
<li>Localização: {{backup_path}}</li>
</ul>`,
body_text: `Backup de arquivos concluído\n\nHorário: {{completed_at}}\nTamanho: {{backup_size}}\nLocalização: {{backup_path}}`,
},
ru: {
subject: '[PicPeak] Резервное копирование файлов завершено',
body_html: `<h2>Резервное копирование файлов завершено</h2>
<p>Запланированное резервное копирование файлов успешно завершено.</p>
<ul>
<li>Время: {{completed_at}}</li>
<li>Размер: {{backup_size}}</li>
<li>Расположение: {{backup_path}}</li>
</ul>`,
body_text: `Резервное копирование файлов завершено\n\nВремя: {{completed_at}}\nРазмер: {{backup_size}}\nРасположение: {{backup_path}}`,
},
fr: {
subject: '[PicPeak] Sauvegarde des fichiers terminée',
body_html: `<h2>Sauvegarde des fichiers terminée</h2>
<p>La sauvegarde planifiée des fichiers s'est terminée avec succès.</p>
<ul>
<li>Heure : {{completed_at}}</li>
<li>Taille : {{backup_size}}</li>
<li>Emplacement : {{backup_path}}</li>
</ul>`,
body_text: `Sauvegarde des fichiers terminée\n\nHeure : {{completed_at}}\nTaille : {{backup_size}}\nEmplacement : {{backup_path}}`,
},
},
backup_failed: {
nl: {
subject: '[PicPeak] Bestandsback-up MISLUKT',
body_html: `<h2 style="color:#b91c1c;">Bestandsback-up mislukt</h2>
<p>De geplande bestandsback-up is mislukt en moet worden onderzocht.</p>
<ul>
<li>Tijdstip: {{failed_at}}</li>
<li>Foutmelding: {{error_message}}</li>
</ul>`,
body_text: `Bestandsback-up mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}`,
},
pt: {
subject: '[PicPeak] FALHA no backup de arquivos',
body_html: `<h2 style="color:#b91c1c;">Falha no backup de arquivos</h2>
<p>O backup agendado de arquivos falhou e precisa de investigação.</p>
<ul>
<li>Horário: {{failed_at}}</li>
<li>Erro: {{error_message}}</li>
</ul>`,
body_text: `Falha no backup de arquivos\n\nHorário: {{failed_at}}\nErro: {{error_message}}`,
},
ru: {
subject: '[PicPeak] ОШИБКА резервного копирования файлов',
body_html: `<h2 style="color:#b91c1c;">Ошибка резервного копирования файлов</h2>
<p>Запланированное резервное копирование файлов завершилось с ошибкой и требует проверки.</p>
<ul>
<li>Время: {{failed_at}}</li>
<li>Ошибка: {{error_message}}</li>
</ul>`,
body_text: `Ошибка резервного копирования файлов\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}`,
},
fr: {
subject: '[PicPeak] ÉCHEC de la sauvegarde des fichiers',
body_html: `<h2 style="color:#b91c1c;">Échec de la sauvegarde des fichiers</h2>
<p>La sauvegarde planifiée des fichiers a échoué et nécessite une investigation.</p>
<ul>
<li>Heure : {{failed_at}}</li>
<li>Erreur : {{error_message}}</li>
</ul>`,
body_text: `Échec de la sauvegarde des fichiers\n\nHeure : {{failed_at}}\nErreur : {{error_message}}`,
},
},
// ────────────────────────────────────────────────────────────────
// Version update notifications
// ────────────────────────────────────────────────────────────────
version_update_available: {
nl: {
subject: 'PicPeak-update beschikbaar: versie {{new_version}}',
body_html: `<h2>Nieuwe PicPeak-versie beschikbaar</h2>
<p>Er is een nieuwe versie van PicPeak beschikbaar.</p>
<ul>
<li>Huidige versie: {{current_version}}</li>
<li>Nieuwe versie: {{new_version}}</li>
<li>Releasekanaal: {{channel}}</li>
</ul>
<p><a href="{{release_url}}" class="button">Release-notities bekijken</a></p>`,
body_text: `Nieuwe PicPeak-versie beschikbaar\n\nHuidige versie: {{current_version}}\nNieuwe versie: {{new_version}}\nReleasekanaal: {{channel}}\n\nRelease-notities: {{release_url}}`,
},
pt: {
subject: 'Atualização do PicPeak disponível: versão {{new_version}}',
body_html: `<h2>Nova versão do PicPeak disponível</h2>
<p>Uma nova versão do PicPeak está disponível.</p>
<ul>
<li>Versão atual: {{current_version}}</li>
<li>Nova versão: {{new_version}}</li>
<li>Canal: {{channel}}</li>
</ul>
<p><a href="{{release_url}}" class="button">Ver notas da versão</a></p>`,
body_text: `Nova versão do PicPeak disponível\n\nVersão atual: {{current_version}}\nNova versão: {{new_version}}\nCanal: {{channel}}\n\nNotas da versão: {{release_url}}`,
},
ru: {
subject: 'Доступно обновление PicPeak: версия {{new_version}}',
body_html: `<h2>Доступна новая версия PicPeak</h2>
<p>Появилась новая версия PicPeak.</p>
<ul>
<li>Текущая версия: {{current_version}}</li>
<li>Новая версия: {{new_version}}</li>
<li>Канал выпуска: {{channel}}</li>
</ul>
<p><a href="{{release_url}}" class="button">Посмотреть примечания к выпуску</a></p>`,
body_text: `Доступна новая версия PicPeak\n\nТекущая версия: {{current_version}}\nНовая версия: {{new_version}}\nКанал: {{channel}}\n\nПримечания к выпуску: {{release_url}}`,
},
fr: {
subject: 'Mise à jour PicPeak disponible : version {{new_version}}',
body_html: `<h2>Nouvelle version de PicPeak disponible</h2>
<p>Une nouvelle version de PicPeak est disponible.</p>
<ul>
<li>Version actuelle : {{current_version}}</li>
<li>Nouvelle version : {{new_version}}</li>
<li>Canal : {{channel}}</li>
</ul>
<p><a href="{{release_url}}" class="button">Voir les notes de version</a></p>`,
body_text: `Nouvelle version de PicPeak disponible\n\nVersion actuelle : {{current_version}}\nNouvelle version : {{new_version}}\nCanal : {{channel}}\n\nNotes de version : {{release_url}}`,
},
},
// version_update_test was seeded by migration 087 in the legacy
// subject_en / body_html_en / subject_de / body_html_de columns
// AFTER migration 075 had already migrated existing rows into
// email_template_translations — so this template has zero
// translation rows even though the EN/DE content exists. The
// Templates admin UI consequently shows it as empty. Seeding the
// full set here (mirroring 087's curated EN/DE plus AI-generated
// nl/pt/ru/fr) restores the editor.
version_update_test: {
en: {
subject: '[TEST] PicPeak Update Notification — configuration check',
body_html: `<h2>This is a test email</h2>
<p>You are receiving this message because an administrator clicked
<strong>Send Test Email</strong> on the Update Notifications page of your
PicPeak installation.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Installed version:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Channel:</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Recipient address:</strong> {{recipient_email}}</p>
</div>
<p>If you can read this email, your SMTP configuration and the recipient
list are working correctly. When a real new version becomes available,
PicPeak will send a separate notification with release notes and update
instructions.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">No action is required.
You may safely delete this message.</p>`,
body_text: `This is a test email\n\nYou are receiving this message because an administrator clicked "Send Test Email" on the Update Notifications page of your PicPeak installation.\n\nInstalled version: {{current_version}}\nChannel: {{channel}}\nRecipient address: {{recipient_email}}\n\nIf you can read this email, your SMTP configuration and the recipient list are working correctly. When a real new version becomes available, PicPeak will send a separate notification with release notes and update instructions.\n\nNo action is required. You may safely delete this message.`,
},
de: {
subject: '[TEST] PicPeak Update-Benachrichtigung — Konfigurationsprüfung',
body_html: `<h2>Dies ist eine Test-E-Mail</h2>
<p>Sie erhalten diese Nachricht, weil ein Administrator auf der Seite
„Update-Benachrichtigungen" Ihrer PicPeak-Installation auf
<strong>Test-E-Mail senden</strong> geklickt hat.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Installierte Version:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Kanal:</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Empfänger-Adresse:</strong> {{recipient_email}}</p>
</div>
<p>Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration
und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar
ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen
und Update-Anweisungen.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">Es ist keine Aktion
erforderlich. Sie können diese Nachricht gefahrlos löschen.</p>`,
body_text: `Dies ist eine Test-E-Mail\n\nSie erhalten diese Nachricht, weil ein Administrator auf der Seite „Update-Benachrichtigungen" Ihrer PicPeak-Installation auf „Test-E-Mail senden" geklickt hat.\n\nInstallierte Version: {{current_version}}\nKanal: {{channel}}\nEmpfänger-Adresse: {{recipient_email}}\n\nWenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen und Update-Anweisungen.\n\nEs ist keine Aktion erforderlich. Sie können diese Nachricht gefahrlos löschen.`,
},
nl: {
subject: '[TEST] PicPeak-update-melding — configuratiecontrole',
body_html: `<h2>Dit is een test-e-mail</h2>
<p>U ontvangt dit bericht omdat een beheerder op de pagina
"Update-meldingen" van uw PicPeak-installatie op
<strong>Test-e-mail verzenden</strong> heeft geklikt.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Geïnstalleerde versie:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Kanaal:</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Ontvangeradres:</strong> {{recipient_email}}</p>
</div>
<p>Als u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct. Wanneer er een echte nieuwe versie beschikbaar komt, stuurt PicPeak een aparte melding met release-notities en update-instructies.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">Geen actie vereist. U kunt dit bericht veilig verwijderen.</p>`,
body_text: `Dit is een test-e-mail\n\nU ontvangt dit bericht omdat een beheerder op de pagina "Update-meldingen" van uw PicPeak-installatie op "Test-e-mail verzenden" heeft geklikt.\n\nGeïnstalleerde versie: {{current_version}}\nKanaal: {{channel}}\nOntvangeradres: {{recipient_email}}\n\nAls u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct.\n\nGeen actie vereist.`,
},
pt: {
subject: '[TESTE] Notificação de atualização do PicPeak — verificação',
body_html: `<h2>Este é um e-mail de teste</h2>
<p>Você está recebendo esta mensagem porque um administrador clicou em
<strong>Enviar e-mail de teste</strong> na página "Notificações de
atualização" da sua instalação do PicPeak.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Versão instalada:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Canal:</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Endereço do destinatário:</strong> {{recipient_email}}</p>
</div>
<p>Se você consegue ler este e-mail, sua configuração SMTP e a lista de destinatários estão funcionando corretamente. Quando uma nova versão real estiver disponível, o PicPeak enviará uma notificação separada com notas de versão e instruções de atualização.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">Nenhuma ação é necessária. Você pode excluir esta mensagem com segurança.</p>`,
body_text: `Este é um e-mail de teste\n\nVocê está recebendo esta mensagem porque um administrador clicou em "Enviar e-mail de teste" na página "Notificações de atualização" da sua instalação do PicPeak.\n\nVersão instalada: {{current_version}}\nCanal: {{channel}}\nEndereço do destinatário: {{recipient_email}}\n\nSe você consegue ler este e-mail, sua configuração SMTP está funcionando corretamente.\n\nNenhuma ação é necessária.`,
},
ru: {
subject: '[ТЕСТ] Уведомление об обновлениях PicPeak — проверка',
body_html: `<h2>Это тестовое письмо</h2>
<p>Вы получили это сообщение, потому что администратор нажал
<strong>Отправить тестовое письмо</strong> на странице
«Уведомления об обновлениях» вашей установки PicPeak.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Установленная версия:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Канал:</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Адрес получателя:</strong> {{recipient_email}}</p>
</div>
<p>Если вы видите это письмо, значит ваша конфигурация SMTP и список получателей работают корректно. Когда станет доступна новая версия, PicPeak отправит отдельное уведомление с примечаниями к выпуску и инструкциями по обновлению.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">Никаких действий не требуется. Можете безопасно удалить это сообщение.</p>`,
body_text: `Это тестовое письмо\n\nВы получили это сообщение, потому что администратор нажал «Отправить тестовое письмо» на странице «Уведомления об обновлениях» вашей установки PicPeak.\n\nУстановленная версия: {{current_version}}\nКанал: {{channel}}\nАдрес получателя: {{recipient_email}}\n\nЕсли вы видите это письмо, ваша конфигурация SMTP работает корректно.\n\nНикаких действий не требуется.`,
},
fr: {
subject: '[TEST] Notification de mise à jour PicPeak — vérification',
body_html: `<h2>Ceci est un e-mail de test</h2>
<p>Vous recevez ce message parce qu'un administrateur a cliqué sur
<strong>Envoyer un e-mail de test</strong> sur la page « Notifications
de mise à jour » de votre installation PicPeak.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Version installée :</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Canal :</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Adresse du destinataire :</strong> {{recipient_email}}</p>
</div>
<p>Si vous pouvez lire cet e-mail, votre configuration SMTP et la liste des destinataires fonctionnent correctement. Lorsqu'une nouvelle version réelle sera disponible, PicPeak enverra une notification distincte avec les notes de version et les instructions de mise à jour.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">Aucune action n'est requise. Vous pouvez supprimer ce message en toute sécurité.</p>`,
body_text: `Ceci est un e-mail de test\n\nVous recevez ce message parce qu'un administrateur a cliqué sur « Envoyer un e-mail de test » sur la page « Notifications de mise à jour » de votre installation PicPeak.\n\nVersion installée : {{current_version}}\nCanal : {{channel}}\nAdresse du destinataire : {{recipient_email}}\n\nSi vous pouvez lire cet e-mail, votre configuration SMTP fonctionne correctement.\n\nAucune action n'est requise.`,
},
},
};
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('email_templates'))) return;
if (!(await knex.schema.hasTable('email_template_translations'))) return;
// Resolve template_key → id once, skip keys that aren't seeded on
// this install (e.g. customer_invitation on a pre-090 instance).
const rows = await knex('email_templates')
.whereIn('template_key', Object.keys(TRANSLATIONS))
.select('id', 'template_key');
const keyToId = Object.fromEntries(rows.map((r) => [r.template_key, r.id]));
let inserted = 0;
let skipped = 0;
for (const [key, perLocale] of Object.entries(TRANSLATIONS)) {
const templateId = keyToId[key];
if (!templateId) {
// Template not present on this install (older release than the
// seeding migration). Skip — there's nothing to attach to.
continue;
}
for (const [language, content] of Object.entries(perLocale)) {
const existing = await knex('email_template_translations')
.where({ template_id: templateId, language })
.first();
if (existing) {
skipped += 1;
continue;
}
await knex('email_template_translations').insert({
template_id: templateId,
language,
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
created_at: new Date(),
updated_at: new Date(),
});
inserted += 1;
}
}
console.log(`099_seed_missing_email_template_translations: inserted=${inserted}, skipped=${skipped}`);
};
exports.down = async function(knex) {
// Down-migration intentionally a no-op. We don't know which of the
// locale rows existed before this migration vs were inserted by it —
// dropping every nl/pt/ru/fr row would wipe content the admin may
// have edited in the UI. Rollback by hand if you really need to.
};
@@ -0,0 +1,295 @@
/**
* Migration: Re-apply email-template backfills that earlier deployed
* versions of 098 / 099 missed.
*
* Why a separate migration? Knex tracks migrations by filename — once
* 098 / 099 were recorded as applied in `knex_migrations`, editing
* them doesn't re-run on subsequent deploys. The first deployed
* versions of those migrations didn't include:
* - the `subcategory` column population (added later)
* - the `customer_password_reset` category override (added later)
* - the en/de/nl/pt/ru/fr translation rows for
* `customer_password_reset` and `version_update_test` (both
* inserted after 075 ran, so they sat in legacy columns only
* and showed empty in the Templates editor — the AI translations
* were added to 099 after its first deploy).
*
* This migration is append-only (no schema change beyond defensive
* column checks) and re-applies all the affected data:
* 1. Sets category / subcategory / feature_flag on every known
* template_key.
* 2. Seeds missing translation rows for the two post-075
* templates across all six locales.
*
* Idempotent throughout: skips translation inserts that already
* exist, and the category writes are no-ops when values already match.
*/
const TEMPLATE_METADATA = {
// Core / Galleries — gallery delivery lifecycle.
gallery_created: { category: 'core', subcategory: 'gallery', feature_flag: null },
expiration_warning: { category: 'core', subcategory: 'gallery', feature_flag: null },
gallery_expired: { category: 'core', subcategory: 'gallery', feature_flag: null },
archive_complete: { category: 'core', subcategory: 'gallery', feature_flag: null },
// Core / Admin — admin account lifecycle.
admin_invitation: { category: 'core', subcategory: 'admin', feature_flag: null },
admin_password_reset: { category: 'core', subcategory: 'admin', feature_flag: null },
// Core / Backup — database + file backups + restores.
database_backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
database_backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
restore_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
restore_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
// Core / System — version-update notifications.
version_update_available: { category: 'core', subcategory: 'system', feature_flag: null },
version_update_test: { category: 'core', subcategory: 'system', feature_flag: null },
// Customers — customer-portal lifecycle.
customer_invitation: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' },
customer_password_reset: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' },
};
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('email_templates'))) return;
// Defensive: if migration 098 didn't run for some reason on this
// install (a fork, a partial copy, etc.) make sure the columns
// exist before we try to write to them. Idempotent — these are
// no-ops if the column already exists.
const cols = await knex('email_templates').columnInfo();
if (!cols.category) {
await knex.schema.alterTable('email_templates', (t) => {
t.string('category', 32).notNullable().defaultTo('core');
});
}
if (!cols.subcategory) {
await knex.schema.alterTable('email_templates', (t) => {
t.string('subcategory', 32).nullable();
});
}
if (!cols.feature_flag) {
await knex.schema.alterTable('email_templates', (t) => {
t.string('feature_flag', 64).nullable();
});
}
let updated = 0;
for (const [key, meta] of Object.entries(TEMPLATE_METADATA)) {
const result = await knex('email_templates')
.where({ template_key: key })
.update({
category: meta.category,
subcategory: meta.subcategory,
feature_flag: meta.feature_flag,
});
if (result > 0) updated += 1;
}
console.log(`100_backfill_email_template_subcategory: updated ${updated} template rows`);
// ── Translation backfill ──────────────────────────────────────────
// customer_password_reset (migration 092) and version_update_test
// (migration 087) were inserted AFTER migration 075 ran, so they
// have content in legacy subject_*/body_html_* columns but zero
// rows in `email_template_translations`. The Templates editor
// reads exclusively from the translations table → shows them as
// 0/6 empty until we seed them. Migration 099 added these rows on
// initial deploy, but the earlier-shipped version of 099 didn't
// include them, so instances that ran it then-and-now still have
// empty editors. Re-seed defensively here, skipping any
// (template_id, language) pair that already exists.
if (!(await knex.schema.hasTable('email_template_translations'))) return;
const TRANSLATIONS = {
customer_password_reset: {
en: {
subject: 'Reset your customer account password',
body_html: `<p>Hello,</p>
<p>Your photographer has triggered a password reset for your customer account.</p>
<p><a href="{{reset_link}}" class="button">Set a new password</a></p>
<p>This link expires on {{expires_at}}.</p>
<p>If you didn't expect this, you can ignore the message — your current password keeps working until you click the link.</p>`,
body_text: `Reset your customer account password\n\nYour photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`,
},
de: {
subject: 'Passwort für dein Kundenkonto zurücksetzen',
body_html: `<p>Hallo,</p>
<p>Dein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.</p>
<p><a href="{{reset_link}}" class="button">Neues Passwort festlegen</a></p>
<p>Dieser Link läuft am {{expires_at}} ab.</p>
<p>Wenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.</p>`,
body_text: `Passwort für dein Kundenkonto zurücksetzen\n\nDein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.\n\nNeues Passwort festlegen: {{reset_link}}\n\nDieser Link läuft am {{expires_at}} ab.\n\nWenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.`,
},
nl: {
subject: 'Wachtwoord van uw klantaccount opnieuw instellen',
body_html: `<p>Hallo,</p>
<p>Uw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.</p>
<p><a href="{{reset_link}}" class="button">Nieuw wachtwoord instellen</a></p>
<p>Deze link verloopt op {{expires_at}}.</p>
<p>Heeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.</p>`,
body_text: `Wachtwoord opnieuw instellen\n\nUw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.\n\nNieuw wachtwoord instellen: {{reset_link}}\n\nDeze link verloopt op {{expires_at}}.\n\nHeeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.`,
},
pt: {
subject: 'Redefina a senha da sua conta de cliente',
body_html: `<p>Olá,</p>
<p>Seu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.</p>
<p><a href="{{reset_link}}" class="button">Definir nova senha</a></p>
<p>Este link expira em {{expires_at}}.</p>
<p>Se você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.</p>`,
body_text: `Redefinir senha\n\nSeu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.\n\nDefinir nova senha: {{reset_link}}\n\nEste link expira em {{expires_at}}.\n\nSe você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.`,
},
ru: {
subject: 'Сброс пароля вашей клиентской учётной записи',
body_html: `<p>Здравствуйте!</p>
<p>Ваш фотограф инициировал сброс пароля для вашей клиентской учётной записи.</p>
<p><a href="{{reset_link}}" class="button">Задать новый пароль</a></p>
<p>Срок действия ссылки истекает {{expires_at}}.</p>
<p>Если вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.</p>`,
body_text: `Сброс пароля\n\nВаш фотограф инициировал сброс пароля для вашей клиентской учётной записи.\n\nЗадать новый пароль: {{reset_link}}\n\nСрок действия ссылки истекает {{expires_at}}.\n\nЕсли вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.`,
},
fr: {
subject: 'Réinitialisez le mot de passe de votre compte client',
body_html: `<p>Bonjour,</p>
<p>Votre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.</p>
<p><a href="{{reset_link}}" class="button">Définir un nouveau mot de passe</a></p>
<p>Ce lien expire le {{expires_at}}.</p>
<p>Si vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.</p>`,
body_text: `Réinitialiser le mot de passe\n\nVotre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.\n\nDéfinir un nouveau mot de passe : {{reset_link}}\n\nCe lien expire le {{expires_at}}.\n\nSi vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.`,
},
},
version_update_test: {
en: {
subject: '[TEST] PicPeak Update Notification — configuration check',
body_html: `<h2>This is a test email</h2>
<p>You are receiving this message because an administrator clicked
<strong>Send Test Email</strong> on the Update Notifications page of your
PicPeak installation.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Installed version:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Channel:</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Recipient address:</strong> {{recipient_email}}</p>
</div>
<p>If you can read this email, your SMTP configuration and the recipient
list are working correctly. When a real new version becomes available,
PicPeak will send a separate notification with release notes and update
instructions.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">No action is required.
You may safely delete this message.</p>`,
body_text: `This is a test email\n\nYou are receiving this message because an administrator clicked "Send Test Email" on the Update Notifications page of your PicPeak installation.\n\nInstalled version: {{current_version}}\nChannel: {{channel}}\nRecipient address: {{recipient_email}}\n\nIf you can read this email, your SMTP configuration and the recipient list are working correctly. When a real new version becomes available, PicPeak will send a separate notification with release notes and update instructions.\n\nNo action is required. You may safely delete this message.`,
},
de: {
subject: '[TEST] PicPeak Update-Benachrichtigung — Konfigurationsprüfung',
body_html: `<h2>Dies ist eine Test-E-Mail</h2>
<p>Sie erhalten diese Nachricht, weil ein Administrator auf der Seite
„Update-Benachrichtigungen" Ihrer PicPeak-Installation auf
<strong>Test-E-Mail senden</strong> geklickt hat.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Installierte Version:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Kanal:</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Empfänger-Adresse:</strong> {{recipient_email}}</p>
</div>
<p>Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration
und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar
ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen
und Update-Anweisungen.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">Es ist keine Aktion
erforderlich. Sie können diese Nachricht gefahrlos löschen.</p>`,
body_text: `Dies ist eine Test-E-Mail\n\nSie erhalten diese Nachricht, weil ein Administrator auf der Seite „Update-Benachrichtigungen" Ihrer PicPeak-Installation auf „Test-E-Mail senden" geklickt hat.\n\nInstallierte Version: {{current_version}}\nKanal: {{channel}}\nEmpfänger-Adresse: {{recipient_email}}\n\nWenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen und Update-Anweisungen.\n\nEs ist keine Aktion erforderlich. Sie können diese Nachricht gefahrlos löschen.`,
},
nl: {
subject: '[TEST] PicPeak-update-melding — configuratiecontrole',
body_html: `<h2>Dit is een test-e-mail</h2>
<p>U ontvangt dit bericht omdat een beheerder op de pagina
"Update-meldingen" van uw PicPeak-installatie op
<strong>Test-e-mail verzenden</strong> heeft geklikt.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Geïnstalleerde versie:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Kanaal:</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Ontvangeradres:</strong> {{recipient_email}}</p>
</div>
<p>Als u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct. Wanneer er een echte nieuwe versie beschikbaar komt, stuurt PicPeak een aparte melding met release-notities en update-instructies.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">Geen actie vereist. U kunt dit bericht veilig verwijderen.</p>`,
body_text: `Dit is een test-e-mail\n\nU ontvangt dit bericht omdat een beheerder op de pagina "Update-meldingen" van uw PicPeak-installatie op "Test-e-mail verzenden" heeft geklikt.\n\nGeïnstalleerde versie: {{current_version}}\nKanaal: {{channel}}\nOntvangeradres: {{recipient_email}}\n\nAls u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct.\n\nGeen actie vereist.`,
},
pt: {
subject: '[TESTE] Notificação de atualização do PicPeak — verificação',
body_html: `<h2>Este é um e-mail de teste</h2>
<p>Você está recebendo esta mensagem porque um administrador clicou em
<strong>Enviar e-mail de teste</strong> na página "Notificações de
atualização" da sua instalação do PicPeak.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Versão instalada:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Canal:</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Endereço do destinatário:</strong> {{recipient_email}}</p>
</div>
<p>Se você consegue ler este e-mail, sua configuração SMTP e a lista de destinatários estão funcionando corretamente. Quando uma nova versão real estiver disponível, o PicPeak enviará uma notificação separada com notas de versão e instruções de atualização.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">Nenhuma ação é necessária. Você pode excluir esta mensagem com segurança.</p>`,
body_text: `Este é um e-mail de teste\n\nVocê está recebendo esta mensagem porque um administrador clicou em "Enviar e-mail de teste" na página "Notificações de atualização" da sua instalação do PicPeak.\n\nVersão instalada: {{current_version}}\nCanal: {{channel}}\nEndereço do destinatário: {{recipient_email}}\n\nSe você consegue ler este e-mail, sua configuração SMTP está funcionando corretamente.\n\nNenhuma ação é necessária.`,
},
ru: {
subject: '[ТЕСТ] Уведомление об обновлениях PicPeak — проверка',
body_html: `<h2>Это тестовое письмо</h2>
<p>Вы получили это сообщение, потому что администратор нажал
<strong>Отправить тестовое письмо</strong> на странице
«Уведомления об обновлениях» вашей установки PicPeak.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Установленная версия:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Канал:</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Адрес получателя:</strong> {{recipient_email}}</p>
</div>
<p>Если вы видите это письмо, значит ваша конфигурация SMTP и список получателей работают корректно. Когда станет доступна новая версия, PicPeak отправит отдельное уведомление с примечаниями к выпуску и инструкциями по обновлению.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">Никаких действий не требуется. Можете безопасно удалить это сообщение.</p>`,
body_text: `Это тестовое письмо\n\nВы получили это сообщение, потому что администратор нажал «Отправить тестовое письмо» на странице «Уведомления об обновлениях» вашей установки PicPeak.\n\nУстановленная версия: {{current_version}}\nКанал: {{channel}}\nАдрес получателя: {{recipient_email}}\n\nЕсли вы видите это письмо, ваша конфигурация SMTP работает корректно.\n\nНикаких действий не требуется.`,
},
fr: {
subject: '[TEST] Notification de mise à jour PicPeak — vérification',
body_html: `<h2>Ceci est un e-mail de test</h2>
<p>Vous recevez ce message parce qu'un administrateur a cliqué sur
<strong>Envoyer un e-mail de test</strong> sur la page « Notifications
de mise à jour » de votre installation PicPeak.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Version installée :</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Canal :</strong> {{channel}}</p>
<p style="margin: 10px 0 0 0;"><strong>Adresse du destinataire :</strong> {{recipient_email}}</p>
</div>
<p>Si vous pouvez lire cet e-mail, votre configuration SMTP et la liste des destinataires fonctionnent correctement. Lorsqu'une nouvelle version réelle sera disponible, PicPeak enverra une notification distincte avec les notes de version et les instructions de mise à jour.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">Aucune action n'est requise. Vous pouvez supprimer ce message en toute sécurité.</p>`,
body_text: `Ceci est un e-mail de test\n\nVous recevez ce message parce qu'un administrateur a cliqué sur « Envoyer un e-mail de test » sur la page « Notifications de mise à jour » de votre installation PicPeak.\n\nVersion installée : {{current_version}}\nCanal : {{channel}}\nAdresse du destinataire : {{recipient_email}}\n\nSi vous pouvez lire cet e-mail, votre configuration SMTP fonctionne correctement.\n\nAucune action n'est requise.`,
},
},
};
const keyRows = await knex('email_templates')
.whereIn('template_key', Object.keys(TRANSLATIONS))
.select('id', 'template_key');
const keyToId = Object.fromEntries(keyRows.map((r) => [r.template_key, r.id]));
let inserted = 0;
let skipped = 0;
for (const [key, perLocale] of Object.entries(TRANSLATIONS)) {
const templateId = keyToId[key];
if (!templateId) continue;
for (const [language, content] of Object.entries(perLocale)) {
const existing = await knex('email_template_translations')
.where({ template_id: templateId, language })
.first();
if (existing) { skipped += 1; continue; }
await knex('email_template_translations').insert({
template_id: templateId,
language,
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
created_at: new Date(),
updated_at: new Date(),
});
inserted += 1;
}
}
console.log(`100_backfill_email_template_subcategory: translation rows inserted=${inserted}, skipped=${skipped}`);
};
exports.down = async function() {
// No-op. This migration is a data backfill — rolling it back would
// require restoring the previous values, which we don't track.
// Migration 098's down handler still owns dropping the columns.
};
@@ -0,0 +1,243 @@
/**
* Migration: Add `customer_gallery_assigned` email template.
*
* Sent when an admin adds new gallery assignments to an existing
* customer via the "Manage galleries" dialog on the customer detail
* page. Digest-style — one email per save listing every newly added
* gallery, not one email per gallery (admins often set up new clients
* by adding several galleries in a single sitting).
*
* Variables:
* - customer_name greeting name (display name / first name / email local)
* - gallery_count integer (string) — number of newly added galleries
* - singular "true" when count === 1 (drives intro wording)
* - multiple "true" when count > 1
* - gallery_list_html pre-rendered <ul> with names + dates; passes
* through unescaped because the service builds it
* from trusted DB fields (event_name from
* admin-owned rows + server-rendered dates).
* - gallery_list_text newline-separated plain-text equivalent for
* the text/plain body.
* - dashboard_link URL of /customer/dashboard on the configured
* frontend origin.
*
* Category + flag: 'customers' + customerPortal — categorisation
* scaffold from migration 098. When the customer portal flag is off,
* the Templates admin UI chips this card "Feature off" but it's still
* editable.
*
* Translations: en + de hand-translated; nl/pt/ru/fr machine-generated
* and flagged for native review per project convention.
*
* Idempotent: skips if the template_key already exists.
*/
const TRANSLATIONS = {
en: {
subject: 'New gallery access on your account',
body_html: `<h2>You have new gallery access</h2>
<p>Hi {{customer_name}},</p>
{{#if singular}}<p>Your photographer just gave you access to a new gallery on your account:</p>{{/if}}{{#if multiple}}<p>Your photographer just gave you access to {{gallery_count}} new galleries on your account:</p>{{/if}}
{{gallery_list_html}}
<p style="text-align: center; margin: 30px 0;">
<a href="{{dashboard_link}}" class="button">Open your dashboard</a>
</p>
<p style="font-size: 13px; color: #666;">If the button doesn't work, copy and paste this link into your browser:<br>
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
body_text: `You have new gallery access
Hi {{customer_name}},
Your photographer just gave you access to {{gallery_count}} new gallery (or galleries) on your account:
{{gallery_list_text}}
Open your dashboard: {{dashboard_link}}`,
},
de: {
subject: 'Neue Galerie in deinem Konto verfügbar',
body_html: `<h2>Du hast Zugriff auf neue Galerien</h2>
<p>Hallo {{customer_name}},</p>
{{#if singular}}<p>Dein Fotograf hat dir gerade Zugriff auf eine neue Galerie in deinem Konto gegeben:</p>{{/if}}{{#if multiple}}<p>Dein Fotograf hat dir gerade Zugriff auf {{gallery_count}} neue Galerien in deinem Konto gegeben:</p>{{/if}}
{{gallery_list_html}}
<p style="text-align: center; margin: 30px 0;">
<a href="{{dashboard_link}}" class="button">Zum Dashboard</a>
</p>
<p style="font-size: 13px; color: #666;">Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
body_text: `Du hast Zugriff auf neue Galerien
Hallo {{customer_name}},
Dein Fotograf hat dir gerade Zugriff auf {{gallery_count}} neue Galerie(n) in deinem Konto gegeben:
{{gallery_list_text}}
Zum Dashboard: {{dashboard_link}}`,
},
fr: {
subject: 'Nouvel accès galerie sur votre compte',
body_html: `<h2>Vous avez accès à de nouvelles galeries</h2>
<p>Bonjour {{customer_name}},</p>
{{#if singular}}<p>Votre photographe vient de vous donner accès à une nouvelle galerie sur votre compte :</p>{{/if}}{{#if multiple}}<p>Votre photographe vient de vous donner accès à {{gallery_count}} nouvelles galeries sur votre compte :</p>{{/if}}
{{gallery_list_html}}
<p style="text-align: center; margin: 30px 0;">
<a href="{{dashboard_link}}" class="button">Ouvrir mon tableau de bord</a>
</p>
<p style="font-size: 13px; color: #666;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
body_text: `Vous avez accès à de nouvelles galeries
Bonjour {{customer_name}},
Votre photographe vient de vous donner accès à {{gallery_count}} nouvelle(s) galerie(s) sur votre compte :
{{gallery_list_text}}
Tableau de bord : {{dashboard_link}}`,
},
nl: {
subject: 'Nieuwe galerij toegevoegd aan uw account',
body_html: `<h2>U heeft toegang tot nieuwe galerijen</h2>
<p>Hallo {{customer_name}},</p>
{{#if singular}}<p>Uw fotograaf heeft u zojuist toegang gegeven tot een nieuwe galerij in uw account:</p>{{/if}}{{#if multiple}}<p>Uw fotograaf heeft u zojuist toegang gegeven tot {{gallery_count}} nieuwe galerijen in uw account:</p>{{/if}}
{{gallery_list_html}}
<p style="text-align: center; margin: 30px 0;">
<a href="{{dashboard_link}}" class="button">Open uw dashboard</a>
</p>
<p style="font-size: 13px; color: #666;">Werkt de knop niet? Kopieer dan deze link in uw browser:<br>
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
body_text: `U heeft toegang tot nieuwe galerijen
Hallo {{customer_name}},
Uw fotograaf heeft u zojuist toegang gegeven tot {{gallery_count}} nieuwe galerij(en) in uw account:
{{gallery_list_text}}
Dashboard: {{dashboard_link}}`,
},
pt: {
subject: 'Nova galeria disponível em sua conta',
body_html: `<h2>Você tem acesso a novas galerias</h2>
<p>Olá {{customer_name}},</p>
{{#if singular}}<p>Seu fotógrafo acabou de lhe dar acesso a uma nova galeria em sua conta:</p>{{/if}}{{#if multiple}}<p>Seu fotógrafo acabou de lhe dar acesso a {{gallery_count}} novas galerias em sua conta:</p>{{/if}}
{{gallery_list_html}}
<p style="text-align: center; margin: 30px 0;">
<a href="{{dashboard_link}}" class="button">Abrir meu painel</a>
</p>
<p style="font-size: 13px; color: #666;">Se o botão não funcionar, copie este link no navegador:<br>
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
body_text: `Você tem acesso a novas galerias
Olá {{customer_name}},
Seu fotógrafo acabou de lhe dar acesso a {{gallery_count}} nova(s) galeria(s) em sua conta:
{{gallery_list_text}}
Painel: {{dashboard_link}}`,
},
ru: {
subject: 'Новая галерея доступна в вашем аккаунте',
body_html: `<h2>У вас новый доступ к галереям</h2>
<p>Здравствуйте, {{customer_name}}!</p>
{{#if singular}}<p>Ваш фотограф только что предоставил вам доступ к новой галерее в вашем аккаунте:</p>{{/if}}{{#if multiple}}<p>Ваш фотограф только что предоставил вам доступ к {{gallery_count}} новым галереям в вашем аккаунте:</p>{{/if}}
{{gallery_list_html}}
<p style="text-align: center; margin: 30px 0;">
<a href="{{dashboard_link}}" class="button">Открыть мой кабинет</a>
</p>
<p style="font-size: 13px; color: #666;">Если кнопка не работает, скопируйте эту ссылку в браузер:<br>
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
body_text: `У вас новый доступ к галереям
Здравствуйте, {{customer_name}}!
Ваш фотограф только что предоставил вам доступ к {{gallery_count}} новым галереям в вашем аккаунте:
{{gallery_list_text}}
Личный кабинет: {{dashboard_link}}`,
},
};
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('email_templates'))) return;
const existing = await knex('email_templates')
.where({ template_key: 'customer_gallery_assigned' })
.first();
if (existing) {
console.log(' customer_gallery_assigned template already exists, skipping insert');
return;
}
// Detect schema variant (legacy per-column vs normalized translations).
// Newer installs have the email_template_translations table from
// migration 075; older ones might still have subject_en/de/... columns
// and NOT NULL constraints on the legacy columns. Cover both.
const cols = await knex('email_templates').columnInfo();
const hasTranslationsTable = await knex.schema.hasTable('email_template_translations');
const enContent = TRANSLATIONS.en;
// Build the master row. category/subcategory/feature_flag columns
// were added in migration 098 — guard so this migration works on
// a slightly older install too.
const masterRow = {
template_key: 'customer_gallery_assigned',
variables: JSON.stringify([
'customer_name',
'gallery_count',
'singular',
'multiple',
'gallery_list_html',
'gallery_list_text',
'dashboard_link',
]),
};
if ('category' in cols) masterRow.category = 'customers';
if ('subcategory' in cols) masterRow.subcategory = null;
if ('feature_flag' in cols) masterRow.feature_flag = 'customerPortal';
if ('created_at' in cols) masterRow.created_at = new Date();
if ('updated_at' in cols) masterRow.updated_at = new Date();
// Populate any legacy subject_*/body_html_*/body_text_* columns the
// schema still carries. Fallback content for non-en locales is the
// English string — the translations table below has the real
// per-locale copy. This only matters if the install hasn't run
// migration 075 yet, which is rare but possible.
for (const colName of Object.keys(cols)) {
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = enContent.subject;
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = enContent.body_html;
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = enContent.body_text;
}
}
const inserted = await knex('email_templates').insert(masterRow).returning('id');
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
if (hasTranslationsTable && templateId) {
for (const [language, content] of Object.entries(TRANSLATIONS)) {
await knex('email_template_translations').insert({
template_id: templateId,
language,
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
created_at: new Date(),
updated_at: new Date(),
});
}
}
console.log(' customer_gallery_assigned template inserted with 6 translations');
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('email_templates'))) return;
await knex('email_templates').where({ template_key: 'customer_gallery_assigned' }).del();
};
@@ -0,0 +1,43 @@
/**
* Migration: Per-event opt-in for using the gallery hero photo as the
* Open Graph share image (#474).
*
* Background: galleryOgService already serves OG/Twitter Card meta
* tags to social-crawler User-Agents (WhatsApp, Facebook, Slack,
* Telegram, Discord, etc.) for /gallery/<slug> URLs — see
* frontend/nginx.conf and backend/src/services/galleryOgService.js.
* Today the og:image is always the brand logo, with the inline
* rationale "no protected photo content."
*
* #474 asks for a hero/cover photo preview. The trade-off is that
* the og:image is fetched unauthenticated by every link-preview
* crawler, so any opted-in image is effectively public. We ship
* this as a per-event boolean, default FALSE, so existing galleries
* never start surfacing photos until the admin consciously flips it
* on per gallery.
*
* When set to TRUE and the event has a hero_photo_id with a
* generated thumbnail, galleryOgService points og:image at the new
* /og/gallery/:slug/cover endpoint. With it set to FALSE (or no
* hero photo selected) the brand logo is used as before.
*
* Idempotent: re-runs are no-ops.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('events'))) return;
if (await knex.schema.hasColumn('events', 'og_image_share_enabled')) return;
await knex.schema.alterTable('events', (table) => {
// Default false everywhere so an upgrade never starts leaking the
// hero photo of a password-protected gallery without admin intent.
table.boolean('og_image_share_enabled').notNullable().defaultTo(false);
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('events'))) return;
if (!(await knex.schema.hasColumn('events', 'og_image_share_enabled'))) return;
await knex.schema.alterTable('events', (table) => {
table.dropColumn('og_image_share_enabled');
});
};
@@ -0,0 +1,43 @@
/**
* Migration: Promotional banner text alignment (#482).
*
* Adds `branding_promo_alignment` to app_settings — admin-controlled
* horizontal alignment for the gallery promotional banner content
* (#440 / #482). Reuses the existing app_settings shape that
* branding_promo_markdown / branding_promo_position already use.
*
* Default 'center' so the banner aligns with the gallery footer
* (which is full-width center-aligned). The previous default left
* the markdown left-aligned in a max-w-3xl block, which Rekoo-PS
* reported as visually offset from the footer.
*
* Allowed values: 'left' | 'center' | 'right' — validated on the
* write path in adminSettings.js, not enforced by the column type
* (we use varchar instead of CHECK so the value can be extended
* later — e.g. 'justify' — without another schema migration).
*
* Idempotent: skips the insert when the row already exists.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
const existing = await knex('app_settings')
.where('setting_key', 'branding_promo_alignment')
.first();
if (existing) return;
await knex('app_settings').insert({
setting_key: 'branding_promo_alignment',
setting_value: JSON.stringify('center'),
setting_type: 'branding',
updated_at: new Date(),
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
await knex('app_settings')
.where('setting_key', 'branding_promo_alignment')
.del();
};
@@ -0,0 +1,59 @@
/**
* Migration: Lightbox medium-resolution preview tier (#492).
*
* Adds:
* - photos.preview_path (nullable VARCHAR) — storage key for the
* per-photo preview JPEG; populated lazily by ensurePreviewImage
* on first lightbox open (or eagerly by the regenerate-previews
* admin endpoint). Mirrors photos.thumbnail_path / hero_path.
* - app_settings.lightbox_preview_enabled (boolean, default false)
* — opt-in toggle. Off by default because the new tier costs
* ~200500 KB per photo on disk; admins flip it on once they've
* decided the perf win is worth the storage.
*
* No backfill of existing photos here — preview generation is lazy
* by design and a separate "Regenerate previews" admin button covers
* eager backfill when an admin wants to warm the cache for an
* existing gallery.
*
* Idempotent: every step checks for existing state.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('photos'))) return;
if (!(await knex.schema.hasColumn('photos', 'preview_path'))) {
await knex.schema.alterTable('photos', (table) => {
table.string('preview_path');
});
}
if (!(await knex.schema.hasTable('app_settings'))) return;
const existing = await knex('app_settings')
.where('setting_key', 'lightbox_preview_enabled')
.first();
if (!existing) {
await knex('app_settings').insert({
setting_key: 'lightbox_preview_enabled',
// SQLite stores TEXT, Postgres JSONB — JSON-stringify so both
// backends round-trip a recognisable boolean shape, matching
// how other branding_* boolean settings are stored today.
setting_value: JSON.stringify(false),
setting_type: 'thumbnail',
updated_at: new Date(),
});
}
};
exports.down = async function(knex) {
if (await knex.schema.hasTable('app_settings')) {
await knex('app_settings')
.where('setting_key', 'lightbox_preview_enabled')
.del();
}
if (await knex.schema.hasTable('photos') && await knex.schema.hasColumn('photos', 'preview_path')) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('preview_path');
});
}
};
@@ -0,0 +1,55 @@
/**
* Migration: back-fix the backup_runs indexes that migration 035 tried to
* create on the nonexistent `created_at` column (#484).
*
* On Postgres, 035's `CREATE INDEX ... ON backup_runs(created_at, ...)`
* statements raised `column "created_at" does not exist`, which was caught
* silently by the wrapping try/catch — so the migration "succeeded" but the
* indexes never got created. Fresh installs saw the ERROR in the postgres
* log; existing installs simply ran without those indexes.
*
* 035 has now been corrected to use `started_at` (the column that does
* exist on backup_runs and carries the same chronological semantics).
* This migration creates the same indexes idempotently for any deployment
* whose 035 silently failed — no-op on fresh installs because 035 already
* built them.
*
* SQLite: partial indexes (`WHERE …`) work but cross-table semantics differ
* slightly from Postgres; we still emit them because the only consumer is
* the backup-history query in `backupService` and it issues identical SQL
* across both backends.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('backup_runs'))) return;
if (!(await knex.schema.hasColumn('backup_runs', 'started_at'))) return;
// Plain composite index — matches the corrected statement in 035.
await knex.raw(
'CREATE INDEX IF NOT EXISTS idx_backup_runs_started_mode ON backup_runs(started_at, backup_mode)'
);
// Partial indexes only get created if backup_mode exists (035 added it).
if (!(await knex.schema.hasColumn('backup_runs', 'backup_mode'))) return;
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
ON backup_runs(started_at DESC)
WHERE status = 'completed' AND backup_mode = 'full'
`);
if (await knex.schema.hasColumn('backup_runs', 'parent_backup_id')) {
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
ON backup_runs(parent_backup_id, started_at)
WHERE backup_mode = 'incremental'
`);
}
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('backup_runs'))) return;
await knex.raw('DROP INDEX IF EXISTS idx_backup_runs_started_mode');
await knex.raw('DROP INDEX IF EXISTS idx_backup_runs_recent_successful');
await knex.raw('DROP INDEX IF EXISTS idx_backup_runs_incremental_chain');
};
@@ -0,0 +1,121 @@
/**
* Migration: seed Spanish (es) email-template translations (#510).
*
* Contributed by @AloePacci on issue #510. Covers the four
* customer-facing gallery delivery templates that already had
* en/de/nl/pt/ru rows from migration 075. Templates without an `es`
* row (admin_*, backup_*, restore_*, customer_*, version_update_*)
* continue to fall back to `en` via the resolution chain in
* emailProcessor.processTemplate — no functional gap, just untranslated
* copy until someone fills them in.
*
* Same idempotency pattern as 099_seed_missing_email_template_translations:
* checks (template_id, language) before inserting so re-runs are safe.
*/
const TRANSLATIONS = {
gallery_created: {
es: {
subject: 'Su galería de fotos está lista!',
body_html: `<h2>Galería creada con éxito</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" ha sido creada con éxito!</p>
<p><strong>Detalles de la galería:</strong></p>
<ul>
<li>Fecha del evento: {{event_date}}</li>
<li>Enlace de la galería: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Contraseña: {{gallery_password}}</li>
<li>Expira en: {{expiry_date}}</li>
</ul>
<p>Comparta este enlace y contraseña con sus invitados para que puedan ver y descargar las fotos.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: 'Galería creada con éxito\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha sido creada con éxito!\n\nEnlace de la galería: {{gallery_link}}\nContraseña: {{gallery_password}}\nExpira en: {{expiry_date}}',
},
},
expiration_warning: {
es: {
subject: 'Su galería de fotos expirará pronto',
body_html: `<h2>Galería expirando pronto</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" expirará en {{days_remaining}} días.</p>
<p>Después de la expiración, la galería será archivada y ya no estará accesible para los invitados.</p>
<p><a href="{{gallery_link}}">Visitar galería</a></p>`,
body_text: 'Galería expirando pronto\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" expirará en {{days_remaining}} días.\n\nGalería: {{gallery_link}}',
},
},
gallery_expired: {
es: {
subject: 'Su galería de fotos está caducada',
body_html: `<h2>Galería vencida</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" ha caducado y por tanto ya no es accesible.</p>
<p>Las fotos han sido archivadas. Si necesita acceso, por favor póngase en contacto con el administrador a través de {{admin_email}}.</p>`,
body_text: 'Galería caducada\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha caducado y ya no está accesible.\n\nContacto: {{admin_email}}',
},
},
archive_complete: {
es: {
subject: 'Archivado completado: {{event_name}}',
body_html: `<h2>Archivado completado</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" ha sido archivada con éxito.</p>
<p><strong>Detalles del archivo:</strong></p>
<ul>
<li>Número de fotos: {{photo_count}}</li>
<li>Tamaño del archivo: {{archive_size}}</li>
<li>Fecha del archivado: {{archive_date}}</li>
</ul>`,
body_text: 'Archivado completado\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha sido archivada con éxito.\n\nFotos: {{photo_count}}\nTamaño: {{archive_size}}',
},
},
};
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('email_templates'))) return;
if (!(await knex.schema.hasTable('email_template_translations'))) return;
const rows = await knex('email_templates')
.whereIn('template_key', Object.keys(TRANSLATIONS))
.select('id', 'template_key');
const keyToId = Object.fromEntries(rows.map((r) => [r.template_key, r.id]));
let inserted = 0;
let skipped = 0;
for (const [key, perLocale] of Object.entries(TRANSLATIONS)) {
const templateId = keyToId[key];
if (!templateId) continue;
for (const [language, content] of Object.entries(perLocale)) {
const existing = await knex('email_template_translations')
.where({ template_id: templateId, language })
.first();
if (existing) {
skipped += 1;
continue;
}
await knex('email_template_translations').insert({
template_id: templateId,
language,
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
created_at: new Date(),
updated_at: new Date(),
});
inserted += 1;
}
}
console.log(`106_seed_es_email_template_translations: inserted=${inserted}, skipped=${skipped}`);
};
exports.down = async function(knex) {
// No-op: same rationale as 099. An admin may have hand-edited the
// `es` rows in the Templates UI after this migration ran, and we
// can't tell apart inserted-by-us rows from edited-by-admin rows.
// Rollback by hand if you truly need to drop them.
};
+51 -2
View File
@@ -39,6 +39,47 @@ async function markMigrationAsApplied(filename) {
async function detectExistingSchema() { async function detectExistingSchema() {
console.log('Detecting existing schema...'); console.log('Detecting existing schema...');
// Modern-bootstrap fingerprint check (#530).
//
// A DB with the post-initializeDatabase state (photo_categories +
// cms_pages present, which db.js:initializeDatabase() creates as
// part of the consolidated modern bootstrap) but an empty migrations
// table is a recovery scenario — either restored from a backup that
// lost the migrations table, or someone invoked initializeDatabase()
// outside the migration runner.
//
// Treating this as a regular "existing deployment" runs the legacy
// chain first, which renames email_templates.subject → subject_en
// (legacy/008). Then core/029 fails when it tries to insert email
// templates referencing the pre-rename `subject` column. Fresh
// installs avoid this by running ONLY core migrations (core/059
// handles the rename later, after core/029 has inserted templates).
// Real legacy upgrades avoid it because their migrations table
// already records that legacy/008028 ran historically.
//
// The fix: when the modern bootstrap fingerprint is detected, mark
// every legacy migration as applied. This matches what fresh
// installs do (skip legacy entirely) and keeps the legacy chain
// from operating on a schema state it doesn't expect. Real legacy
// upgrades hit no-op markings here because they already have their
// migrations recorded.
const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories');
const hasCmsPagesTable = await db.schema.hasTable('cms_pages');
if (hasPhotoCategoriesTable && hasCmsPagesTable) {
const legacyDir = path.join(__dirname, 'legacy');
try {
const legacyFiles = await fs.readdir(legacyDir);
const legacyMigrations = legacyFiles.filter((f) => /^\d{3}_.*\.js$/.test(f));
for (const filename of legacyMigrations) {
await markMigrationAsApplied(filename);
}
} catch (err) {
// Non-fatal — only legacy dir absence (very-old test setups)
// would land here. Original table-based markers below still run.
console.log(`Could not enumerate legacy migrations: ${err.message}`);
}
}
const tableChecks = [ const tableChecks = [
{ table: 'events', migration: '001_init.js' }, { table: 'events', migration: '001_init.js' },
{ table: 'photos', migration: '001_init.js' }, { table: 'photos', migration: '001_init.js' },
@@ -126,8 +167,8 @@ async function runMigrations() {
const hasActivityLogsTable = await db.schema.hasTable('activity_logs'); const hasActivityLogsTable = await db.schema.hasTable('activity_logs');
// Get applied migrations // Get applied migrations
const appliedMigrations = await db('migrations').select('filename'); let appliedMigrations = await db('migrations').select('filename');
const appliedFilenames = appliedMigrations.map(m => m.filename); let appliedFilenames = appliedMigrations.map(m => m.filename);
// Check if this is a new deployment // Check if this is a new deployment
// It's new if no essential tables exist OR no migrations have been applied // It's new if no essential tables exist OR no migrations have been applied
@@ -138,6 +179,14 @@ async function runMigrations() {
// Only detect existing schema for truly existing deployments // Only detect existing schema for truly existing deployments
if (!isNewDeployment) { if (!isNewDeployment) {
await detectExistingSchema(); await detectExistingSchema();
// detectExistingSchema may have inserted rows into the migrations
// table (e.g. for 004_add_categories_and_cms.js when photo_categories
// already exists). Re-query so the iteration below sees the up-to-date
// applied set — otherwise the loop attempts those migrations again,
// their tx-internal `insert into migrations` conflicts, and postgres
// logs a "duplicate key" ERROR on every fresh-after-partial install.
appliedMigrations = await db('migrations').select('filename');
appliedFilenames = appliedMigrations.map(m => m.filename);
} }
// Get migration files from appropriate directories // Get migration files from appropriate directories
+20 -66
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "3.43.0", "version": "3.42.2-beta.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "3.43.0", "version": "3.42.2-beta.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0",
@@ -25,7 +25,6 @@
"express-validator": "^7.0.1", "express-validator": "^7.0.1",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.4", "form-data": "^4.0.4",
"handlebars": "^4.7.9",
"helmet": "^7.0.0", "helmet": "^7.0.0",
"i18next": "25.3.2", "i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
@@ -4925,12 +4924,12 @@
} }
}, },
"node_modules/cross-fetch": { "node_modules/cross-fetch": {
"version": "4.0.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"node-fetch": "^2.6.12" "node-fetch": "^2.7.0"
} }
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
@@ -5952,9 +5951,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.4.1", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -6383,27 +6382,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/handlebars": {
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -6622,12 +6600,12 @@
} }
}, },
"node_modules/i18next-http-backend": { "node_modules/i18next-http-backend": {
"version": "3.0.2", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.6.tgz",
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", "integrity": "sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cross-fetch": "4.0.0" "cross-fetch": "4.1.0"
} }
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
@@ -8615,12 +8593,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/node-abi": { "node_modules/node-abi": {
"version": "3.85.0", "version": "3.85.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
@@ -9308,9 +9280,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -9400,9 +9372,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -10374,6 +10346,7 @@
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -11116,19 +11089,6 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/undefsafe": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -11402,12 +11362,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT"
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "3.43.1", "version": "3.55.0-beta.0",
"description": "Backend for PicPeak event photo sharing platform", "description": "Backend for PicPeak event photo sharing platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@@ -31,7 +31,6 @@
"express-validator": "^7.0.1", "express-validator": "^7.0.1",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.4", "form-data": "^4.0.4",
"handlebars": "^4.7.9",
"helmet": "^7.0.0", "helmet": "^7.0.0",
"i18next": "25.3.2", "i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
+58 -2
View File
@@ -161,7 +161,12 @@ const corsOptions = {
callback(null, false); callback(null, false);
} }
}, },
credentials: true credentials: true,
// Expose Content-Disposition so split (cross-origin) frontend
// deployments can read the server's chosen download filename. Used
// by the gallery/admin download flows to honour the #493 "original
// camera filename" toggle on individual photo downloads (#507).
exposedHeaders: ['Content-Disposition'],
}; };
// Only attach CORS to API endpoints, not static assets // Only attach CORS to API endpoints, not static assets
@@ -182,6 +187,10 @@ function composeInlineStyles(payload) {
--brand-accent: ${branding.colors.accent}; --brand-accent: ${branding.colors.accent};
--brand-background: ${branding.colors.background}; --brand-background: ${branding.colors.background};
--brand-text: ${branding.colors.text}; --brand-text: ${branding.colors.text};
--brand-surface: ${branding.colors.surface || '#ffffff'};
--brand-elevated: ${branding.colors.elevated || '#f5f5f5'};
--brand-border: ${branding.colors.border || '#e5e5e5'};
--brand-muted-text: ${branding.colors.mutedText || '#737373'};
}`); }`);
if (payload.baseCss) { if (payload.baseCss) {
@@ -503,8 +512,16 @@ if (process.env.NODE_ENV === 'development') {
// Slack, Facebook, etc.) don't execute JS, so the SPA's client-side meta tags // Slack, Facebook, etc.) don't execute JS, so the SPA's client-side meta tags
// never reach them. nginx routes UA-detected crawlers from /gallery/:slug to // never reach them. nginx routes UA-detected crawlers from /gallery/:slug to
// here; humans still get the SPA via try_files. // here; humans still get the SPA via try_files.
const { isSocialCrawler, handleGalleryOgRequest } = require('./src/services/galleryOgService'); const {
isSocialCrawler,
handleGalleryOgRequest,
handleGalleryOgCover,
} = require('./src/services/galleryOgService');
app.get('/og/gallery/:slug', handleGalleryOgRequest); app.get('/og/gallery/:slug', handleGalleryOgRequest);
// Public hero-photo cover served as og:image when the admin has flipped
// events.og_image_share_enabled (#474). Unauthenticated by design;
// returns 404 unless the opt-in is on AND a hero_photo_id is set.
app.get('/og/gallery/:slug/cover', handleGalleryOgCover);
// robots.txt endpoint (dynamic, served from DB settings) // robots.txt endpoint (dynamic, served from DB settings)
const { generateRobotsTxt } = require('./src/services/robotsTxtService'); const { generateRobotsTxt } = require('./src/services/robotsTxtService');
@@ -555,6 +572,7 @@ app.use('/api/gallery', require('./src/routes/galleryGuests'));
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);
app.use('/api/admin/auth', adminAuthRoutes); app.use('/api/admin/auth', adminAuthRoutes);
app.use('/api/admin/system', require('./src/routes/adminSystem')); app.use('/api/admin/system', require('./src/routes/adminSystem'));
app.use('/api/admin/feature-flags', require('./src/routes/adminFeatureFlags'));
app.use('/api/admin/backup', require('./src/routes/adminBackup')); app.use('/api/admin/backup', require('./src/routes/adminBackup'));
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup')); app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
app.use('/api/admin/feedback', require('./src/routes/adminFeedback')); app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
@@ -567,6 +585,44 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates')); app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename')); app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers')); app.use('/api/admin/users', require('./src/routes/adminUsers'));
// Customer portal (#354). The customerPortal feature flag is a
// VISIBILITY toggle for the admin surface, not a kill switch for
// customer access. Enforcement:
//
// 1. Frontend: RequireFeature guards + AdminSidebar visibility
// hide the Clients section when the flag is off. Customer-side
// /customer/* surfaces stay reachable.
// 2. Backend: NO route-level gate. The admin surface is gated by
// adminAuth + permission checks (admin still has rights to
// manage customer records even if the section is hidden in
// their UI). The customer surface is gated by customerAuth +
// is_active checks on customer_accounts.
//
// For close-to-realtime access changes use the dedicated tools:
// - Revoke a customer's access to ONE gallery → "Manage galleries"
// dialog removes the event_customer_assignments row, which
// verifyGalleryAccess re-checks on every customer-minted JWT.
// - Lock out a customer entirely → "Deactivate" sets is_active=false
// and bumps password_changed_at, killing every outstanding JWT.
// - Toggle per-customer feature surfaces (calendar/quotes/bills)
// → toggles on the customer detail page.
//
// Putting the global flag in the kill-switch role was a mistake — a
// stray click in Settings → Features would lock every paying
// customer out at once. PR-revert moved the gate back to per-record.
//
// `noStoreCache` belt-and-braces the cache-control story for both
// surfaces: any response — 200, 4xx, 5xx — carries `Cache-Control:
// no-store` so a transient error (the now-reverted #458 410, a
// permission flip mid-session, a backend restart) can't get pinned
// in browser or intermediate caches and outlive its cause. See the
// PR #458 → #470 history in the middleware file for context.
const { noStoreCache } = require('./src/middleware/noStoreCache');
app.use('/api/admin/customers', noStoreCache, require('./src/routes/adminCustomers'));
// Customer-side surface (#354). Strictly separate from /api/admin/* —
// distinct token type, distinct cookie, distinct middleware.
app.use('/api/customer/auth', noStoreCache, require('./src/routes/customerAuth'));
app.use('/api/customer', noStoreCache, require('./src/routes/customer'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes')); app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens')); app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks')); app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
@@ -0,0 +1,339 @@
/**
* Unit tests for customerAccountsService (#354).
*
* The service touches the DB in most call sites, so we mock the knex
* builder. The point of these tests is to catch the assignment-diff
* logic and the invitation guards — not to integration-test knex.
*/
// --- mocks --------------------------------------------------------------
jest.mock('../database/db', () => {
const mockDb = jest.fn();
mockDb.transaction = jest.fn(async (fn) => fn(mockDb));
return { db: mockDb, logActivity: jest.fn() };
});
jest.mock('../utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
jest.mock('../services/emailProcessor', () => ({
queueEmail: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../utils/passwordValidation', () => ({
getBcryptRounds: () => 4, // fast for tests
}));
// frontendUrl is resolved against app_settings; mock the helper directly
// so the test doesn't need to also stub the settings query.
jest.mock('../utils/frontendUrl', () => ({
getFrontendBaseUrl: jest.fn().mockResolvedValue('https://example.test'),
}));
jest.mock('../utils/dbCompat', () => ({
formatBoolean: (v) => (v ? 1 : 0),
}));
const { db } = require('../database/db');
const { queueEmail } = require('../services/emailProcessor');
// Helper to make a chainable query builder mock that resolves to `result`.
const chain = (result) => {
const q = {};
['where', 'whereNull', 'whereNot', 'whereRaw', 'whereIn', 'andWhere',
'select', 'leftJoin', 'join', 'orderBy', 'limit', 'groupBy', 'first']
.forEach((m) => { q[m] = jest.fn().mockReturnValue(q); });
q.first = jest.fn().mockResolvedValue(result?.first);
q.del = jest.fn().mockResolvedValue(result?.del ?? 0);
// returning() must itself return a thenable that resolves to the
// configured insert result, since the service awaits it directly.
q.insert = jest.fn().mockImplementation(() => {
const promise = Promise.resolve(result?.insert ?? []);
promise.returning = () => Promise.resolve(result?.insert ?? []);
return promise;
});
q.pluck = jest.fn().mockResolvedValue(result?.pluck ?? []);
q.update = jest.fn().mockResolvedValue(result?.update ?? 0);
q.then = (resolve) => Promise.resolve(result?.rows ?? []).then(resolve);
q.catch = () => q;
return q;
};
beforeEach(() => {
db.mockReset();
queueEmail.mockClear();
db.transaction.mockImplementation(async (fn) => fn(db));
});
// ---- createInvitation --------------------------------------------------
describe('createInvitation', () => {
it('rejects when a customer with the email already exists', async () => {
const svc = require('../services/customerAccountsService');
db.mockImplementationOnce(() => chain({ first: { id: 1, email: '[email protected]' } }));
await expect(
svc.createInvitation({ email: '[email protected]', invitedById: 9 })
).rejects.toThrow(/already exists/i);
expect(queueEmail).not.toHaveBeenCalled();
});
it('rejects when a non-expired pending invitation exists', async () => {
const svc = require('../services/customerAccountsService');
db.mockImplementationOnce(() => chain({ first: null })); // no customer
db.mockImplementationOnce(() => chain({ first: { id: 5, email: '[email protected]' } }));
await expect(
svc.createInvitation({ email: '[email protected]', invitedById: 9 })
).rejects.toThrow(/pending invitation/i);
});
it('queues an invitation email on success', async () => {
const svc = require('../services/customerAccountsService');
db.mockImplementationOnce(() => chain({ first: null })); // no customer
db.mockImplementationOnce(() => chain({ first: null })); // no pending
db.mockImplementationOnce(() => chain({ insert: [{ id: 42 }] })); // insert invitation
const result = await svc.createInvitation({
email: '[email protected]',
invitedById: 9,
});
expect(result.email).toBe('[email protected]');
expect(result.token).toMatch(/^[a-f0-9]{64}$/);
expect(queueEmail).toHaveBeenCalledTimes(1);
const call = queueEmail.mock.calls[0];
expect(call[2]).toBe('customer_invitation');
expect(call[3].invite_link).toMatch(/\/customer\/invite\//);
// Link must honour the configured frontend URL (Site Settings →
// general_site_url, surfaced via getFrontendBaseUrl). Mocked above
// to https://example.test — the dev-day bug was the link always
// emitting localhost regardless of config.
expect(call[3].invite_link.startsWith('https://example.test/')).toBe(true);
});
});
// ---- setAssignmentsForEvent --------------------------------------------
describe('setAssignmentsForEvent', () => {
it('inserts only customers that are missing and removes those not in the wanted list', async () => {
const svc = require('../services/customerAccountsService');
// existing assignments: customers 1 and 2
const existingChain = chain({ rows: [
{ id: 100, customer_account_id: 1 },
{ id: 101, customer_account_id: 2 },
] });
// delete chain
const deleteChain = chain({ del: 1 });
// validity check chain — returns valid ids 3 only (99 is filtered out)
const validityChain = chain({ pluck: [3] });
// insert chain
const insertChain = chain({ insert: [] });
db.mockImplementationOnce(() => existingChain);
db.mockImplementationOnce(() => deleteChain);
db.mockImplementationOnce(() => validityChain);
db.mockImplementationOnce(() => insertChain);
const summary = await svc.setAssignmentsForEvent(42, [2, 3, 99], 7);
// Should remove customer 1 (not in wanted) and only insert valid ones.
expect(deleteChain.whereIn).toHaveBeenCalledWith('id', [100]);
expect(insertChain.insert).toHaveBeenCalledWith([{
event_id: 42,
customer_account_id: 3,
assigned_by_admin_id: 7,
assigned_at: expect.any(Date),
}]);
// `added` counts attempted-additions before the validity filter — so
// 3 and 99 were both attempted (added: 2). The validity filter drops
// 99 silently (logged as a warning) before the insert. This matches
// the service contract; the test is asserting on it explicitly so a
// future refactor can't quietly change it.
expect(summary).toEqual({ added: 2, removed: 1 });
});
it('clears all assignments when wanted list is empty', async () => {
const svc = require('../services/customerAccountsService');
const existingChain = chain({ rows: [
{ id: 100, customer_account_id: 1 },
{ id: 101, customer_account_id: 2 },
] });
const deleteChain = chain({ del: 2 });
db.mockImplementationOnce(() => existingChain);
db.mockImplementationOnce(() => deleteChain);
const summary = await svc.setAssignmentsForEvent(42, [], 7);
expect(deleteChain.whereIn).toHaveBeenCalledWith('id', [100, 101]);
expect(summary).toEqual({ added: 0, removed: 2 });
});
it('is a no-op when wanted equals existing', async () => {
const svc = require('../services/customerAccountsService');
const existingChain = chain({ rows: [
{ id: 100, customer_account_id: 1 },
] });
db.mockImplementationOnce(() => existingChain);
const summary = await svc.setAssignmentsForEvent(42, [1], 7);
// Only the existing-rows query was called; no del or insert chain
// was needed because both diffs are empty.
expect(db).toHaveBeenCalledTimes(1);
expect(summary).toEqual({ added: 0, removed: 0 });
});
});
// ---- customerHasAccessToEvent ------------------------------------------
describe('customerHasAccessToEvent', () => {
it('returns true when an assignment row exists', async () => {
const svc = require('../services/customerAccountsService');
const c = chain({ first: { id: 99 } });
db.mockImplementationOnce(() => c);
const result = await svc.customerHasAccessToEvent(1, 2);
expect(result).toBe(true);
expect(c.where).toHaveBeenCalledWith('customer_account_id', 1);
expect(c.where).toHaveBeenCalledWith('event_id', 2);
});
it('returns false when no assignment row exists', async () => {
const svc = require('../services/customerAccountsService');
db.mockImplementationOnce(() => chain({ first: undefined }));
const result = await svc.customerHasAccessToEvent(1, 999);
expect(result).toBe(false);
});
});
// ---- setAssignmentsForCustomer ----------------------------------------
//
// The inverse of setAssignmentsForEvent: takes one customer + a list of
// event ids and reconciles the junction table. Powers the "Manage
// galleries" dialog on the customer detail page. The
// `verifyGalleryAccess` middleware re-checks this junction on every
// customer-minted JWT, so getting the diff math right here is the
// access-control story for the whole feature (#470).
//
// notifyCustomerOfNewAssignments() runs as fire-and-forget after the
// transactional work and queues a follow-up email. The tests below
// configure mocks for the calls it makes (load customer row, load
// event rows) so it can resolve cleanly without crashing the assert
// path — we don't assert on its body here; the email pipeline is a
// separate seam.
describe('setAssignmentsForCustomer', () => {
// The notifier issues two more db() calls after the writer returns:
// SELECT customer_accounts and SELECT events. Provide cheap chains
// that resolve to "no customer / no events" so it early-returns
// without firing queueEmail. Returns a small helper so each test
// can append it after its own writer chains.
function appendNotifierMocks() {
db.mockImplementationOnce(() => chain({ first: null })); // customer lookup -> not found
db.mockImplementationOnce(() => chain({ rows: [] })); // events lookup -> empty
}
it('inserts only events that are missing and removes those not in the wanted list', async () => {
const svc = require('../services/customerAccountsService');
// existing assignments: customer is on events 10 and 20.
const existingChain = chain({ rows: [
{ id: 500, event_id: 10 },
{ id: 501, event_id: 20 },
] });
const deleteChain = chain({ del: 1 });
// Validity check returns 30 only — event 99 is filtered out
// (archived or missing).
const validityChain = chain({ pluck: [30] });
const insertChain = chain({ insert: [] });
db.mockImplementationOnce(() => existingChain);
db.mockImplementationOnce(() => deleteChain);
db.mockImplementationOnce(() => validityChain);
db.mockImplementationOnce(() => insertChain);
appendNotifierMocks();
const summary = await svc.setAssignmentsForCustomer(7, [20, 30, 99], 12);
// Remove event 10 (not in wanted).
expect(deleteChain.whereIn).toHaveBeenCalledWith('id', [500]);
// Insert ONLY event 30 — event 99 was dropped by the validity filter.
expect(insertChain.insert).toHaveBeenCalledWith([{
event_id: 30,
customer_account_id: 7,
assigned_by_admin_id: 12,
assigned_at: expect.any(Date),
}]);
expect(summary).toEqual({
added: 2, // 30 and 99 were both attempted
removed: 1, // event 10
addedEventIds: [30], // only event 30 actually landed in the DB
});
});
it('silently filters archived/missing event ids out of the insert', async () => {
const svc = require('../services/customerAccountsService');
const existingChain = chain({ rows: [] });
// Three candidates; validity check pulls back zero -> all three are
// archived or missing. Service should log a warning and skip the
// insert entirely (rows.length === 0 short-circuits the .insert call).
const validityChain = chain({ pluck: [] });
db.mockImplementationOnce(() => existingChain);
db.mockImplementationOnce(() => validityChain);
appendNotifierMocks();
const summary = await svc.setAssignmentsForCustomer(7, [99, 100, 101], 12);
expect(summary).toEqual({
added: 3, // attempted three
removed: 0,
addedEventIds: [], // none landed
});
});
it('clears all assignments when wanted list is empty', async () => {
const svc = require('../services/customerAccountsService');
const existingChain = chain({ rows: [
{ id: 500, event_id: 10 },
{ id: 501, event_id: 20 },
] });
const deleteChain = chain({ del: 2 });
db.mockImplementationOnce(() => existingChain);
db.mockImplementationOnce(() => deleteChain);
appendNotifierMocks();
const summary = await svc.setAssignmentsForCustomer(7, [], 12);
expect(deleteChain.whereIn).toHaveBeenCalledWith('id', [500, 501]);
expect(summary).toEqual({ added: 0, removed: 2, addedEventIds: [] });
});
it('is a no-op when wanted equals existing', async () => {
const svc = require('../services/customerAccountsService');
const existingChain = chain({ rows: [
{ id: 500, event_id: 10 },
] });
db.mockImplementationOnce(() => existingChain);
appendNotifierMocks();
const summary = await svc.setAssignmentsForCustomer(7, [10], 12);
expect(summary).toEqual({ added: 0, removed: 0, addedEventIds: [] });
});
it('coerces non-integer / negative event ids out of the wanted set', async () => {
const svc = require('../services/customerAccountsService');
const existingChain = chain({ rows: [] });
const validityChain = chain({ pluck: [10] });
const insertChain = chain({ insert: [] });
db.mockImplementationOnce(() => existingChain);
db.mockImplementationOnce(() => validityChain);
db.mockImplementationOnce(() => insertChain);
appendNotifierMocks();
// 'abc' isn't a number, -5 is negative, 0 is invalid, 10.5 is fractional.
// Only the integer 10 should survive the Number()/Number.isFinite()
// filter. 10.5 coerces to a finite 10.5 (Number.isFinite returns true)
// but the validity check only returns the integer 10 so we end up
// inserting 10. Asserting on the eventual insert payload is the
// cleanest contract.
await svc.setAssignmentsForCustomer(7, [10, 'abc', -5, 0, '10'], 12);
const insertedRows = insertChain.insert.mock.calls[0][0];
const insertedEventIds = insertedRows.map((r) => r.event_id);
expect(insertedEventIds).toEqual([10]);
});
});
@@ -0,0 +1,409 @@
/**
* Unit tests for customerAuth middleware (#354 follow-up).
*
* Mirrors the parity-with-adminAuth invariants the maintainer flagged
* during the PR #403 review:
* - Issuer-claim verify
* - Token revocation lookup
* - Wrong-token-type rejection
* - Missing-customer / inactive-customer rejection
* - password_changed_at invalidation
* - IP drift logged but not rejected
*
* The middleware reaches into the DB, the JWT verifier, the revocation
* cache and the cookie helper — all four are mocked so this stays a
* fast unit test (no postgres, no real JWTs).
*/
// --- mocks --------------------------------------------------------------
jest.mock('../database/db', () => {
const mockDb = jest.fn();
return { db: mockDb, logActivity: jest.fn() };
});
jest.mock('../utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
jest.mock('jsonwebtoken', () => ({
verify: jest.fn(),
}));
jest.mock('../utils/tokenRevocation', () => ({
isTokenRevoked: jest.fn(),
}));
jest.mock('../utils/tokenUtils', () => ({
getCustomerTokenFromRequest: jest.fn(),
}));
jest.mock('../utils/dbCompat', () => ({
formatBoolean: (v) => (v ? 1 : 0),
}));
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
const { customerAuth } = require('../middleware/customerAuth');
// Helper: build a minimal Express-shaped req/res/next trio. The
// middleware reads req.headers, req.cookies, req.ip etc.; res.status()
// returns res so the .json() chain works; next is a jest fn so we can
// assert it was/wasn't called.
function makeRes() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
function makeReq({ token = 'tkn', cookies = {}, headers = {}, originalUrl = '/api/customer/foo', ip = '1.2.3.4' } = {}) {
return { headers: { authorization: undefined, ...headers }, cookies, originalUrl, ip, connection: { remoteAddress: ip } };
}
// Convenience: mock db('customer_accounts').where(...).select(...).first()
// to return the given row. The middleware uses `.where().select().first()`.
function mockCustomerLookup(row) {
const q = {};
q.where = jest.fn().mockReturnValue(q);
q.select = jest.fn().mockReturnValue(q);
q.first = jest.fn().mockResolvedValue(row);
db.mockImplementationOnce(() => q);
return q;
}
beforeEach(() => {
db.mockReset();
jwt.verify.mockReset();
isTokenRevoked.mockReset();
getCustomerTokenFromRequest.mockReset();
logger.info.mockClear();
logger.warn.mockClear();
logger.debug.mockClear();
logger.error.mockClear();
});
// ---- no token ----------------------------------------------------------
describe('customerAuth — no token', () => {
it('returns 401 with NO_TOKEN code when the helper returns null', async () => {
getCustomerTokenFromRequest.mockReturnValue(null);
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await customerAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'NO_TOKEN' }),
);
// Maintainer-flagged: must be debug-level for unauthenticated probes.
// (info-level was the prod-noise bug.)
expect(logger.info).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
});
// ---- JWT verification --------------------------------------------------
describe('customerAuth — JWT verification', () => {
it('returns 401 TOKEN_EXPIRED when the JWT is expired', async () => {
getCustomerTokenFromRequest.mockReturnValue('tkn');
const err = new Error('jwt expired');
err.name = 'TokenExpiredError';
jwt.verify.mockImplementation(() => { throw err; });
const res = makeRes();
const next = jest.fn();
await customerAuth(makeReq(), res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'TOKEN_EXPIRED' }));
});
it('returns 401 JWT_INVALID on any other JWT error', async () => {
getCustomerTokenFromRequest.mockReturnValue('tkn');
const err = new Error('invalid signature');
err.name = 'JsonWebTokenError';
jwt.verify.mockImplementation(() => { throw err; });
const res = makeRes();
const next = jest.fn();
await customerAuth(makeReq(), res, next);
expect(next).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'JWT_INVALID' }));
});
it('passes the issuer claim to jwt.verify', async () => {
getCustomerTokenFromRequest.mockReturnValue('tkn');
// Make jwt.verify succeed with a customer payload so the test gets
// past the verify step; we only care that the call shape is right.
jwt.verify.mockReturnValue({
payload: { type: 'customer', customerId: 1, iat: 1000 },
});
isTokenRevoked.mockResolvedValue(false);
mockCustomerLookup({
id: 1, email: '[email protected]', display_name: null,
first_name: null, last_name: null,
password_changed_at: null, preferred_language: 'en',
});
await customerAuth(makeReq(), makeRes(), jest.fn());
expect(jwt.verify).toHaveBeenCalledWith(
'tkn',
expect.anything(),
expect.objectContaining({ issuer: 'picpeak-auth', complete: true }),
);
});
});
// ---- revocation --------------------------------------------------------
describe('customerAuth — revocation', () => {
it('rejects revoked tokens with TOKEN_REVOKED', async () => {
getCustomerTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
payload: { type: 'customer', customerId: 1, iat: 1000 },
});
isTokenRevoked.mockResolvedValue(true);
const res = makeRes();
const next = jest.fn();
await customerAuth(makeReq(), res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'TOKEN_REVOKED' }));
});
});
// ---- wrong token type --------------------------------------------------
describe('customerAuth — token type', () => {
it('rejects an admin token with WRONG_TOKEN_TYPE', async () => {
getCustomerTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
payload: { type: 'admin', id: 99, iat: 1000 },
});
isTokenRevoked.mockResolvedValue(false);
const res = makeRes();
const next = jest.fn();
await customerAuth(makeReq(), res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'WRONG_TOKEN_TYPE' }));
});
it('rejects a gallery token with WRONG_TOKEN_TYPE', async () => {
getCustomerTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
payload: { type: 'gallery', eventId: 1, iat: 1000 },
});
isTokenRevoked.mockResolvedValue(false);
const res = makeRes();
const next = jest.fn();
await customerAuth(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'WRONG_TOKEN_TYPE' }));
});
});
// ---- customer existence + active check ---------------------------------
describe('customerAuth — customer lookup', () => {
it('rejects when the customer row is missing', async () => {
getCustomerTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
payload: { type: 'customer', customerId: 1, iat: 1000 },
});
isTokenRevoked.mockResolvedValue(false);
mockCustomerLookup(null); // not found
const res = makeRes();
const next = jest.fn();
await customerAuth(makeReq(), res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'CUSTOMER_NOT_FOUND' }));
});
it('rejects when the customer is inactive (the where-clause filters them out)', async () => {
// Active filter is part of the query (.where({ ..., is_active: true })),
// so an inactive customer surfaces as a missing row — same code path
// as CUSTOMER_NOT_FOUND. This test guards the active filter itself
// by asserting the where call shape.
getCustomerTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
payload: { type: 'customer', customerId: 1, iat: 1000 },
});
isTokenRevoked.mockResolvedValue(false);
const q = mockCustomerLookup(null);
await customerAuth(makeReq(), makeRes(), jest.fn());
expect(q.where).toHaveBeenCalledWith(
expect.objectContaining({ id: 1, is_active: 1 }),
);
});
});
// ---- password_changed_at invalidation ----------------------------------
describe('customerAuth — password_changed_at', () => {
it('rejects tokens issued before password_changed_at with PASSWORD_CHANGED', async () => {
getCustomerTokenFromRequest.mockReturnValue('tkn');
// Token issued at unix 1000; password changed at 2000.
jwt.verify.mockReturnValue({
payload: { type: 'customer', customerId: 1, iat: 1000 },
});
isTokenRevoked.mockResolvedValue(false);
mockCustomerLookup({
id: 1, email: '[email protected]', display_name: null,
first_name: null, last_name: null,
password_changed_at: new Date(2000 * 1000), // unix 2000
preferred_language: 'en',
});
const res = makeRes();
const next = jest.fn();
await customerAuth(makeReq(), res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'PASSWORD_CHANGED' }));
});
it('accepts tokens issued at exactly password_changed_at', async () => {
// Boundary: iat === passwordChangedSeconds → the strict-less-than
// check should NOT reject. Token is still valid in this edge case.
getCustomerTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
payload: { type: 'customer', customerId: 1, iat: 2000 },
});
isTokenRevoked.mockResolvedValue(false);
mockCustomerLookup({
id: 1, email: '[email protected]', display_name: null,
first_name: null, last_name: null,
password_changed_at: new Date(2000 * 1000),
preferred_language: 'en',
});
const next = jest.fn();
await customerAuth(makeReq(), makeRes(), next);
expect(next).toHaveBeenCalled();
});
it('accepts tokens when password_changed_at is null', async () => {
getCustomerTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
payload: { type: 'customer', customerId: 1, iat: 1000 },
});
isTokenRevoked.mockResolvedValue(false);
mockCustomerLookup({
id: 1, email: '[email protected]', display_name: null,
first_name: null, last_name: null,
password_changed_at: null,
preferred_language: 'en',
});
const next = jest.fn();
await customerAuth(makeReq(), makeRes(), next);
expect(next).toHaveBeenCalled();
});
});
// ---- IP drift ----------------------------------------------------------
describe('customerAuth — IP drift', () => {
it('logs but does not reject when token IP differs from request IP', async () => {
// Mirrors adminAuth: customers may roam between mobile networks
// mid-session, so IP drift is a log-and-continue, not a denial.
getCustomerTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
payload: { type: 'customer', customerId: 1, iat: 1000, ip: '8.8.8.8' },
});
isTokenRevoked.mockResolvedValue(false);
mockCustomerLookup({
id: 1, email: '[email protected]', display_name: null,
first_name: null, last_name: null,
password_changed_at: null,
preferred_language: 'en',
});
const next = jest.fn();
await customerAuth(makeReq({ ip: '4.4.4.4' }), makeRes(), next);
expect(next).toHaveBeenCalled();
// The drift line uses logger.info on adminAuth and customerAuth;
// we don't assert level here, just that something was logged.
expect(logger.info).toHaveBeenCalled();
});
});
// ---- happy path --------------------------------------------------------
describe('customerAuth — happy path', () => {
it('attaches req.customer and calls next() on a valid token', async () => {
getCustomerTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
payload: { type: 'customer', customerId: 7, iat: 1000 },
});
isTokenRevoked.mockResolvedValue(false);
mockCustomerLookup({
id: 7,
email: '[email protected]',
display_name: 'Charlie',
first_name: 'Charlie',
last_name: 'Customer',
password_changed_at: null,
preferred_language: 'de',
});
const req = makeReq();
const next = jest.fn();
await customerAuth(req, makeRes(), next);
expect(next).toHaveBeenCalled();
expect(req.customer).toEqual({
id: 7,
email: '[email protected]',
displayName: 'Charlie',
firstName: 'Charlie',
lastName: 'Customer',
preferredLanguage: 'de',
});
expect(req.token).toBe('tkn');
});
it('defaults preferredLanguage to en when the column is null', async () => {
getCustomerTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
payload: { type: 'customer', customerId: 7, iat: 1000 },
});
isTokenRevoked.mockResolvedValue(false);
mockCustomerLookup({
id: 7, email: '[email protected]',
display_name: null, first_name: null, last_name: null,
password_changed_at: null,
preferred_language: null,
});
const req = makeReq();
await customerAuth(req, makeRes(), jest.fn());
expect(req.customer.preferredLanguage).toBe('en');
});
});
@@ -0,0 +1,289 @@
/**
* Unit tests for the per-event social-share preview opt-in (#474).
*
* Pins three contracts on `buildOgMetadata`:
* - opt-in OFF (or missing) → og:image is the brand logo
* - opt-in ON without a hero photo → og:image is the brand logo
* - opt-in ON + hero + thumbnail → og:image is the public
* /og/gallery/<slug>/cover URL
*
* Plus the `handleGalleryOgCover` 404 path so we can't accidentally
* widen the unauthenticated cover endpoint to expose a hero photo
* the admin hasn't opted into sharing.
*/
jest.mock('../database/db', () => {
const mockDb = jest.fn();
return { db: mockDb };
});
jest.mock('../utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
jest.mock('../services/imageProcessor', () => ({
ensureThumbnail: jest.fn(),
}));
jest.mock('../services/storage', () => ({
getStorage: jest.fn(),
}));
const { db } = require('../database/db');
const { ensureThumbnail } = require('../services/imageProcessor');
const { getStorage } = require('../services/storage');
const {
buildOgMetadata,
handleGalleryOgCover,
isSocialCrawler,
} = require('../services/galleryOgService');
// The service hits two tables in sequence:
// 1. events (slug lookup → may then hit event_slug_redirects)
// 2. app_settings (branding lookup)
// then optionally a third query when og_image_share_enabled is true:
// 3. photos (validate hero exists + has thumbnail)
//
// Each test queues responses on the shared mock in the order the
// service calls them.
function chain(result) {
const q = {};
['where', 'whereIn', 'andWhere', 'select', 'orderBy', 'limit', 'first']
.forEach((m) => { q[m] = jest.fn().mockReturnValue(q); });
q.first = jest.fn().mockResolvedValue(result?.first);
q.then = (resolve) => Promise.resolve(result?.rows ?? []).then(resolve);
q.catch = () => q;
return q;
}
function mockResolveSlug(event) {
// events table query → return event row (or null + no redirects).
db.mockImplementationOnce(() => chain({ first: event || null }));
if (!event) {
// event_slug_redirects fallback — unused here, return null.
db.schema = db.schema || {};
db.schema.hasTable = jest.fn().mockResolvedValue(false);
}
}
function mockBranding() {
// app_settings → fetchBranding rows. Empty = pure defaults.
db.mockImplementationOnce(() => chain({ rows: [] }));
}
function mockHeroPhoto(photo) {
db.mockImplementationOnce(() => chain({ first: photo }));
}
beforeEach(() => {
db.mockReset();
ensureThumbnail.mockReset();
getStorage.mockReset();
process.env.FRONTEND_URL = 'https://gallery.example.com';
});
// ---- buildOgMetadata: cover-vs-logo decision ---------------------------
describe('buildOgMetadata — share-image opt-in', () => {
it('uses the brand logo when og_image_share_enabled is false (default)', async () => {
mockResolveSlug({
id: 1,
slug: 'wedding-2026',
event_name: 'Wedding 2026',
event_date: '2026-06-12',
welcome_message: null,
hero_photo_id: 99, // hero IS picked
og_image_share_enabled: false, // ...but opt-in is off
});
mockBranding();
const meta = await buildOgMetadata('wedding-2026', '/gallery/wedding-2026');
// Falls back to the default logo URL — the picpeak-logo asset
// since branding has no logo configured.
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
// Confirm the photos table was NOT queried — opt-in off means no
// hero lookup at all.
expect(db).toHaveBeenCalledTimes(2); // events + app_settings only
});
it('uses the brand logo when opt-in is on but no hero photo is picked', async () => {
mockResolveSlug({
id: 2,
slug: 'engagement',
event_name: 'Engagement',
event_date: null,
welcome_message: null,
hero_photo_id: null, // no hero
og_image_share_enabled: true, // opt-in IS on
});
mockBranding();
const meta = await buildOgMetadata('engagement', '/gallery/engagement');
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
// photos table NOT queried — service short-circuits when hero_photo_id
// is falsy, even with opt-in on.
expect(db).toHaveBeenCalledTimes(2);
});
it('uses the cover URL when opt-in is on AND hero exists with a thumbnail', async () => {
mockResolveSlug({
id: 3,
slug: 'birthday-2026',
event_name: 'Birthday 2026',
event_date: '2026-04-15',
welcome_message: null,
hero_photo_id: 42,
og_image_share_enabled: true,
});
mockBranding();
mockHeroPhoto({
id: 42,
thumbnail_path: 'thumbnails/thumb_birthday_42.jpg',
});
const meta = await buildOgMetadata('birthday-2026', '/gallery/birthday-2026');
expect(meta.image).toBe('https://gallery.example.com/og/gallery/birthday-2026/cover');
});
it('falls back to the brand logo if the hero photo row is missing', async () => {
// Defensive: hero_photo_id points to a photo that no longer
// exists (e.g. deleted after admin enabled the toggle). The OG
// page must still render with the logo, never a broken image
// src in WhatsApp previews.
mockResolveSlug({
id: 4,
slug: 'orphan',
event_name: 'Orphan',
hero_photo_id: 999,
og_image_share_enabled: true,
});
mockBranding();
mockHeroPhoto(null); // photo deleted
const meta = await buildOgMetadata('orphan', '/gallery/orphan');
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
});
it('falls back to the brand logo if the hero photo has no thumbnail yet', async () => {
// The hero exists but the background processor hasn't generated
// its thumbnail yet (or the regenerate failed). Same fallback.
mockResolveSlug({
id: 5,
slug: 'just-uploaded',
event_name: 'Just Uploaded',
hero_photo_id: 7,
og_image_share_enabled: true,
});
mockBranding();
mockHeroPhoto({ id: 7, thumbnail_path: null });
const meta = await buildOgMetadata('just-uploaded', '/gallery/just-uploaded');
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
});
});
// ---- handleGalleryOgCover: unauthenticated 404 contract ----------------
function makeRes() {
const res = { headers: {} };
res.status = jest.fn().mockReturnValue(res);
res.type = jest.fn().mockReturnValue(res);
res.send = jest.fn().mockReturnValue(res);
res.set = jest.fn((kv) => { Object.assign(res.headers, kv); return res; });
res.setHeader = jest.fn((k, v) => { res.headers[k] = v; });
res.end = jest.fn().mockReturnValue(res);
return res;
}
describe('handleGalleryOgCover — 404 unless explicitly opted in', () => {
it('returns 400 on an invalid slug shape', async () => {
const req = { params: { slug: '../../etc/passwd' }, headers: {} };
const res = makeRes();
await handleGalleryOgCover(req, res);
expect(res.status).toHaveBeenCalledWith(400);
});
it('returns 404 when the event has og_image_share_enabled = false', async () => {
mockResolveSlug({
id: 1,
slug: 'wedding-2026',
hero_photo_id: 99,
og_image_share_enabled: false,
});
const req = { params: { slug: 'wedding-2026' }, headers: {} };
const res = makeRes();
await handleGalleryOgCover(req, res);
expect(res.status).toHaveBeenCalledWith(404);
// Crucial: ensureThumbnail must NOT be called — we never want to
// touch the storage backend for a non-opted-in gallery.
expect(ensureThumbnail).not.toHaveBeenCalled();
});
it('returns 404 when the event opts in but has no hero_photo_id', async () => {
mockResolveSlug({
id: 2,
slug: 'engagement',
hero_photo_id: null,
og_image_share_enabled: true,
});
const req = { params: { slug: 'engagement' }, headers: {} };
const res = makeRes();
await handleGalleryOgCover(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(ensureThumbnail).not.toHaveBeenCalled();
});
});
// Regression for #521 — WhatsApp Business API + 3rd-party preview
// services use UAs that aren't "WhatsApp/X.Y.Z". If isSocialCrawler
// misses them, those requests fall through to the static SPA shell
// and the link preview ends up unbranded.
describe('isSocialCrawler — extended bot coverage (#521)', () => {
it('matches every UA the README/changelog claims to support', () => {
// Pin the contract: each listed UA must hit the crawler path so the
// nginx rewrite + backend OG handler stay in sync. Adding a new UA
// here without also adding it to nginx.conf would silently regress.
const knownBots = [
// Main WhatsApp app
'WhatsApp/2.23.20.0',
// WhatsApp Business / Cloud API variants
'WhatsAppBot/1.0',
'wa-bot/2.0',
// Other messaging app crawlers
'facebookexternalhit/1.1',
'Twitterbot/1.0',
'Slackbot-LinkExpanding 1.0',
'TelegramBot (like TwitterBot)',
// 3rd-party preview services used by business-messaging stacks
'LinkPreview/1.0',
'Slack-ImgProxy/1.0',
];
for (const ua of knownBots) {
expect(isSocialCrawler(ua)).toBe(true);
}
});
it('does not match a regular browser UA', () => {
const browsers = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15',
// Browser UA that happens to contain "Mobile" — guard against an
// over-broad regex landing on it.
'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 Chrome/120.0 Mobile Safari/537.36',
];
for (const ua of browsers) {
expect(isSocialCrawler(ua)).toBe(false);
}
});
it('returns false for null/empty/undefined UAs', () => {
expect(isSocialCrawler(null)).toBe(false);
expect(isSocialCrawler(undefined)).toBe(false);
expect(isSocialCrawler('')).toBe(false);
});
});
@@ -0,0 +1,48 @@
/**
* Unit test for the noStoreCache middleware (#470 follow-up).
*
* The middleware exists because of a real production-class bug: when
* #458 mounted a 410-returning kill-switch in front of customer
* endpoints, browsers cached the 410 (no Cache-Control was set) and
* kept serving it after #470 reverted the middleware. This test pins
* the contract so a future cleanup pass doesn't quietly drop the
* header set and re-introduce the bug.
*/
const { noStoreCache } = require('../middleware/noStoreCache');
function makeRes() {
const headers = {};
return {
setHeader: (k, v) => { headers[k] = v; },
headers,
};
}
describe('noStoreCache middleware', () => {
it('sets Cache-Control: no-store + private and calls next()', () => {
const res = makeRes();
const next = jest.fn();
noStoreCache({}, res, next);
expect(res.headers['Cache-Control']).toBe(
'no-store, no-cache, must-revalidate, private',
);
// HTTP/1.0 fallbacks — old proxies in front of customer-facing
// surfaces (corporate VPN gateways, legacy CDNs) honour these.
expect(res.headers.Pragma).toBe('no-cache');
expect(res.headers.Expires).toBe('0');
expect(next).toHaveBeenCalledTimes(1);
});
it('runs as middleware regardless of response status', () => {
// The header must land on EVERY response coming from the route
// group — including 4xx/5xx — so a stale 410 from a
// now-reverted middleware can't get pinned in browser cache like
// it did in the #458 → #470 sequence.
const res = makeRes();
noStoreCache({}, res, () => {});
expect(res.headers['Cache-Control']).toContain('no-store');
});
});
@@ -159,4 +159,33 @@ describe('publicSiteService', () => {
expect(payload.branding.colors.accentDark).toBe('#5C8762'); expect(payload.branding.colors.accentDark).toBe('#5C8762');
}); });
it('exposes surface tokens and uses theme-aware public site CSS', async () => {
const publicSiteRows = buildPublicSiteRows({});
const brandingRows = buildBrandingRows({
themeConfig: {
primaryColor: '#014E4E',
accentColor: '#017C7C',
backgroundColor: '#0D0D0D',
surfaceColor: '#111414',
elevatedColor: '#182222',
surfaceBorderColor: '#1E2E2E',
textColor: '#EBEBEB',
mutedTextColor: '#B6C2C2'
}
});
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.branding.colors.surface).toBe('#111414');
expect(payload.branding.colors.elevated).toBe('#182222');
expect(payload.branding.colors.border).toBe('#1E2E2E');
expect(payload.branding.colors.mutedText).toBe('#B6C2C2');
expect(payload.baseCss).toContain('var(--brand-surface');
expect(payload.baseCss).toContain('var(--brand-muted-text');
});
}); });
@@ -0,0 +1,220 @@
/**
* Unit tests for verifyGalleryAccess's customer-assignment re-check
* (PR #470). Documents the contract:
*
* - JWT with `via === 'customer'` and a `customerId` claim →
* middleware re-reads event_customer_assignments and 403s with
* code 'CUSTOMER_ASSIGNMENT_REVOKED' when the row is gone.
* - JWT without those claims (the per-event-password flow) → no
* re-check, no extra query, no perf cost. This is asserted
* explicitly because a regression that silently re-checks every
* gallery token would 403 every guest the moment a customer was
* unassigned from any unrelated event.
* - Re-check is wrapped in withRetry so transient DB blips don't
* bounce a legitimate session.
*
* Same pattern as authSession.symmetry.test.js — every collaborator
* mocked so the test stays a fast unit test (no postgres, no real JWTs).
*/
jest.mock('../database/db', () => {
const mockDb = jest.fn();
// The middleware uses `withRetry(fn)` to wrap reads. For the test
// surface we just want to invoke the callback synchronously and
// surface whatever it returns / throws.
const withRetry = jest.fn((fn) => fn());
return { db: mockDb, withRetry };
});
jest.mock('../utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
jest.mock('jsonwebtoken', () => ({
verify: jest.fn(),
}));
jest.mock('../utils/tokenUtils', () => ({
getGalleryTokenFromRequest: jest.fn(),
}));
jest.mock('../utils/dbCompat', () => ({
formatBoolean: (v) => (v ? 1 : 0),
}));
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const { verifyGalleryAccess } = require('../middleware/gallery');
function makeRes() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
function makeReq(slug = 'test-event') {
return {
params: { slug },
headers: {},
cookies: {},
query: {},
ip: '1.2.3.4',
get: () => 'jest',
connection: { remoteAddress: '1.2.3.4' },
};
}
// The middleware queries the `events` table first (existence check),
// then optionally `event_customer_assignments`. This helper queues
// both responses on the shared db mock so each test can spell out
// the scenario in order. Returns the assignments chain so the test
// can assert against it.
function mockEventAndAssignment({ event, assignment }) {
// `db('events').where({...}).select('*').first()` — chainable.
const eventsChain = {};
eventsChain.where = jest.fn().mockReturnValue(eventsChain);
eventsChain.select = jest.fn().mockReturnValue(eventsChain);
eventsChain.first = jest.fn().mockResolvedValue(event);
// `db('event_customer_assignments').where({...}).first()`.
const assignChain = {};
assignChain.where = jest.fn().mockReturnValue(assignChain);
assignChain.first = jest.fn().mockResolvedValue(assignment);
db.mockImplementationOnce(() => eventsChain)
.mockImplementationOnce(() => assignChain);
return { eventsChain, assignChain };
}
beforeEach(() => {
db.mockReset();
jwt.verify.mockReset();
getGalleryTokenFromRequest.mockReset();
});
// ---- customer-minted JWT, assignment intact ----------------------------
describe('verifyGalleryAccess — customer-minted JWT with active assignment', () => {
it('allows access when the event_customer_assignments row exists', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
eventId: 42,
via: 'customer',
customerId: 7,
});
const { assignChain } = mockEventAndAssignment({
event: { id: 42, slug: 'test-event', is_active: true, is_archived: false },
assignment: { id: 999, event_id: 42, customer_account_id: 7 },
});
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await verifyGalleryAccess(req, res, next);
expect(assignChain.where).toHaveBeenCalledWith({
event_id: 42,
customer_account_id: 7,
});
expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
expect(req.event).toEqual(expect.objectContaining({ id: 42 }));
});
});
// ---- customer-minted JWT, assignment revoked ---------------------------
describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
eventId: 42,
via: 'customer',
customerId: 7,
});
mockEventAndAssignment({
event: { id: 42, slug: 'test-event', is_active: true, is_archived: false },
assignment: undefined, // <-- the admin just removed it
});
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await verifyGalleryAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'CUSTOMER_ASSIGNMENT_REVOKED' }),
);
});
it('still re-checks when the customerId is in the token but via claim is missing-but-numeric', async () => {
// Belt-and-braces: the gate triggers on `via === 'customer'`. A
// token with customerId but no `via` should NOT re-check (it isn't
// a customer-minted token — could be legacy). This pins the
// contract so a future refactor can't accidentally widen the gate
// and start 403'ing per-event-password sessions.
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
eventId: 42,
customerId: 7,
// intentionally no `via` claim
});
// The middleware uses one db() call for events; if it tried to
// re-check we'd see a second db() call and the test would throw
// (no more mock implementations queued).
const eventsChain = {};
eventsChain.where = jest.fn().mockReturnValue(eventsChain);
eventsChain.select = jest.fn().mockReturnValue(eventsChain);
eventsChain.first = jest.fn().mockResolvedValue({
id: 42, slug: 'test-event', is_active: true, is_archived: false,
});
db.mockImplementationOnce(() => eventsChain);
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await verifyGalleryAccess(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(db).toHaveBeenCalledTimes(1); // events table only — no assignments query
});
});
// ---- per-event-password JWT (no `via` claim) ---------------------------
describe('verifyGalleryAccess — per-event-password JWT', () => {
it('does NOT touch event_customer_assignments and passes through', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
eventId: 42,
// No via, no customerId — this is the legacy per-event-password
// flow where every guest mints their own JWT after entering the
// gallery password.
});
// Only events table should be queried. If the middleware regresses
// and starts querying event_customer_assignments here, the second
// db() call would have no mock implementation and the test would
// surface an error.
const eventsChain = {};
eventsChain.where = jest.fn().mockReturnValue(eventsChain);
eventsChain.select = jest.fn().mockReturnValue(eventsChain);
eventsChain.first = jest.fn().mockResolvedValue({
id: 42, slug: 'test-event', is_active: true, is_archived: false,
});
db.mockImplementationOnce(() => eventsChain);
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await verifyGalleryAccess(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(db).toHaveBeenCalledTimes(1);
expect(db.mock.calls[0][0]).toBe('events');
});
});
+29 -29
View File
@@ -165,7 +165,7 @@ const DEFAULT_PUBLIC_SITE_CSS = `
body { body {
margin: 0; margin: 0;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(180deg, var(--brand-background), #ffffff 55%); background: linear-gradient(180deg, var(--brand-background), var(--brand-surface, #ffffff) 55%);
color: var(--brand-text); color: var(--brand-text);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -184,16 +184,16 @@ img {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.03), transparent 65%); background: linear-gradient(180deg, var(--brand-elevated, rgba(15, 23, 42, 0.03)), transparent 65%);
} }
.site-header { .site-header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 30; z-index: 30;
background: rgba(255, 255, 255, 0.92); background: var(--brand-surface, rgba(255, 255, 255, 0.92));
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(15, 23, 42, 0.08); border-bottom: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
} }
.header-inner { .header-inner {
@@ -238,14 +238,14 @@ img {
.brand-tagline { .brand-tagline {
margin: 0; margin: 0;
font-size: 0.85rem; font-size: 0.85rem;
color: rgba(15, 23, 42, 0.65); color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
} }
.site-nav { .site-nav {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
font-size: 0.95rem; font-size: 0.95rem;
color: rgba(15, 23, 42, 0.65); color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
} }
.site-nav a { .site-nav a {
@@ -334,7 +334,7 @@ img {
.hero__lead { .hero__lead {
margin: 0; margin: 0;
max-width: 32rem; max-width: 32rem;
color: rgba(15, 23, 42, 0.72); color: var(--brand-muted-text, rgba(15, 23, 42, 0.72));
font-size: 1.05rem; font-size: 1.05rem;
line-height: 1.6; line-height: 1.6;
} }
@@ -360,7 +360,7 @@ img {
.hero__stats dd { .hero__stats dd {
margin: 0.35rem 0 0; margin: 0.35rem 0 0;
color: rgba(15, 23, 42, 0.6); color: var(--brand-muted-text, rgba(15, 23, 42, 0.6));
font-size: 0.95rem; font-size: 0.95rem;
} }
@@ -372,9 +372,9 @@ img {
.deck { .deck {
border-radius: 20px; border-radius: 20px;
padding: 1.75rem; padding: 1.75rem;
background: #fff; background: var(--brand-surface, #fff);
box-shadow: 0 35px 60px -35px rgba(15, 23, 42, 0.35); box-shadow: 0 35px 60px -35px rgba(15, 23, 42, 0.35);
border: 1px solid rgba(15, 23, 42, 0.08); border: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
display: grid; display: grid;
gap: 1.35rem; gap: 1.35rem;
} }
@@ -384,7 +384,7 @@ img {
} }
.deck--secondary { .deck--secondary {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(15, 23, 42, 0.03)); background: linear-gradient(135deg, var(--brand-surface, #fff), var(--brand-elevated, rgba(15, 23, 42, 0.03)));
} }
.deck__header { .deck__header {
@@ -411,14 +411,14 @@ img {
padding-left: 1.1rem; padding-left: 1.1rem;
display: grid; display: grid;
gap: 0.65rem; gap: 0.65rem;
color: rgba(15, 23, 42, 0.68); color: var(--brand-muted-text, rgba(15, 23, 42, 0.68));
} }
.deck__quote { .deck__quote {
margin: 0; margin: 0;
font-size: 1.05rem; font-size: 1.05rem;
line-height: 1.7; line-height: 1.7;
color: rgba(15, 23, 42, 0.78); color: var(--brand-text, rgba(15, 23, 42, 0.78));
} }
.deck__author { .deck__author {
@@ -458,7 +458,7 @@ img {
.section-head p { .section-head p {
margin: 0; margin: 0;
color: rgba(15, 23, 42, 0.65); color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
} }
.feature-grid { .feature-grid {
@@ -469,10 +469,10 @@ img {
} }
.feature-grid article { .feature-grid article {
background: rgba(255, 255, 255, 0.9); background: var(--brand-surface, rgba(255, 255, 255, 0.9));
border-radius: 16px; border-radius: 16px;
padding: 1.75rem; padding: 1.75rem;
border: 1px solid rgba(15, 23, 42, 0.08); border: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
box-shadow: 0 18px 40px -30px rgba(15, 23, 42, 0.28); box-shadow: 0 18px 40px -30px rgba(15, 23, 42, 0.28);
} }
@@ -504,17 +504,17 @@ img {
.workflow__steps p { .workflow__steps p {
margin: 0; margin: 0;
color: rgba(15, 23, 42, 0.65); color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
} }
.workflow__browser { .workflow__browser {
margin: 0; margin: 0;
background: rgba(15, 23, 42, 0.05); background: var(--brand-elevated, rgba(15, 23, 42, 0.05));
border-radius: 20px; border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.1); border: 1px solid var(--brand-border, rgba(15, 23, 42, 0.1));
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
color: rgba(15, 23, 42, 0.55); color: var(--brand-muted-text, rgba(15, 23, 42, 0.55));
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -526,9 +526,9 @@ img {
} }
.collection-showcase article { .collection-showcase article {
background: rgba(255, 255, 255, 0.92); background: var(--brand-surface, rgba(255, 255, 255, 0.92));
border-radius: 18px; border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08); border: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 18px 45px -32px rgba(15, 23, 42, 0.3); box-shadow: 0 18px 45px -32px rgba(15, 23, 42, 0.3);
} }
@@ -543,9 +543,9 @@ img {
.story-grid figure { .story-grid figure {
margin: 0; margin: 0;
padding: 1.75rem; padding: 1.75rem;
background: rgba(255, 255, 255, 0.95); background: var(--brand-surface, rgba(255, 255, 255, 0.95));
border-radius: 20px; border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.08); border: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
box-shadow: 0 18px 42px -32px rgba(15, 23, 42, 0.28); box-shadow: 0 18px 42px -32px rgba(15, 23, 42, 0.28);
} }
@@ -553,12 +553,12 @@ img {
margin: 0 0 1.2rem; margin: 0 0 1.2rem;
font-size: 1.05rem; font-size: 1.05rem;
line-height: 1.7; line-height: 1.7;
color: rgba(15, 23, 42, 0.8); color: var(--brand-text, rgba(15, 23, 42, 0.8));
} }
.story-grid figcaption { .story-grid figcaption {
font-weight: 600; font-weight: 600;
color: rgba(15, 23, 42, 0.7); color: var(--brand-muted-text, rgba(15, 23, 42, 0.7));
} }
.cta { .cta {
@@ -625,8 +625,8 @@ img {
.site-footer { .site-footer {
padding: 3rem 1.5rem; padding: 3rem 1.5rem;
background: rgba(15, 23, 42, 0.05); background: var(--brand-elevated, rgba(15, 23, 42, 0.05));
border-top: 1px solid rgba(15, 23, 42, 0.08); border-top: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
} }
.footer-inner { .footer-inner {
@@ -644,7 +644,7 @@ img {
.footer-inner p { .footer-inner p {
margin: 0; margin: 0;
color: rgba(15, 23, 42, 0.65); color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
line-height: 1.6; line-height: 1.6;
} }
+43 -3
View File
@@ -86,7 +86,16 @@ async function initializeDatabase() {
table.boolean('disable_right_click').defaultTo(false); table.boolean('disable_right_click').defaultTo(false);
table.boolean('watermark_downloads').defaultTo(false); table.boolean('watermark_downloads').defaultTo(false);
table.text('watermark_text'); table.text('watermark_text');
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL'); // events.hero_photo_id → photos.id is a forward reference (the
// photos table is created later in this same function). Postgres
// rejects FK declarations that reference a non-existent table at
// CREATE TABLE time, so the constraint is added below as an
// ALTER TABLE *after* the photos table exists. SQLite previously
// tolerated the inline declaration because its FK enforcement is
// lazy — the inline form silently became a column with no FK
// metadata. Both backends now go through the same code path.
// (#484, MrGabri's reproduction.)
table.integer('hero_photo_id');
table.boolean('require_password').defaultTo(true); table.boolean('require_password').defaultTo(true);
}); });
} else { } else {
@@ -213,6 +222,25 @@ async function initializeDatabase() {
table.integer('view_count').defaultTo(0); table.integer('view_count').defaultTo(0);
table.integer('download_count').defaultTo(0); table.integer('download_count').defaultTo(0);
}); });
// Deferred FK: events.hero_photo_id → photos.id. See the comment
// on the events createTable above for why this can't be inline.
// Wrapped in try/catch so a re-run path or an SQLite install that
// already accepted the inline (no-op) declaration doesn't fail
// boot when the constraint already exists in some shape.
try {
await db.schema.alterTable('events', (table) => {
table.foreign('hero_photo_id')
.references('id').inTable('photos')
.onDelete('SET NULL');
});
} catch (err) {
const msg = err?.message || '';
if (!/already exists|duplicate|exists/i.test(msg)) {
throw err;
}
// Constraint already in place — fine, carry on.
}
} }
// Access logs table // Access logs table
@@ -554,11 +582,23 @@ async function ensureGlobalCategories() {
// Helper function to log activities // Helper function to log activities
async function logActivity(activityType, metadata = {}, eventId = null, actor = null) { async function logActivity(activityType, metadata = {}, eventId = null, actor = null) {
try { try {
// actor_id is integer-typed; some legacy callers pass a hex-string
// identifier (e.g. a 16-char guest fingerprint) which makes Postgres
// throw "invalid input syntax for type integer" and drop the entire
// log entry. Coerce anything non-integer to null and surface the
// string in actor_name so we don't lose the audit trail. Customer/
// admin actors are unaffected — their ids are already numeric.
const rawId = actor?.id;
const actorIdInt = Number.isInteger(rawId) ? rawId
: (typeof rawId === 'string' && /^\d+$/.test(rawId) ? Number(rawId) : null);
const actorName = actor?.name
|| (actorIdInt === null && rawId !== undefined && rawId !== null ? String(rawId) : null);
await db('activity_logs').insert({ await db('activity_logs').insert({
activity_type: activityType, activity_type: activityType,
actor_type: actor?.type || 'system', actor_type: actor?.type || 'system',
actor_id: actor?.id || null, actor_id: actorIdInt,
actor_name: actor?.name || null, actor_name: actorName,
metadata: JSON.stringify(metadata), metadata: JSON.stringify(metadata),
event_id: eventId event_id: eventId
}); });
+131
View File
@@ -0,0 +1,131 @@
/**
* Customer Authentication Middleware
*
* Verifies a 'customer' JWT issued by /api/customer/auth/login. Mirrors
* adminAuth (same revocation, IP-log, password-change invalidation flow)
* but operates on customer_accounts rather than admin_users — so an
* admin token cannot pass as a customer and vice versa.
*
* Sets `req.customer = { id, email, displayName, isActive }` on success.
*/
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
async function customerAuth(req, res, next) {
try {
const token = getCustomerTokenFromRequest(req);
if (!token) {
// Quiet by default — unauthenticated /api/customer/* requests are
// normal (page polling, pre-login session probes). Bump to debug
// for noisy investigations only.
logger.debug('[customerAuth] no token on request', {
url: req.originalUrl,
hasCookieHeader: !!req.headers?.cookie,
cookieKeys: Object.keys(req.cookies || {}),
});
return res.status(401).json({ error: 'No token provided', code: 'NO_TOKEN' });
}
let decoded;
try {
const verified = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true,
});
decoded = verified.payload;
} catch (err) {
logger.warn('[customerAuth] jwt verification failed', {
url: req.originalUrl,
errorName: err.name,
errorMessage: err.message,
});
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token', code: 'JWT_INVALID' });
}
if (await isTokenRevoked(decoded)) {
logger.warn('[customerAuth] token revoked', {
url: req.originalUrl,
customerId: decoded.customerId,
tokenType: decoded.type,
iat: decoded.iat,
});
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
if (decoded.type !== 'customer') {
logger.warn('[customerAuth] wrong token type', {
url: req.originalUrl,
tokenType: decoded.type,
});
return res.status(403).json({ error: 'Insufficient permissions', code: 'WRONG_TOKEN_TYPE' });
}
// IP drift gets logged but doesn't reject — same lenient policy as
// adminAuth. Customers may roam between mobile networks frequently.
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.info('Customer token used from different IP', {
customerId: decoded.customerId,
tokenIp: decoded.ip,
currentIp,
});
}
const customer = await db('customer_accounts')
.where({ id: decoded.customerId, is_active: formatBoolean(true) })
.select('id', 'email', 'display_name', 'first_name', 'last_name', 'password_changed_at', 'preferred_language')
.first();
if (!customer) {
// Either deleted, deactivated, or the id was forged. 401 across the
// board so the frontend session-expiry handler kicks in.
logger.warn('[customerAuth] customer row not found / inactive', {
url: req.originalUrl,
customerId: decoded.customerId,
});
return res.status(401).json({ error: 'Invalid token', code: 'CUSTOMER_NOT_FOUND' });
}
if (customer.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(customer.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
logger.warn('[customerAuth] token rejected: password_changed_at', {
url: req.originalUrl,
customerId: decoded.customerId,
iat: decoded.iat,
passwordChangedSeconds,
});
return res.status(401).json({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED',
});
}
}
req.customer = {
id: customer.id,
email: customer.email,
displayName: customer.display_name,
firstName: customer.first_name,
lastName: customer.last_name,
preferredLanguage: customer.preferred_language || 'en',
};
req.token = token;
next();
} catch (error) {
logger.error('Customer auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
module.exports = { customerAuth };
+29
View File
@@ -121,6 +121,35 @@ async function verifyGalleryAccess(req, res, next) {
return res.status(404).json({ error: 'Gallery not found or expired' }); return res.status(404).json({ error: 'Gallery not found or expired' });
} }
// Customer-minted gallery JWTs (#354): when the customer obtained
// this token via /api/customer/events/:slug/access-token, the
// payload carries `via:'customer'` and `customerId`. The admin
// can revoke the customer's access at any time by removing the
// event_customer_assignments row from the "Manage galleries"
// dialog on the customer detail page. Re-check that row here so
// the revocation takes effect on the customer's very next
// request — no token-blacklisting machinery required.
if (decoded.via === 'customer' && decoded.customerId) {
const assignment = await withRetry(async () => {
return await db('event_customer_assignments')
.where({
event_id: event.id,
customer_account_id: decoded.customerId,
})
.first();
});
if (!assignment) {
logger.info('[verifyGalleryAccess] Customer assignment revoked, rejecting token', {
customerId: decoded.customerId,
eventId: event.id,
});
return res.status(403).json({
error: 'Access to this gallery has been revoked',
code: 'CUSTOMER_ASSIGNMENT_REVOKED',
});
}
}
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug }); logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
req.event = event; req.event = event;
req.accessLevel = decoded.accessLevel || 'guest'; req.accessLevel = decoded.accessLevel || 'guest';
+39
View File
@@ -0,0 +1,39 @@
/**
* Cache-Control: no-store helper for sensitive endpoints.
*
* Why a dedicated middleware: shipping the wrong cache-control header
* on a session-bearing endpoint is a class-of-bug that bites long
* after the original mistake. The PR #458 / PR #470 history is the
* concrete trigger:
*
* - #458 mounted requireCustomerPortalEnabled which 410'd every
* /api/customer/* and /api/admin/customers/* request when the
* master toggle was off.
* - Some browsers cached the 410 (the response carried no explicit
* Cache-Control header, so heuristic freshness applied — for an
* authenticated/sensitive surface that's the wrong default).
* - #470 reverted the middleware, but a customer whose tab cached
* the 410 still saw 410s until they hard-refreshed.
*
* Mounting `noStoreCache` in front of these routes belt-and-braces
* the future: any 4xx/5xx (or 200) response from these endpoints
* carries `Cache-Control: no-store`, so a transient kill-switch,
* permission flip, or backend restart can never get pinned in
* intermediate caches.
*
* No-op cost (one setHeader per request); applied per route group
* rather than globally so static assets + galleries keep their
* own caching strategy.
*/
function noStoreCache(req, res, next) {
// `no-store` is the strongest signal — no cache, no revalidation,
// no offline retention. Pair with `private` so any well-behaved
// intermediate proxy treats the response as user-specific.
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.setHeader('Pragma', 'no-cache'); // HTTP/1.0 fallback for older proxies
res.setHeader('Expires', '0');
next();
}
module.exports = { noStoreCache };
+2 -1
View File
@@ -3,6 +3,7 @@ const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { slugify } = require('../utils/slug');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver'); const archiver = require('archiver');
@@ -217,7 +218,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
const insertResult = await db('photo_categories').insert({ const insertResult = await db('photo_categories').insert({
event_id: archive.id, event_id: archive.id,
name: categoryName, name: categoryName,
slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'), slug: slugify(categoryName),
created_at: new Date() created_at: new Date()
}).returning('id'); }).returning('id');
+5 -1
View File
@@ -73,7 +73,8 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
body('content_de').optional().isString(), body('content_de').optional().isString(),
body('logo_url').optional({ nullable: true }).isString(), body('logo_url').optional({ nullable: true }).isString(),
body('use_external_url').optional().isBoolean(), body('use_external_url').optional().isBoolean(),
body('external_url').optional({ nullable: true }).isString() body('external_url').optional({ nullable: true }).isString(),
body('show_in_footer').optional().isBoolean()
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -127,6 +128,9 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
const trimmed = typeof external_url === 'string' ? external_url.trim() : ''; const trimmed = typeof external_url === 'string' ? external_url.trim() : '';
updateFields.external_url = trimmed || null; updateFields.external_url = trimmed || null;
} }
if (Object.prototype.hasOwnProperty.call(req.body, 'show_in_footer')) {
updateFields.show_in_footer = !!req.body.show_in_footer;
}
await db('cms_pages').where('slug', slug).update(updateFields); await db('cms_pages').where('slug', slug).update(updateFields);
+6 -2
View File
@@ -56,7 +56,9 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
const { name, slug, is_global = true, event_id = null } = req.body; const { name, slug, is_global = true, event_id = null } = req.body;
// Generate slug if not provided // Generate slug if not provided
const categorySlug = slug || name.toLowerCase() const categorySlug = slug || name
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^\w\s-]/g, '') .replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/-+/g, '-') .replace(/-+/g, '-')
@@ -128,7 +130,9 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
const updateData = { const updateData = {
name, name,
slug: name.toLowerCase() slug: name
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^\w\s-]/g, '') .replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/-+/g, '-') .replace(/-+/g, '-')
+344
View File
@@ -0,0 +1,344 @@
/**
* Admin → Customers Routes
*
* Endpoint mounted at /api/admin/customers (see app.js wiring).
* Mirrors adminUsers.js for the invitation lifecycle but operates on
* customer_accounts. Customer-side login routes live in customerAuth.js.
*/
const express = require('express');
const { body, param, query } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const customerAccountsService = require('../services/customerAccountsService');
const router = express.Router();
/**
* Snake_case (DB) → camelCase (API). Kept narrow on purpose: only fields
* the frontend actually needs land in the response so the surface area
* doesn't accidentally grow when new columns get added later.
*/
function transformCustomer(c) {
return {
id: c.id,
email: c.email,
salutation: c.salutation,
firstName: c.first_name,
lastName: c.last_name,
displayName: c.display_name,
phone: c.phone,
companyName: c.company_name,
billingEmail: c.billing_email,
vatId: c.vat_id,
addressLine1: c.address_line1,
addressLine2: c.address_line2,
postalCode: c.postal_code,
city: c.city,
state: c.state,
countryCode: c.country_code,
preferredLanguage: c.preferred_language,
notes: c.notes,
isActive: c.is_active,
// Per-customer feature flags (#354 follow-up). Coerce to bool so the
// frontend doesn't have to deal with SQLite's 0/1 values.
featureCalendar: c.feature_calendar === true || c.feature_calendar === 1,
featureQuotes: c.feature_quotes === true || c.feature_quotes === 1,
featureBills: c.feature_bills === true || c.feature_bills === 1,
lastLogin: c.last_login,
createdAt: c.created_at,
updatedAt: c.updated_at,
eventCount: c.event_count != null ? Number(c.event_count) : undefined,
events: Array.isArray(c.events)
? c.events.map((e) => ({
id: e.id,
slug: e.slug,
eventName: e.event_name,
eventDate: e.event_date,
expiresAt: e.expires_at,
isArchived: e.is_archived,
assignedAt: e.assigned_at,
}))
: undefined,
};
}
function transformInvitation(inv) {
return {
id: inv.id,
email: inv.email,
expiresAt: inv.expires_at,
createdAt: inv.created_at,
invitedBy: inv.invited_by,
};
}
// ---- list / search ------------------------------------------------------
router.get('/', [
adminAuth,
requirePermission('customers.view'),
query('search').optional().isString(),
], handleAsync(async (req, res) => {
validateRequest(req);
const customers = await customerAccountsService.listCustomers({
search: req.query.search,
});
res.json({ customers: customers.map(transformCustomer) });
}));
/**
* GET /search?email=…
*
* Autocomplete used by the event-form CustomerAccountPicker. Returns
* up to 10 matches against email/name/company prefixes. Permission is
* customers.view because exposing emails to anyone with users.view but
* not customers.view would leak the customer roster.
*/
router.get('/search', [
adminAuth,
requirePermission('customers.view'),
query('email').optional().isString(),
query('q').optional().isString(),
], handleAsync(async (req, res) => {
validateRequest(req);
const term = req.query.email || req.query.q || '';
const results = await customerAccountsService.searchCustomers(term);
res.json({ customers: results.map(transformCustomer) });
}));
// ---- invitations --------------------------------------------------------
router.get('/invitations', [
adminAuth,
requirePermission('customers.view'),
], handleAsync(async (req, res) => {
const invitations = await customerAccountsService.getPendingInvitations();
res.json({ invitations: invitations.map(transformInvitation) });
}));
router.post('/invite', [
adminAuth,
requirePermission('customers.create'),
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
// Optional prefill — admin can stash any subset of customer profile fields
// on the invitation. The customer sees them pre-populated on the accept
// form and can edit before submitting. Validators are deliberately lax:
// any field can be omitted, and only length is enforced (sanitisation
// happens server-side in the service).
body('prefill').optional().isObject(),
body('prefill.salutation').optional({ nullable: true }).isString().isLength({ max: 32 }),
body('prefill.first_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
body('prefill.last_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
body('prefill.display_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.phone').optional({ nullable: true }).isString().isLength({ max: 40 }),
body('prefill.company_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.vat_id').optional({ nullable: true }).isString().isLength({ max: 40 }),
body('prefill.address_line1').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('prefill.address_line2').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('prefill.postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }),
body('prefill.city').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.state').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const invitation = await customerAccountsService.createInvitation({
email: req.body.email,
invitedById: req.admin.id,
prefill: req.body.prefill,
});
// Echo the token in the response ONLY in non-production. This lets
// local dev + Playwright e2e specs skip the email round-trip
// (queueing → SMTP → mailbox → parse) and accept the invitation
// straight away. In production the token stays email-channel-only:
// anyone with API access plus the response body would otherwise be
// able to take over a freshly-invited customer account before the
// legitimate user clicks the link.
const payload = {
invitation: {
id: invitation.id,
email: invitation.email,
expiresAt: invitation.expiresAt,
},
};
if (process.env.NODE_ENV !== 'production') {
payload.invitation.token = invitation.token;
}
successResponse(res, payload, 201);
}));
router.delete('/invitations/:id', [
adminAuth,
requirePermission('customers.create'),
param('id').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
await customerAccountsService.cancelInvitation(
parseInt(req.params.id, 10),
req.admin.id
);
successResponse(res, { message: 'Invitation cancelled' });
}));
// ---- customer record ----------------------------------------------------
router.get('/:id', [
adminAuth,
requirePermission('customers.view'),
param('id').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const customer = await customerAccountsService.getCustomerById(
parseInt(req.params.id, 10)
);
res.json({ customer: transformCustomer(customer) });
}));
router.put('/:id', [
adminAuth,
requirePermission('customers.create'),
param('id').isInt({ min: 1 }),
body('email').optional().isEmail().normalizeEmail(),
body('salutation').optional().isString().isLength({ max: 32 }),
body('first_name').optional().isString().isLength({ max: 80 }),
body('last_name').optional().isString().isLength({ max: 80 }),
body('display_name').optional().isString().isLength({ max: 120 }),
body('phone').optional().isString().isLength({ max: 40 }),
body('company_name').optional().isString().isLength({ max: 120 }),
body('billing_email').optional({ nullable: true }).isString(),
body('vat_id').optional({ nullable: true }).isString().isLength({ max: 40 }),
body('address_line1').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('address_line2').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }),
body('city').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('state').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
body('preferred_language').optional().isString().isLength({ max: 8 }),
body('notes').optional({ nullable: true }).isString(),
body('is_active').optional().isBoolean(),
body('feature_calendar').optional().isBoolean(),
body('feature_quotes').optional().isBoolean(),
body('feature_bills').optional().isBoolean(),
], handleAsync(async (req, res) => {
validateRequest(req);
const customer = await customerAccountsService.updateCustomer(
parseInt(req.params.id, 10),
req.body,
req.admin.id
);
res.json({ customer: transformCustomer(customer) });
}));
router.post('/:id/deactivate', [
adminAuth,
requirePermission('customers.delete'),
param('id').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
await customerAccountsService.deactivateCustomer(
parseInt(req.params.id, 10),
req.admin.id
);
successResponse(res, { message: 'Customer deactivated' });
}));
/**
* POST /:id/reactivate (#354 follow-up).
*
* Restore a previously-deactivated customer. Same permission as
* deactivate (`customers.delete`) since they're inverse operations and
* the admin who can disable should be the one who can re-enable.
*/
router.post('/:id/reactivate', [
adminAuth,
requirePermission('customers.delete'),
param('id').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
await customerAccountsService.reactivateCustomer(
parseInt(req.params.id, 10),
req.admin.id
);
successResponse(res, { message: 'Customer reactivated' });
}));
/**
* POST /:id/erase (#354 follow-up).
*
* Anonymize-in-place erasure (GDPR Art. 17 style): nulls every PII
* column, wipes credentials, drops pending invitations and reset tokens,
* keeps the row + audit references intact so historical "who had access"
* queries don't break. See customerAccountsService.eraseCustomer for
* the full rationale.
*
* Hard delete is NOT shipped — `customer_invitations.accepted_customer_id`
* has no ON DELETE CASCADE, so a real DELETE would FK-block on any
* customer who ever accepted an invitation.
*/
router.post('/:id/erase', [
adminAuth,
requirePermission('customers.delete'),
param('id').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
await customerAccountsService.eraseCustomer(
parseInt(req.params.id, 10),
req.admin.id
);
successResponse(res, { message: 'Customer erased' });
}));
/**
* POST /:id/password-reset (#354 follow-up).
*
* Generate a 7-day password-reset token and email it to the customer.
* Reused permission `customers.create` because issuing a reset is the
* same authority level as issuing an invitation — both put a credential
* into the customer's mailbox.
*/
router.post('/:id/password-reset', [
adminAuth,
requirePermission('customers.create'),
param('id').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await customerAccountsService.createPasswordReset({
customerId: parseInt(req.params.id, 10),
requestedByAdminId: req.admin.id,
});
successResponse(res, { email: result.email, expiresAt: result.expiresAt });
}));
/**
* PUT /api/admin/customers/:id/events — replace the customer's full
* event assignment list. Backs the "Manage galleries" dialog on the
* customer detail page. Body is `{ event_ids: number[] }`. Empty
* array clears every assignment.
*
* Access revocation is implicit: gallery middleware checks for a
* live event_customer_assignments row whenever it decodes a
* customer-minted gallery JWT, so removing an assignment here
* immediately blocks the customer's next gallery request without
* needing to enumerate + revoke any active tokens. Permission tier
* is customers.create (same as invite + deactivate) — managing
* which galleries a customer can see is a write-class operation
* on the customer record.
*/
router.put('/:id/events', [
adminAuth,
requirePermission('customers.create'),
param('id').isInt({ min: 1 }),
body('event_ids').isArray(),
body('event_ids.*').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await customerAccountsService.setAssignmentsForCustomer(
parseInt(req.params.id, 10),
req.body.event_ids,
req.admin.id,
);
successResponse(res, result);
}));
module.exports = router;
+11
View File
@@ -315,6 +315,13 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req,
template_key: template.template_key, template_key: template.template_key,
variables: parseVariables(template), variables: parseVariables(template),
translations, translations,
// Categorisation + feature-flag link added by migration 098.
// Older installs that haven't run the migration yet return
// 'core' / null fall-backs so the frontend keeps working
// without a hard dependency on the new columns.
category: template.category || 'core',
subcategory: template.subcategory || null,
feature_flag: template.feature_flag || null,
updated_at: template.updated_at, updated_at: template.updated_at,
}); });
} }
@@ -344,6 +351,10 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
template_key: template.template_key, template_key: template.template_key,
variables: parseVariables(template), variables: parseVariables(template),
translations, translations,
// See list endpoint for the rationale on the || fallbacks.
category: template.category || 'core',
subcategory: template.subcategory || null,
feature_flag: template.feature_flag || null,
updated_at: template.updated_at, updated_at: template.updated_at,
}); });
} catch (error) { } catch (error) {
+170 -48
View File
@@ -2,6 +2,7 @@ const express = require('express');
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { slugify } = require('../utils/slug');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const router = express.Router(); const router = express.Router();
@@ -393,7 +394,21 @@ router.post('/', adminAuth, requirePermission('events.create'), [
'upload_date_desc', 'upload_date_asc', 'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc', 'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc' 'filename_asc', 'filename_desc'
]) ]),
// Per-event promotional override (#440). Three-way mode:
// inherit → fall back to global branding_promo_markdown
// custom → render this event's promo_markdown verbatim
// off → suppress entirely for this event
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
body('promo_markdown').optional({ nullable: true }).isString(),
// Per-event opt-in for using hero photo as the social-share preview
// image (#474). When false (default), galleryOgService falls back to
// the brand logo for og:image / Twitter Card.
body('og_image_share_enabled').optional().isBoolean(),
// Customer accounts assigned to this event (#354). Optional array of
// customer_accounts.id — many-to-many via event_customer_assignments.
body('customer_account_ids').optional().isArray(),
body('customer_account_ids.*').optional().isInt({ min: 1 })
], async (req, res) => { ], async (req, res) => {
try { try {
logger.debug('Create event request body', { body: req.body }); logger.debug('Create event request body', { body: req.body });
@@ -425,7 +440,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
allow_presigned_download = false, allow_presigned_download = false,
require_password: requirePasswordInput, require_password: requirePasswordInput,
// Feedback settings // Feedback settings
feedback_enabled = false, feedback_enabled: feedbackEnabledInput,
allow_ratings = true, allow_ratings = true,
allow_likes = true, allow_likes = true,
allow_comments = true, allow_comments = true,
@@ -493,6 +508,17 @@ router.post('/', adminAuth, requirePermission('events.create'), [
} }
const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback); const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
// Default feedback_enabled from global "event_default_feedback_enabled"
// setting when the body omits it (#520 — same pattern as require_password
// above, lets admins make Guest Feedback ON the out-of-box default for
// new events instead of toggling it on every time).
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await readBooleanSetting('event_default_feedback_enabled');
if (setting !== undefined) feedbackEnabledFallback = setting;
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
// Debug logging // Debug logging
logger.debug('Download control values', { logger.debug('Download control values', {
allow_downloads, allow_downloads,
@@ -524,12 +550,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
} }
} }
// Generate unique slug // Generate unique slug. Uses the shared util so accented names
const processedEventName = event_name // (Família, Decoração, etc.) get transliterated instead of dropped
.toLowerCase() // — see backend/src/utils/slug.js for the why (#525).
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash const processedEventName = slugify(event_name);
.replace(/-+/g, '-') // Replace multiple dashes with single dash
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
// Use event_date in slug if provided, otherwise use random suffix // Use event_date in slug if provided, otherwise use random suffix
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex'); const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
@@ -653,12 +677,37 @@ router.post('/', adminAuth, requirePermission('events.create'), [
...(client_access_enabled && client_password ? { ...(client_access_enabled && client_password ? {
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()), client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
client_share_token: crypto.randomBytes(32).toString('hex') client_share_token: crypto.randomBytes(32).toString('hex')
} : {}) } : {}),
// Per-event opt-in for hero-photo OG share image (#474). Defaults
// false on create — admin opts in from the event detail page once
// they've picked a hero they're comfortable surfacing publicly.
og_image_share_enabled: formatBoolean(req.body.og_image_share_enabled === true),
}).returning('id'); }).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0]; const eventId = insertResult[0]?.id || insertResult[0];
// Apply customer-account assignments (#354). Skip when the customer
// portal flag is off — the frontend hides the picker in that case,
// but a stale tab could still POST customer_account_ids; we ignore
// them rather than 403 the entire create.
if (Array.isArray(req.body.customer_account_ids)) {
try {
const customerAccountsService = require('../services/customerAccountsService');
if (await customerAccountsService.isCustomerPortalEnabled()) {
await customerAccountsService.setAssignmentsForEvent(
eventId,
req.body.customer_account_ids,
req.admin.id
);
}
} catch (e) {
logger.error('Failed to set customer assignments on event create', {
eventId, error: e.message,
});
}
}
// Insert feedback settings if feedback is enabled // Insert feedback settings if feedback is enabled
if (feedback_enabled) { if (feedback_enabled) {
await db('event_feedback_settings').insert({ await db('event_feedback_settings').insert({
@@ -937,6 +986,17 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
.where('event_id', id) .where('event_id', id)
.countDistinct('ip_address as uniqueVisitors'); .countDistinct('ip_address as uniqueVisitors');
// Customer accounts assigned to this event (#354). Hydrates the
// CustomerAccountPicker on the EventDetailsPage admin form. Returns
// an empty array on installs missing the table (e.g. pre-migrate).
let customerAccounts = [];
try {
const customerAccountsService = require('../services/customerAccountsService');
customerAccounts = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10));
} catch (e) {
logger.warn('Failed to load customer assignments for event', { eventId: id, error: e.message });
}
res.json(mapEventForApi({ res.json(mapEventForApi({
...event, ...event,
photo_count: parseInt(photoCount) || 0, photo_count: parseInt(photoCount) || 0,
@@ -944,7 +1004,14 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
total_views: parseInt(totalViews) || 0, total_views: parseInt(totalViews) || 0,
total_downloads: parseInt(totalDownloads) || 0, total_downloads: parseInt(totalDownloads) || 0,
unique_visitors: parseInt(uniqueVisitors) || 0, unique_visitors: parseInt(uniqueVisitors) || 0,
recent_photos: recentPhotos recent_photos: recentPhotos,
customer_accounts: customerAccounts.map((c) => ({
id: c.id,
email: c.email,
display_name: c.display_name,
first_name: c.first_name,
last_name: c.last_name,
})),
})); }));
} catch (error) { } catch (error) {
console.error('Error fetching event:', error); console.error('Error fetching event:', error);
@@ -1100,7 +1167,21 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
'upload_date_desc', 'upload_date_asc', 'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc', 'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc' 'filename_asc', 'filename_desc'
]) ]),
// Per-event promotional override (#440). Three-way mode:
// inherit → fall back to global branding_promo_markdown
// custom → render this event's promo_markdown verbatim
// off → suppress entirely for this event
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
body('promo_markdown').optional({ nullable: true }).isString(),
// Per-event opt-in for using hero photo as the social-share preview
// image (#474). When false (default), galleryOgService falls back to
// the brand logo for og:image / Twitter Card.
body('og_image_share_enabled').optional().isBoolean(),
// Customer accounts assigned to this event (#354). Optional array of
// customer_accounts.id — many-to-many via event_customer_assignments.
body('customer_account_ids').optional().isArray(),
body('customer_account_ids.*').optional().isInt({ min: 1 })
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -1192,14 +1273,6 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
return res.status(400).json({ error: 'external_path is required when source_mode is reference' }); return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
} }
// Handle client access fields (#172)
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
// Auto-generate client share token when first enabling
if (parseBooleanInput(updates.client_access_enabled, false) && !event.client_share_token) {
updates.client_share_token = crypto.randomBytes(32).toString('hex');
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) { if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) {
updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds()); updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds());
delete updates.client_password; delete updates.client_password;
@@ -1211,6 +1284,13 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
} }
delete updates.regenerate_client_token; delete updates.regenerate_client_token;
// customer_account_ids (#354) is a body-only field consumed
// separately below by customerAccountsService.setAssignmentsForEvent
// — it isn't a column on the events table, so spreading it into
// the UPDATE statement throws "column does not exist" and crashes
// the entire edit with 500 Failed to update event.
delete updates.customer_account_ids;
// Log the update request for debugging // Log the update request for debugging
logger.debug('Update event request', { logger.debug('Update event request', {
id, id,
@@ -1245,21 +1325,41 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
} }
// Enforce expires_at requirement based on app settings // Enforce expires_at requirement based on app settings
if (Object.prototype.hasOwnProperty.call(updates, 'expires_at')) { // Allow admins to clear `expires_at` on edit even when the global
if (!updates.expires_at) { // `event_require_expiration` setting is ON (#426). The setting now
const fieldReqs = await getEventFieldRequirements(); // controls only the create-time default — once an event exists, an
if (fieldReqs.require_expiration) { // admin editing it can override and remove the expiration. Empty /
return res.status(400).json({ error: 'Expiration date is required.' }); // null values normalize to NULL in the column ("never expires").
} if (Object.prototype.hasOwnProperty.call(updates, 'expires_at') && !updates.expires_at) {
updates.expires_at = null; updates.expires_at = null;
} }
}
// Format hero logo settings if provided // Format hero logo settings if provided
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) { if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible); updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
} }
// Per-event opt-in for hero-photo OG share image (#474). Coerce so
// SQLite stores 0/1 and Postgres stores boolean true/false.
if (Object.prototype.hasOwnProperty.call(updates, 'og_image_share_enabled')) {
updates.og_image_share_enabled = formatBoolean(updates.og_image_share_enabled === true);
}
// Per-event promotional override (#440). Normalize promo_markdown to
// NULL when mode is anything other than 'custom' so we don't carry
// stale text after the admin switches modes. Empty markdown also
// becomes NULL.
if (Object.prototype.hasOwnProperty.call(updates, 'promo_mode')
|| Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) {
const mode = updates.promo_mode;
if (mode && mode !== 'custom') {
updates.promo_markdown = null;
} else if (Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) {
const md = typeof updates.promo_markdown === 'string' ? updates.promo_markdown.trim() : '';
updates.promo_markdown = md || null;
}
}
// Sync header_style / hero_divider_style from color_theme JSON when not // Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158). This ensures the // explicitly provided in the request body (#158). This ensures the
// database columns stay in sync even if the frontend only sends the // database columns stay in sync even if the frontend only sends the
@@ -1281,11 +1381,40 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
} }
} }
// Handle client access fields (#172)
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
// Auto-generate client share token when first enabling
if (parseBooleanInput(updates.client_access_enabled, false) && !event.client_share_token && !updates.client_share_token) {
updates.client_share_token = crypto.randomBytes(32).toString('hex');
}
}
// Update event // Update event
await db('events') await db('events')
.where('id', id) .where('id', id)
.update(updates); .update(updates);
// Customer-account assignments (#354). Same skip semantics as POST:
// ignore when the customer portal flag is off so stale tabs don't
// 4xx the whole edit.
if (Array.isArray(req.body.customer_account_ids)) {
try {
const customerAccountsService = require('../services/customerAccountsService');
if (await customerAccountsService.isCustomerPortalEnabled()) {
await customerAccountsService.setAssignmentsForEvent(
parseInt(id, 10),
req.body.customer_account_ids,
req.admin.id
);
}
} catch (e) {
logger.error('Failed to set customer assignments on event update', {
eventId: id, error: e.message,
});
}
}
// Log activity // Log activity
await logActivity('event_updated', await logActivity('event_updated',
{ changes: Object.keys(updates), eventName: event.event_name }, { changes: Object.keys(updates), eventName: event.event_name },
@@ -1652,18 +1781,23 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
} }
}); });
// Bulk delete — destructive, irreversible. Requires the calling admin to // Bulk delete — destructive, irreversible. Caps at 100 events per request
// re-enter their password as a confirmation gate (verified against the // to keep request time bounded; the per-event cascade touches 5 DB tables
// stored bcrypt hash, same pattern as /auth/admin/change-password). Caps at // + 3 filesystem paths so 1000 events would risk timing out the request.
// 100 events per request to keep request time bounded; the per-event // Loops via deleteEventCascade so the per-event delete behaviour stays in
// cascade touches 5 DB tables + 3 filesystem paths so 1000 events would // lock-step with DELETE /:id.
// risk timing out the request. Loops via deleteEventCascade so the per- //
// event delete behaviour stays in lock-step with DELETE /:id. // Confirmation is enforced client-side via the typed-DELETE pattern in
// BulkDeleteModal (#417). The previous server-side bcrypt-password gate
// was dropped because the destructive single-event DELETE /:id has never
// required a password either — events.delete permission + admin session
// is the auth boundary for both. The typed-literal client gate is the
// "accidental click" safeguard, and unlike a password input it isn't
// affected by passkey/Windows Hello autofill that auto-submits the form.
const BULK_DELETE_MAX = 100; const BULK_DELETE_MAX = 100;
router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [ router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [
body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`), body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`),
body('eventIds.*').isInt().withMessage('Each eventId must be an integer'), body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
body('password').isString().notEmpty().withMessage('Password is required for confirmation')
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -1671,19 +1805,7 @@ router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [
return res.status(400).json({ errors: errors.array() }); return res.status(400).json({ errors: errors.array() });
} }
const { eventIds, password } = req.body; const { eventIds } = req.body;
// Verify the admin's password before doing anything destructive.
// Same pattern as /auth/admin/change-password (auth.js).
const admin = await db('admin_users').where({ id: req.admin.id }).first();
if (!admin) {
return res.status(401).json({ error: 'Authentication required' });
}
const validPassword = await bcrypt.compare(password, admin.password_hash);
if (!validPassword) {
logger.warn('Incorrect password on bulk-delete attempt', { adminId: req.admin.id, eventCount: eventIds.length });
return res.status(401).json({ error: 'Incorrect password', code: 'INVALID_PASSWORD' });
}
// Editor-role events.delete permission is already gated by the route // Editor-role events.delete permission is already gated by the route
// middleware. We do NOT additionally filter to created_by here because // middleware. We do NOT additionally filter to created_by here because
+38 -3
View File
@@ -7,6 +7,7 @@ const { list, resolveExternalPath, getExternalMediaRoot } = require('../services
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const sharp = require('sharp'); const sharp = require('sharp');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { generateThumbnail } = require('../services/imageProcessor');
const router = express.Router(); const router = express.Router();
@@ -93,6 +94,8 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
} }
let imported = 0; let imported = 0;
let thumbnailsGenerated = 0;
let thumbnailsFailed = 0;
// Insert photos // Insert photos
for (const f of dedupeMap.values()) { for (const f of dedupeMap.values()) {
@@ -137,6 +140,34 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
}) })
.returning('id'); .returning('id');
const photoId = Array.isArray(inserted) && inserted.length
? (typeof inserted[0] === 'object' ? inserted[0].id : inserted[0])
: null;
// Generate the thumbnail right away so the gallery grid can use the
// managed thumbnail endpoint instead of falling back to the full
// NAS-streamed original (#423). Best-effort: a single failure logs
// a warning and leaves thumbnail_path=null — the gallery's
// ensureThumbnail will retry lazily on first view. The cost of
// doing this synchronously is ~100-300ms per image; for the
// worst-case 1000-photo import that's still under the 5-minute
// request timeout typical of the import flow.
if (photoId != null) {
try {
const outputBasename = `ext${photoId}_${path.basename(f.rel)}`;
const thumbnailPath = await generateThumbnail(f.full, { outputBasename });
if (thumbnailPath) {
await db('photos').where({ id: photoId }).update({ thumbnail_path: thumbnailPath });
thumbnailsGenerated++;
} else {
thumbnailsFailed++;
}
} catch (thumbErr) {
thumbnailsFailed++;
logger.warn(`Thumbnail generation failed for external photo ${photoId} (${f.rel}): ${thumbErr.message}`);
}
}
imported += (inserted?.length ? 1 : 0); imported += (inserted?.length ? 1 : 0);
} catch (e) { } catch (e) {
skipped++; skipped++;
@@ -146,10 +177,14 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
// Update event fields // Update event fields
await db('events').where('id', eventId).update({ source_mode: 'reference', external_path }); await db('events').where('id', eventId).update({ source_mode: 'reference', external_path });
// Queue thumbnail generation lazily by reading thumbnails via ensure endpoint as needed await logActivity(
await logActivity('external_import_completed', { event_id: eventId, imported, skipped, external_path }, eventId, { type: 'admin' }); 'external_import_completed',
{ event_id: eventId, imported, skipped, thumbnailsGenerated, thumbnailsFailed, external_path },
eventId,
{ type: 'admin' }
);
res.json({ imported, skipped, thumbnailsQueued: 0 }); res.json({ imported, skipped, thumbnailsGenerated, thumbnailsFailed });
} catch (error) { } catch (error) {
logger.error('External media import failed', { logger.error('External media import failed', {
eventId: req.params.id, eventId: req.params.id,
+172
View File
@@ -0,0 +1,172 @@
/**
* Feature flags admin endpoints (#feature-flags-settings-reorg).
*
* GET /api/admin/feature-flags → { [key]: boolean }
* PUT /api/admin/feature-flags → body { [key]: boolean }, replaces in tx
*
* Server-side dependency rules mirror the frontend:
* - quotes=false forces bills=false
* - calendar=false forces calendarBooking=false
* - galleries is hard-coded true regardless of input
*
* Audit log: every successful PUT writes one activity_logs row with the
* before/after diff so changes are traceable.
*/
const express = require('express');
const router = express.Router();
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const logger = require('../utils/logger');
// Canonical flag list. Keep in sync with frontend
// `FeatureKey` union in frontend/src/contexts/FeatureFlagsContext.tsx.
const KNOWN_FLAGS = [
'galleries',
'reminderEmails',
'calendar',
'calendarBooking',
'quotes',
'bills',
'messaging',
'analytics',
'userManagement',
// Top-level "Clients" section (#354 follow-up). Parent flag that
// gates the /admin/clients/* sidebar entry. customerPortal,
// calendar, quotes, bills and messaging are conceptually its
// children — when `clients` is off none of them surface in the
// admin UI even if their individual flags are on.
'clients',
// Customer-side portal surface (#354). Gates /customer/* routes
// and the Accounts sub-page under Clients. See migration 095.
'customerPortal',
];
// Spec defaults for any flag missing from the DB (e.g. a row added by a
// new release that hasn't run its migration yet on this instance).
const DEFAULT_FLAGS = {
galleries: true,
reminderEmails: true,
calendar: false,
calendarBooking: false,
quotes: false,
bills: false,
messaging: false,
analytics: true,
userManagement: true,
clients: false,
};
async function readAllFlags() {
const rows = await db('feature_flags').select('key', 'value');
const result = { ...DEFAULT_FLAGS };
for (const row of rows) {
if (KNOWN_FLAGS.includes(row.key)) {
result[row.key] = Boolean(row.value);
}
}
return result;
}
function applyDependencyRules(flags) {
const out = { ...flags };
// Galleries is the foundation — never off.
out.galleries = true;
// Sub-features can't outlive their parents.
if (out.quotes === false) out.bills = false;
if (out.calendar === false) out.calendarBooking = false;
// Clients parent flag is DERIVED from its children. Admins don't
// toggle it directly in the Features tab — they enable a specific
// sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging
// later) and the Clients sidebar section lights up automatically.
// Computing the value here (rather than only on writes) means GET
// /admin/feature-flags also returns a consistent state if the DB
// ever drifts (e.g. partial migration run).
out.clients = Boolean(
out.customerPortal
// future siblings (out.calendar || out.quotes || out.bills || out.messaging) go here
);
return out;
}
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const flags = await readAllFlags();
// Always run the rules so derived flags (e.g. `clients`) and
// hard invariants (galleries always on) are consistent even if
// the DB row is stale or missing.
res.json(applyDependencyRules(flags));
} catch (error) {
logger.error('Failed to read feature flags', { error: error.message });
res.status(500).json({ error: 'Failed to read feature flags' });
}
});
router.put('/', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const body = req.body || {};
if (typeof body !== 'object' || Array.isArray(body)) {
return res.status(400).json({ error: 'Body must be an object of { key: boolean } pairs' });
}
// Validate keys + types up front.
const cleaned = {};
for (const [key, value] of Object.entries(body)) {
if (!KNOWN_FLAGS.includes(key)) {
return res.status(400).json({ error: `Unknown feature flag: ${key}` });
}
if (typeof value !== 'boolean') {
return res.status(400).json({ error: `Flag ${key} must be boolean, got ${typeof value}` });
}
cleaned[key] = value;
}
const before = await readAllFlags();
const merged = applyDependencyRules({ ...before, ...cleaned });
// Compute diff for audit log.
const changed = {};
for (const key of KNOWN_FLAGS) {
if (merged[key] !== before[key]) {
changed[key] = { from: before[key], to: merged[key] };
}
}
if (Object.keys(changed).length === 0) {
// No-op write — return current state, skip audit log.
return res.json(merged);
}
const adminId = req.admin?.id || null;
const adminUsername = req.admin?.username || 'unknown';
await db.transaction(async (trx) => {
for (const key of KNOWN_FLAGS) {
const value = merged[key];
const existing = await trx('feature_flags').where({ key }).first();
if (existing) {
await trx('feature_flags')
.where({ key })
.update({ value, updated_at: trx.fn.now(), updated_by: adminId });
} else {
await trx('feature_flags').insert({ key, value, updated_by: adminId });
}
}
});
await logActivity(
'feature_flags_updated',
{ changed, actor: adminUsername },
null,
{ type: 'admin' }
);
res.json(merged);
} catch (error) {
logger.error('Failed to update feature flags', { error: error.message, stack: error.stack });
res.status(500).json({ error: 'Failed to update feature flags' });
}
});
module.exports = router;
+43 -7
View File
@@ -7,7 +7,11 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { ensureThumbnail } = require('../services/imageProcessor'); const { ensureThumbnail } = require('../services/imageProcessor');
const { isVideoMimeType } = require('../services/videoProcessor'); const { isVideoMimeType } = require('../services/videoProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { generatePhotoFilename, buildContentDisposition } = require('../utils/filenameSanitizer');
const {
getUseOriginalFilenames,
pickRawDownloadName,
} = require('../services/downloadFilenameService');
const { escapeLikePattern } = require('../utils/sqlSecurity'); const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation'); const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings'); const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
@@ -222,16 +226,29 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
let photoType = 'individual'; // default let photoType = 'individual'; // default
let categoryName = 'individual'; let categoryName = 'individual';
// Look up the actual category from database if provided // Look up the actual category from database if provided. Scope the
// lookup to (event_id = event.id OR is_global = true) — same contract
// the public v1 upload route enforces (#500 / #525). Without it, the
// admin upload silently accepts any category id including ones that
// belong to a different event. The v1 route rejects out-of-scope ids
// with 400; mirror that here so admin and v1 stay consistent.
if (parsedCategoryId && !isNaN(parsedCategoryId)) { if (parsedCategoryId && !isNaN(parsedCategoryId)) {
const category = await db('photo_categories').where({ id: parsedCategoryId }).first(); const category = await db('photo_categories')
if (category) { .where({ id: parsedCategoryId })
.andWhere(function () {
this.where({ event_id: event.id }).orWhere('is_global', true);
})
.first();
if (!category) {
return res.status(400).json({
error: `Unknown or out-of-scope category_id ${parsedCategoryId}`
});
}
categoryName = category.slug || category.name.toLowerCase().replace(/\s+/g, '_'); categoryName = category.slug || category.name.toLowerCase().replace(/\s+/g, '_');
// Use category slug for type determination // Use category slug for type determination
if (category.slug === 'collage' || category.slug === 'collages') { if (category.slug === 'collage' || category.slug === 'collages') {
photoType = 'collage'; photoType = 'collage';
} }
}
} else if (category_id === 'collage') { } else if (category_id === 'collage') {
// For backwards compatibility, accept string values // For backwards compatibility, accept string values
photoType = 'collage'; photoType = 'collage';
@@ -649,6 +666,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
if (photo.hero_path) { if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {}); await storage.delete(photo.hero_path).catch(() => {});
} }
// Lightbox preview tier (#492). Same disposable-derived semantics
// as thumbnail / hero — wipe on photo delete so we don't leak
// orphaned files into previews/ that no DB row references.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
// Delete pre-generated watermark if exists // Delete pre-generated watermark if exists
if (photo.watermark_path) { if (photo.watermark_path) {
@@ -783,6 +806,10 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
if (photo.hero_path) { if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {}); await storage.delete(photo.hero_path).catch(() => {});
} }
// Lightbox preview tier (#492) — bulk delete cleanup.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
if (photo.watermark_path) { if (photo.watermark_path) {
await watermarkGeneratorService.deleteForPhoto(photo.id); await watermarkGeneratorService.deleteForPhoto(photo.id);
} }
@@ -901,6 +928,11 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
const storage = getStorage(); const storage = getStorage();
const storageKey = resolvePhotoStorageKey(event, photo); const storageKey = resolvePhotoStorageKey(event, photo);
// #493: respect the original-filenames toggle for admin downloads too.
const useOriginal = await getUseOriginalFilenames();
const downloadName = pickRawDownloadName(photo, useOriginal);
const contentDisposition = buildContentDisposition(downloadName);
if (storageKey) { if (storageKey) {
const stat = await storage.stat(storageKey); const stat = await storage.stat(storageKey);
if (!stat) { if (!stat) {
@@ -909,7 +941,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
res.set({ res.set({
'Content-Type': photo.mime_type || 'application/octet-stream', 'Content-Type': photo.mime_type || 'application/octet-stream',
'Content-Length': stat.size, 'Content-Length': stat.size,
'Content-Disposition': `attachment; filename="${photo.filename}"`, 'Content-Disposition': contentDisposition,
}); });
const stream = await storage.get(storageKey); const stream = await storage.get(storageKey);
stream.pipe(res); stream.pipe(res);
@@ -923,7 +955,11 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
} catch (error) { } catch (error) {
return res.status(404).json({ error: 'Photo file not found' }); return res.status(404).json({ error: 'Photo file not found' });
} }
res.download(filePath, photo.filename); res.set({
'Content-Type': photo.mime_type || 'application/octet-stream',
'Content-Disposition': contentDisposition,
});
res.sendFile(filePath);
} catch (error) { } catch (error) {
console.error('Error downloading photo:', error); console.error('Error downloading photo:', error);
res.status(500).json({ error: 'Failed to download photo' }); res.status(500).json({ error: 'Failed to download photo' });
+155 -2
View File
@@ -134,6 +134,88 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
} }
}); });
/**
* Customer-surface branding settings (#354 follow-up).
*
* Two toggles control what shows in the customer dashboard header:
* customer_show_logo (default true)
* customer_show_company_name (default true)
*
* The Calendar / Quotes / Bills feature globals that used to live here
* have moved to the maintainer's Features tab (feature_flags table).
*
* IMPORTANT: both routes MUST be registered before the generic
* `router.get('/:type', ...)` below — Express matches routes in
* registration order.
*/
router.get('/customer-surface', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const rows = await db('app_settings')
.where('setting_type', 'customer_surface')
.select('setting_key', 'setting_value');
const settings = {};
for (const r of rows) {
let value = r.setting_value;
if (value === null || value === undefined) {
settings[r.setting_key] = null;
continue;
}
if (typeof value !== 'string') {
settings[r.setting_key] = value;
} else {
try { settings[r.setting_key] = JSON.parse(value); }
catch { settings[r.setting_key] = value; }
}
}
res.json(settings);
} catch (error) {
console.error('Customer surface settings fetch error:', error);
res.status(500).json({ error: 'Failed to fetch customer surface settings' });
}
});
router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
// Branding-only whitelist (calendar/quotes/bills feature globals
// moved to the Features tab / feature_flags table).
const allowed = [
'customer_show_logo',
'customer_show_company_name',
];
const updates = [];
for (const key of allowed) {
if (Object.prototype.hasOwnProperty.call(req.body, key)) {
const value = !!req.body[key];
updates.push({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'customer_surface' });
}
}
for (const u of updates) {
const existing = await db('app_settings').where('setting_key', u.setting_key).first();
if (existing) {
await db('app_settings').where('setting_key', u.setting_key).update({
setting_value: u.setting_value,
setting_type: u.setting_type,
updated_at: new Date(),
});
} else {
await db('app_settings').insert({ ...u, created_at: new Date(), updated_at: new Date() });
}
}
// Clear the public-site cache so any consumer relying on it
// (e.g. customer login footer if it picks these up) refetches.
clearPublicSiteCache();
res.json({ message: 'Customer surface settings updated', updated: updates.map((u) => u.setting_key) });
} catch (error) {
console.error('Customer surface settings save error:', error);
res.status(500).json({ error: 'Failed to save customer surface settings' });
}
});
// Get settings by type // Get settings by type
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => { router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
@@ -220,7 +302,28 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_hero, logo_display_hero,
logo_display_mode, logo_display_mode,
hide_powered_by, hide_powered_by,
force_color_mode force_color_mode,
// Login-page-only branding (#354 follow-up). Both toggles apply
// exclusively to /admin/login and /customer/login — the gallery
// and admin chrome use their own logo_size / logo_max_height.
// - login_logo_frame_enabled: true (default) renders the tinted
// square behind the logo; false drops it.
// - login_logo_size: 'small' | 'medium' | 'large' | 'xlarge'
// matches the gallery logo_size token set but applies only to
// the two login screens.
login_logo_frame_enabled,
login_logo_size,
// Footer overhaul (#441 + #440). Socials are URL strings (empty
// hides the icon). Promo content is markdown (rendered via
// marked → DOMPurify on the frontend, no raw HTML accepted).
facebook_url,
instagram_url,
whatsapp_url,
twitter_url,
youtube_url,
promo_markdown,
promo_position,
promo_alignment
} = req.body; } = req.body;
// Normalize force_color_mode: only 'dark' | 'light' | null are valid. // Normalize force_color_mode: only 'dark' | 'light' | null are valid.
@@ -233,6 +336,27 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
// Get current watermark settings hash for change detection // Get current watermark settings hash for change detection
const oldSettingsHash = await watermarkService.getSettingsHash(); const oldSettingsHash = await watermarkService.getSettingsHash();
// Normalize promo_position: only 'above_footer' | 'below_footer' valid.
const normalizedPromoPosition = promo_position === 'below_footer'
? 'below_footer'
: 'above_footer';
// Normalize promo_alignment: 'left' | 'center' | 'right'. Defaults
// to 'center' to match the gallery footer's full-width centering
// (#482 — the previous default left the markdown left-aligned in
// a max-w-3xl block, which read as visually offset from the footer).
const allowedPromoAlignments = ['left', 'center', 'right'];
const normalizedPromoAlignment = allowedPromoAlignments.includes(promo_alignment)
? promo_alignment
: 'center';
// Normalize login_logo_size to the same token set as logo_size.
// Anything else falls back to 'medium' on the next render.
const allowedLoginLogoSizes = ['small', 'medium', 'large', 'xlarge'];
const normalizedLoginLogoSize = allowedLoginLogoSizes.includes(login_logo_size)
? login_logo_size
: undefined;
const brandingSettings = { const brandingSettings = {
company_name, company_name,
company_tagline, company_tagline,
@@ -252,7 +376,23 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_hero, logo_display_hero,
logo_display_mode, logo_display_mode,
hide_powered_by, hide_powered_by,
force_color_mode: normalizedForceColorMode force_color_mode: normalizedForceColorMode,
// Login-only knobs (only persist when the request actually
// included the key, so a partial PUT from another tab doesn't
// accidentally clear them).
...(login_logo_frame_enabled !== undefined && { login_logo_frame_enabled }),
...(normalizedLoginLogoSize !== undefined && { login_logo_size: normalizedLoginLogoSize }),
// Footer overhaul (#441 + #440). String fields normalize empty/
// undefined → '' so the column is always a known type. Only persist
// when the request actually included the key (partial PUTs).
...(facebook_url !== undefined && { facebook_url: String(facebook_url || '').trim() }),
...(instagram_url !== undefined && { instagram_url: String(instagram_url || '').trim() }),
...(whatsapp_url !== undefined && { whatsapp_url: String(whatsapp_url || '').trim() }),
...(twitter_url !== undefined && { twitter_url: String(twitter_url || '').trim() }),
...(youtube_url !== undefined && { youtube_url: String(youtube_url || '').trim() }),
...(promo_markdown !== undefined && { promo_markdown: typeof promo_markdown === 'string' ? promo_markdown : '' }),
...(promo_position !== undefined && { promo_position: normalizedPromoPosition }),
...(promo_alignment !== undefined && { promo_alignment: normalizedPromoAlignment })
}; };
// Handle favicon deletion if empty string or null is provided // Handle favicon deletion if empty string or null is provided
@@ -659,6 +799,19 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) { if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache(); clearShareLinkSettingsCache();
} }
// Toggling the original-filenames setting (#493) requires busting the
// per-event pre-generated zips so the next download-all rebuilds with the
// new entry names. Single-photo downloads pick up the change as soon as
// the in-memory cache TTL in downloadFilenameService expires (cleared
// here for immediacy).
if (Object.prototype.hasOwnProperty.call(settings, 'general_use_original_filenames_for_downloads')) {
try {
require('../services/downloadFilenameService').clearCache();
require('../services/downloadZipService').invalidateAll();
} catch (e) {
console.warn('Failed to invalidate download caches after filename setting change:', e.message);
}
}
// Log activity // Log activity
await db('activity_logs').insert({ await db('activity_logs').insert({
+9 -4
View File
@@ -11,7 +11,7 @@ const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckS
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService'); const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
const { const {
checkAndNotifyUpdates, checkAndNotifyUpdates,
sendUpdateNotificationNow, sendTestUpdateNotification,
getUpdateNotificationSettings getUpdateNotificationSettings
} = require('../services/updateNotificationService'); } = require('../services/updateNotificationService');
const router = express.Router(); const router = express.Router();
@@ -352,13 +352,18 @@ router.put('/updates/notifications', adminAuth, requirePermission('settings.edit
}); });
// Manually trigger update notification email // Manually trigger update notification email
// Send a test update notification email. Uses the dedicated
// `version_update_test` template (migration 087) rather than reusing
// `version_update_available`, so admins on the latest version can still
// verify their SMTP + recipient config — the previous handler bailed
// with "No updates available" when nothing was pending (#418).
router.post('/updates/notifications/send', adminAuth, requirePermission('settings.edit'), async (req, res) => { router.post('/updates/notifications/send', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try { try {
const result = await sendUpdateNotificationNow(); const result = await sendTestUpdateNotification();
res.json(result); res.json(result);
} catch (error) { } catch (error) {
logger.error('Error sending update notification:', error); logger.error('Error sending test update notification:', error);
res.status(500).json({ error: 'Failed to send update notification' }); res.status(500).json({ error: 'Failed to send test update notification' });
} }
}); });
+79 -4
View File
@@ -3,7 +3,7 @@ const router = express.Router();
const { db } = require('../database/db'); const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail } = require('../services/imageProcessor'); const { generateThumbnail, ensurePreviewImage } = require('../services/imageProcessor');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const logger = require('../utils/logger'); const logger = require('../utils/logger');
@@ -25,7 +25,9 @@ router.get('/settings', adminAuth, requirePermission('photos.view'), async (req,
'thumbnail_height', 'thumbnail_height',
'thumbnail_fit', 'thumbnail_fit',
'thumbnail_quality', 'thumbnail_quality',
'thumbnail_format' 'thumbnail_format',
// Lightbox preview tier (#492). Boolean, default false.
'lightbox_preview_enabled'
]) ])
.select('setting_key', 'setting_value'); .select('setting_key', 'setting_value');
@@ -51,7 +53,7 @@ router.get('/settings', adminAuth, requirePermission('photos.view'), async (req,
// Update thumbnail settings // Update thumbnail settings
router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => { router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try { try {
const { width, height, fit, quality, format } = req.body; const { width, height, fit, quality, format, lightbox_preview_enabled } = req.body;
// Validate inputs // Validate inputs
if (width && (width < 50 || width > 1000)) { if (width && (width < 50 || width > 1000)) {
@@ -77,14 +79,35 @@ router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req,
if (fit) updates.push({ setting_key: 'thumbnail_fit', setting_value: JSON.stringify(fit) }); if (fit) updates.push({ setting_key: 'thumbnail_fit', setting_value: JSON.stringify(fit) });
if (quality) updates.push({ setting_key: 'thumbnail_quality', setting_value: quality }); if (quality) updates.push({ setting_key: 'thumbnail_quality', setting_value: quality });
if (format) updates.push({ setting_key: 'thumbnail_format', setting_value: JSON.stringify(format) }); if (format) updates.push({ setting_key: 'thumbnail_format', setting_value: JSON.stringify(format) });
// Lightbox preview tier (#492). Boolean — store JSON-stringified
// so the round-trip matches what migration 104 seeds.
if (typeof lightbox_preview_enabled === 'boolean') {
updates.push({
setting_key: 'lightbox_preview_enabled',
setting_value: JSON.stringify(lightbox_preview_enabled),
});
}
for (const update of updates) { for (const update of updates) {
await db('app_settings') const updated = await db('app_settings')
.where('setting_key', update.setting_key) .where('setting_key', update.setting_key)
.update({ .update({
setting_value: update.setting_value, setting_value: update.setting_value,
updated_at: db.fn.now() updated_at: db.fn.now()
}); });
// Defensive insert when the row is missing — covers the case
// where lightbox_preview_enabled is being saved on an install
// that pre-dates migration 104. Existing thumbnail_* keys are
// seeded by migration 040 so the update path always wins for
// them; this only fires on the new key.
if (!updated) {
await db('app_settings').insert({
setting_key: update.setting_key,
setting_value: update.setting_value,
setting_type: update.setting_key === 'lightbox_preview_enabled' ? 'thumbnail' : 'thumbnail',
updated_at: db.fn.now(),
});
}
} }
res.json({ res.json({
@@ -170,6 +193,58 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
} }
}); });
// Regenerate all preview-tier images (#492). Eager backfill counterpart
// to ensurePreviewImage's lazy generation. Mirrors the regenerate
// (thumbnails) endpoint above — same auth, same fire-and-forget shape,
// same per-photo error handling.
router.post('/regenerate-previews', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { eventId } = req.body;
let query = db('photos').select('id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path');
if (eventId) query = query.where('event_id', eventId);
// Skip videos — preview tier is image-only.
query = query.where(function() {
this.whereNull('media_type').orWhere('media_type', '!=', 'video');
});
const photos = await query;
if (photos.length === 0) {
return res.json({ message: 'No image photos to regenerate previews for', count: 0 });
}
res.json({
message: `Started regenerating ${photos.length} previews`,
count: photos.length,
});
setImmediate(async () => {
let successCount = 0;
let errorCount = 0;
for (const photo of photos) {
try {
// Force regeneration regardless of existing preview state by
// nulling the cached path so ensurePreviewImage doesn't
// short-circuit on a stale isPreviewValid check.
const newPreviewPath = await ensurePreviewImage({ ...photo, preview_path: null });
if (newPreviewPath) {
successCount++;
} else {
errorCount++;
}
} catch (error) {
logger.error(`Error regenerating preview for photo ${photo.id}:`, error);
errorCount++;
}
}
logger.info(`Preview regeneration complete: ${successCount} success, ${errorCount} errors`);
});
} catch (error) {
logger.error('Error starting preview regeneration:', error);
res.status(500).json({ error: 'Failed to start preview regeneration' });
}
});
// Get regeneration status // Get regeneration status
router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => { router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
try { try {
+39 -5
View File
@@ -11,6 +11,37 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
const userManagementService = require('../services/userManagementService'); const userManagementService = require('../services/userManagementService');
const router = express.Router(); const router = express.Router();
/**
* Coerce any of the shapes a TIMESTAMP column produces across our
* supported drivers into a single ISO 8601 string the frontend (and
* any external API consumer) can safely pass to date-fns / new Date.
*
* Postgres Date object (becomes ISO via JSON.stringify anyway, but
* pinning the format defends against driver-side surprises).
* SQLite integer milliseconds since epoch (the surface that crashed
* the admin Users page in #485 `parseISO(123456789)` blows up
* with "e.split is not a function"). Native installs default to
* SQLite, so this path matters every release.
* Already a string assume it's a parseable ISO/RFC3339 (Postgres
* driver may stringify under JSON serialization mid-pipeline).
*
* Returns null/undefined unchanged so an unset last_login surfaces as
* "Never" in the UI rather than 1970-01-01T00:00:00Z.
*/
function toIso(value) {
if (value === null || value === undefined || value === '') return value;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'number') return new Date(value).toISOString();
if (typeof value === 'string') {
// Numeric-as-string ("1778752458666") happens when the SQLite
// driver stringifies large integers — re-coerce so the frontend
// doesn't try to parseISO('1778752458666').
if (/^\d{10,}$/.test(value)) return new Date(Number(value)).toISOString();
return value;
}
return value;
}
/** /**
* Transform user object from snake_case (DB) to camelCase (API) * Transform user object from snake_case (DB) to camelCase (API)
*/ */
@@ -20,10 +51,10 @@ function transformUser(user) {
username: user.username, username: user.username,
email: user.email, email: user.email,
isActive: user.is_active, isActive: user.is_active,
lastLogin: user.last_login, lastLogin: toIso(user.last_login),
lastLoginIp: user.last_login_ip, lastLoginIp: user.last_login_ip,
createdAt: user.created_at, createdAt: toIso(user.created_at),
updatedAt: user.updated_at, updatedAt: toIso(user.updated_at),
roleId: user.role_id, roleId: user.role_id,
roleName: user.role_name, roleName: user.role_name,
roleDisplayName: user.role_display_name, roleDisplayName: user.role_display_name,
@@ -52,8 +83,8 @@ function transformInvitation(invitation) {
return { return {
id: invitation.id, id: invitation.id,
email: invitation.email, email: invitation.email,
expiresAt: invitation.expires_at, expiresAt: toIso(invitation.expires_at),
createdAt: invitation.created_at, createdAt: toIso(invitation.created_at),
roleName: invitation.role_name, roleName: invitation.role_name,
invitedBy: invitation.invited_by invitedBy: invitation.invited_by
}; };
@@ -212,4 +243,7 @@ router.post('/:id/reset-password', [
successResponse(res, { message: 'Password reset email sent', ...result }); successResponse(res, { message: 'Password reset email sent', ...result });
})); }));
// Test surface: expose the date normaliser so the unit test can pin
// the contract without spinning up the full router.
module.exports = router; module.exports = router;
module.exports.__test = { toIso, transformUser, transformInvitation };
+12 -1
View File
@@ -476,7 +476,18 @@ router.post('/gallery/logout', async (req, res) => {
router.get('/session', async (req, res) => { router.get('/session', async (req, res) => {
try { try {
const { slug } = req.query; const { slug } = req.query;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug); // Token precedence: when ?slug= is present the caller is asking
// specifically about gallery auth (GalleryAuthContext), so prefer the
// gallery token. Without this, an admin who's also dogfooding the
// customer dashboard from the same browser would always get
// {type:'admin'} back here, the gallery context's
// `type === 'gallery'` check would fail, and the page would fall
// through to the per-event password prompt — even though the
// gallery_token_<slug> cookie was correctly set on the prior
// /api/customer/events/:slug/access-token response.
const token = slug
? (getGalleryTokenFromRequest(req, slug) || getAdminTokenFromRequest(req))
: (getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug));
if (!token) { if (!token) {
return res.status(401).json({ error: 'No token provided' }); return res.status(401).json({ error: 'No token provided' });
+371
View File
@@ -0,0 +1,371 @@
/**
* Customer dashboard routes
*
* Mounted at /api/customer (see server.js). Every endpoint here requires
* a valid 'customer' JWT see middleware/customerAuth.js.
*
* Endpoints:
* GET /events list assigned events for dashboard
* GET /events/:slug/access-token mint a gallery JWT so the customer
* can browse the event without going
* through the per-event password gate
*/
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, param, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const { getClientIp } = require('../utils/requestIp');
const { customerAuth } = require('../middleware/customerAuth');
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
const customerAccountsService = require('../services/customerAccountsService');
/**
* Customer-side password policy mirrors the one in customerAuth.js kept
* deliberately simple (8 chars, one uppercase, one digit) since a customer
* account only sees galleries, never financial or admin surfaces.
*/
function validateCustomerPassword(password) {
if (typeof password !== 'string' || password.length < 8) {
return 'Password must be at least 8 characters long.';
}
if (!/[A-Z]/.test(password)) return 'Password must contain at least one uppercase letter.';
if (!/[0-9]/.test(password)) return 'Password must contain at least one number.';
return null;
}
/**
* Camelsnake mapping used by the self-service profile PUT. Same field set
* as the admin update endpoint minus is_active (admin-only) and
* preferred_language / notes (admin-only metadata, not customer-facing).
*/
const PROFILE_FIELD_MAP = {
salutation: 'salutation',
firstName: 'first_name',
lastName: 'last_name',
displayName: 'display_name',
phone: 'phone',
companyName: 'company_name',
vatId: 'vat_id',
addressLine1: 'address_line1',
addressLine2: 'address_line2',
postalCode: 'postal_code',
city: 'city',
state: 'state',
countryCode: 'country_code',
preferredLanguage: 'preferred_language',
};
function shapeProfile(row) {
if (!row) return null;
return {
id: row.id,
email: row.email,
salutation: row.salutation,
firstName: row.first_name,
lastName: row.last_name,
displayName: row.display_name,
phone: row.phone,
companyName: row.company_name,
vatId: row.vat_id,
addressLine1: row.address_line1,
addressLine2: row.address_line2,
postalCode: row.postal_code,
city: row.city,
state: row.state,
countryCode: row.country_code,
preferredLanguage: row.preferred_language || 'en',
};
}
const router = express.Router();
const GALLERY_TOKEN_TTL_SECONDS = 24 * 60 * 60;
// ---- list assigned events ---------------------------------------------
router.get('/events', customerAuth, async (req, res) => {
try {
const events = await customerAccountsService.listEventsForCustomer(req.customer.id);
res.json({
events: events.map((e) => ({
id: e.id,
slug: e.slug,
eventName: e.event_name,
eventType: e.event_type,
eventDate: e.event_date,
expiresAt: e.expires_at,
isActive: e.is_active,
assignedAt: e.assigned_at,
})),
});
} catch (error) {
logger.error('Customer event list error:', error);
res.status(500).json({ error: 'Failed to load events' });
}
});
// ---- access-token exchange --------------------------------------------
/**
* Customer JWT Gallery JWT exchange.
*
* The gallery API and frontend already expect a 'gallery' token in the
* gallery_token / gallery_token_{slug} cookie. Rather than teach every
* gallery code path about a third token type, we mint a fresh gallery
* token here when the customer is assigned to the event. The frontend
* stores it in the slug-specific cookie via the existing
* storeGalleryToken() utility, and from that point on the gallery loads
* exactly as if the per-event password had been entered.
*
* Returns 403 if the customer is not assigned, 404 if the event slug is
* unknown, 410 if the event is archived/expired (so the dashboard can
* surface a useful "this gallery has expired" message rather than just
* an opaque 403).
*/
router.get('/events/:slug/access-token', [
customerAuth,
param('slug').isString().notEmpty(),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug } = req.params;
const event = await db('events').where('slug', slug).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (event.is_archived) {
return res.status(410).json({ error: 'This gallery has been archived' });
}
if (event.expires_at && new Date(event.expires_at) < new Date()) {
return res.status(410).json({ error: 'This gallery has expired' });
}
const hasAccess = await customerAccountsService.customerHasAccessToEvent(
req.customer.id,
event.id
);
if (!hasAccess) {
logger.warn('Customer attempted to access unassigned event', {
customerId: req.customer.id,
eventId: event.id,
slug,
});
return res.status(403).json({ error: 'You do not have access to this gallery' });
}
const ipAddress = getClientIp(req);
// Same shape as /api/auth/gallery/verify — keep them in sync so the
// gallery middleware (verifyGalleryAccess) doesn't need a code change.
const token = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
ip: ipAddress,
loginTime: Date.now(),
// Optional bookkeeping claim — surfaces the originating customer in
// logs when the token is later used. Doesn't affect authorization.
via: 'customer',
customerId: req.customer.id,
}, process.env.JWT_SECRET, {
expiresIn: GALLERY_TOKEN_TTL_SECONDS,
issuer: 'picpeak-auth',
});
// Mirror the cookie-write that /api/auth/gallery/verify performs on
// password success. Without this, the freshly-minted token only lives
// in the dashboard's sessionStorage; GalleryAuthProvider runs
// cleanupOldGalleryAuth() on mount and sweeps every gallery_token_*
// sessionStorage key, including the one we just stored. The cookie
// (which that cleanup helper does NOT touch when it's slug-scoped)
// is what keeps the customer authenticated after navigation, hard
// reloads, and tab restores.
setGalleryAuthCookies(res, token, event.slug);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: req.headers['user-agent'] || '',
action: 'login_success',
});
await logActivity('customer_event_access',
{ customerId: req.customer.id, eventId: event.id, slug },
event.id,
{ type: 'customer', id: req.customer.id, name: req.customer.email }
);
res.json({
token,
event: {
id: event.id,
slug: event.slug,
eventName: event.event_name,
},
});
} catch (error) {
logger.error('Customer access-token exchange error:', error);
res.status(500).json({ error: 'Failed to issue access token' });
}
});
// ---- self-service profile ----------------------------------------------
/**
* GET /profile
*
* Returns the full customer profile (everything the customer can edit on
* their own profile page). The /auth/session endpoint deliberately stays
* narrow only the fields the layout needs to keep the auth payload
* tight; this endpoint is the canonical "give me everything" read.
*/
router.get('/profile', customerAuth, async (req, res) => {
try {
const row = await db('customer_accounts').where('id', req.customer.id).first();
if (!row) {
return res.status(404).json({ error: 'Profile not found' });
}
res.json({ profile: shapeProfile(row) });
} catch (error) {
logger.error('Customer profile read error:', error);
res.status(500).json({ error: 'Failed to load profile' });
}
});
/**
* PUT /profile
*
* Self-service edit. Accepts the same field set as the admin endpoint but
* deliberately excludes:
* - email (would invalidate the login credential silently)
* - is_active (admin-only)
* - notes (admin-only metadata)
* - billing_email (kept admin-managed for now; we'll surface it later
* when the quotes/bills flows actually need a separate
* billing contact)
* - password_hash (separate /profile/password endpoint)
*/
router.put('/profile', [
customerAuth,
body('salutation').optional({ nullable: true }).isString().isLength({ max: 32 }),
body('firstName').optional({ nullable: true }).isString().isLength({ max: 80 }),
body('lastName').optional({ nullable: true }).isString().isLength({ max: 80 }),
body('displayName').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('phone').optional({ nullable: true }).isString().isLength({ max: 40 }),
body('companyName').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('vatId').optional({ nullable: true }).isString().isLength({ max: 40 }),
body('addressLine1').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('addressLine2').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('postalCode').optional({ nullable: true }).isString().isLength({ max: 20 }),
body('city').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('state').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('countryCode').optional({ nullable: true }).isString().isLength({ max: 2 }),
body('preferredLanguage').optional().isString().isLength({ max: 8 }),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Normalise incoming values: trim strings, drop empty → null so the DB
// doesn't end up with `' '` rows that look populated but render blank.
const updates = {};
for (const [camel, snake] of Object.entries(PROFILE_FIELD_MAP)) {
if (!Object.prototype.hasOwnProperty.call(req.body, camel)) continue;
let value = req.body[camel];
if (typeof value === 'string') value = value.trim();
if (value === '') value = null;
if (snake === 'country_code' && value) {
value = String(value).toUpperCase().slice(0, 2);
}
updates[snake] = value;
}
updates.updated_at = new Date();
await db('customer_accounts').where('id', req.customer.id).update(updates);
const row = await db('customer_accounts').where('id', req.customer.id).first();
await logActivity('customer_self_profile_update',
{ customerId: req.customer.id, fields: Object.keys(updates).filter((k) => k !== 'updated_at') },
null,
{ type: 'customer', id: req.customer.id, name: req.customer.email }
);
res.json({ profile: shapeProfile(row) });
} catch (error) {
logger.error('Customer profile update error:', error);
res.status(500).json({ error: 'Failed to update profile' });
}
});
/**
* POST /profile/password
*
* Customer changes their own password. Requires the current password as
* proof of identity (so a stolen session cookie can't pivot to a permanent
* takeover without also having the old password). Bumps
* password_changed_at so any other active sessions for this customer get
* invalidated on next request via the customerAuth middleware check.
*/
router.post('/profile/password', [
customerAuth,
body('currentPassword').isString().isLength({ min: 1 }),
body('newPassword').isString().isLength({ min: 8 })
.withMessage('Password must be at least 8 characters'),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { currentPassword, newPassword } = req.body;
const policyError = validateCustomerPassword(newPassword);
if (policyError) {
return res.status(400).json({
error: 'Password does not meet complexity requirements',
details: [policyError],
});
}
const row = await db('customer_accounts').where('id', req.customer.id).first();
if (!row || !row.password_hash) {
return res.status(400).json({ error: 'Password change unavailable' });
}
const ok = await bcrypt.compare(currentPassword, row.password_hash);
if (!ok) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
const newHash = await bcrypt.hash(newPassword, getBcryptRounds());
await db('customer_accounts').where('id', req.customer.id).update({
password_hash: newHash,
password_changed_at: new Date(),
updated_at: new Date(),
});
await logActivity('customer_password_change',
{ customerId: req.customer.id },
null,
{ type: 'customer', id: req.customer.id, name: req.customer.email }
);
res.json({ message: 'Password updated' });
} catch (error) {
logger.error('Customer password change error:', error);
res.status(500).json({ error: 'Failed to change password' });
}
});
module.exports = router;
+383
View File
@@ -0,0 +1,383 @@
/**
* Customer-side auth routes
*
* Mounted at /api/customer/auth (see server.js wiring). Strictly separate
* from /api/auth/* (admin) and /api/auth/gallery/* (per-event guests).
*
* Endpoints:
* POST /login email + password customer_token cookie
* POST /logout revoke + clear cookie
* GET /session echo current customer for frontend boot
* GET /invite/:token public, returns invite metadata
* POST /accept-invite public, completes the invitation
*/
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, param, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const {
trackFailedAttempt,
trackSuccessfulLogin,
checkAccountLockout,
getGenericAuthError,
} = require('../utils/authSecurity');
const { revokeToken } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const {
setCustomerAuthCookie,
clearCustomerAuthCookie,
getCustomerTokenFromRequest,
} = require('../utils/tokenUtils');
const { getClientIp } = require('../utils/requestIp');
// NOTE: customers intentionally do NOT go through validatePasswordInContext
// (the admin-grade policy that can require special chars, dictionary checks,
// breach lists, etc.). Customers are end-users picking a one-off password —
// the friction of the admin policy turned them away. We enforce a simple,
// human-readable rule below: minimum length, at least one uppercase letter,
// at least one digit. No special-character or breach-list requirement.
const customerAccountsService = require('../services/customerAccountsService');
const { customerAuth } = require('../middleware/customerAuth');
const router = express.Router();
const TOKEN_TTL_SECONDS = 24 * 60 * 60; // mirrors admin tokens
// ---- login -------------------------------------------------------------
// The customerPortal feature flag deliberately does NOT gate this route.
// Flipping the master toggle off in Settings → Features hides the
// admin-side Clients section (sidebar entry, /admin/clients pages) but
// must not revoke access for customers who already accepted an
// invitation — that would mean a stray click in the Features tab
// locks every paying customer out at once.
//
// To revoke access at the customer level, use the per-record tools:
// - "Deactivate" on the customer detail page → sets
// customer_accounts.is_active = false AND bumps password_changed_at,
// which customerAuth rejects below + on every protected route.
// - "Manage galleries" dialog → removes event_customer_assignments
// rows, which verifyGalleryAccess re-checks on customer-minted
// gallery JWTs (instant per-gallery revocation).
router.post('/login', [
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('password').isString().notEmpty(),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { email, password, recaptchaToken } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Lockout key includes a `customer:` prefix so admin and customer
// attempt counters don't share a bucket — an attacker hitting an
// admin login with the same email should not lock out the customer
// account or vice versa.
const lockoutKey = `customer:${email}`;
const lockoutStatus = await checkAccountLockout(lockoutKey);
if (lockoutStatus.isLocked) {
logger.warn('Customer login attempt on locked account', { email, ipAddress });
return res.status(423).json({
error: 'Account temporarily locked due to too many failed attempts',
retryAfter: lockoutStatus.remainingTime,
});
}
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const customer = await db('customer_accounts').where('email', email).first();
// Generic error to prevent user enumeration — same wording as admin login.
if (!customer || !customer.password_hash || !await bcrypt.compare(password, customer.password_hash)) {
await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
if (!customer.is_active) {
await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
await db('customer_accounts').where('id', customer.id).update({
last_login: new Date(),
last_login_ip: ipAddress,
});
const token = jwt.sign({
customerId: customer.id,
email: customer.email,
type: 'customer',
ip: ipAddress,
loginTime: Date.now(),
}, process.env.JWT_SECRET, {
expiresIn: TOKEN_TTL_SECONDS,
issuer: 'picpeak-auth',
});
setCustomerAuthCookie(res, token);
await logActivity('customer_login',
{ customerId: customer.id, email: customer.email, ipAddress },
null,
{ type: 'customer', id: customer.id, name: customer.email }
);
// Resolve effective features + branding right here so the login
// response carries the same shape as /session. Without this, the
// first-render dashboard after login would use the context's
// DEFAULT_FEATURES (all false) — features only "appear" on the next
// CustomerAuthProvider mount (e.g. after the user navigates to a
// gallery and back). Mirroring the /session resolution keeps the
// frontend on a single source of truth.
let features = { calendar: false, quotes: false, bills: false };
let branding = { showLogo: true, showCompanyName: true };
try {
features = await customerAccountsService.getEffectiveFeaturesForCustomer(customer);
const globals = await customerAccountsService.getCustomerSurfaceGlobals();
branding = { showLogo: globals.showLogo, showCompanyName: globals.showCompanyName };
} catch (e) {
logger.warn('Customer login: failed to resolve features/branding, using defaults', { error: e?.message });
}
res.json({
customer: {
id: customer.id,
email: customer.email,
displayName: customer.display_name,
firstName: customer.first_name,
lastName: customer.last_name,
preferredLanguage: customer.preferred_language || 'en',
},
features,
branding,
});
} catch (error) {
logger.error('Customer login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
// ---- logout ------------------------------------------------------------
router.post('/logout', async (req, res) => {
try {
const token = getCustomerTokenFromRequest(req);
if (token) {
await revokeToken(token, 'user_logout');
}
clearCustomerAuthCookie(res);
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Customer logout error:', error);
// Always clear the cookie even if revocation failed — the client must
// not stay locked into a half-broken session.
clearCustomerAuthCookie(res);
res.status(500).json({ error: 'Logout failed' });
}
});
// ---- session echo ------------------------------------------------------
router.get('/session', customerAuth, async (req, res) => {
// Resolve the effective feature set (global toggle AND per-customer flag)
// and the branding visibility globals so the customer frontend can render
// the correct sidebar without an extra round-trip on every navigation.
// Failure here is non-fatal — the customer should still be able to see
// their galleries even if the settings table is briefly unavailable.
let features = { calendar: false, quotes: false, bills: false };
let branding = { showLogo: true, showCompanyName: true };
try {
features = await customerAccountsService.getEffectiveFeaturesForCustomer(req.customer.id);
const globals = await customerAccountsService.getCustomerSurfaceGlobals();
branding = { showLogo: globals.showLogo, showCompanyName: globals.showCompanyName };
} catch (e) {
logger.warn('Customer session: failed to resolve features/branding, using defaults', { error: e?.message });
}
res.json({ customer: req.customer, features, branding });
});
// ---- invitation lifecycle (public) -------------------------------------
router.get('/invite/:token', [
param('token').isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(404).json({ error: 'Invalid invitation link' });
}
const invitation = await customerAccountsService.validateInvitationToken(req.params.token);
if (!invitation) {
return res.status(404).json({ error: 'Invalid or expired invitation' });
}
res.json({
invitation: {
email: invitation.email,
expiresAt: invitation.expires_at,
invitedBy: invitation.invited_by_username,
// Surface admin-supplied prefill so the accept page can populate
// its profile form. Customer can still edit any field — we just
// saved them some typing.
prefill: invitation.prefill || null,
},
});
} catch (error) {
logger.error('Customer invite lookup error:', error);
res.status(500).json({ error: 'Failed to load invitation' });
}
});
/**
* Customer-specific password policy.
*
* Intentionally simpler than validatePasswordInContext (the admin-grade
* checker). Rules:
* - At least 8 characters
* - At least one uppercase letter (AZ)
* - At least one digit (09)
*
* No special-character requirement, no breach-list lookup, no dictionary
* check those tripped up real customers picking real passwords (e.g.
* "PartyTime2026"). Capitals + a number is enough entropy for an
* account that only views galleries; it's not protecting financial data.
*
* Returns null on success, or a string error message on failure.
*/
function validateCustomerPassword(password) {
if (typeof password !== 'string' || password.length < 8) {
return 'Password must be at least 8 characters long.';
}
if (!/[A-Z]/.test(password)) {
return 'Password must contain at least one uppercase letter.';
}
if (!/[0-9]/.test(password)) {
return 'Password must contain at least one number.';
}
return null;
}
router.post('/accept-invite', [
body('token').isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
body('name').optional({ nullable: true }).isString().trim().isLength({ max: 120 }),
// Length floor enforced again here for an early reject; the full
// policy (uppercase + digit) is checked below so we can surface a
// specific message rather than a generic validator error.
body('password').isString().isLength({ min: 8 })
.withMessage('Password must be at least 8 characters'),
// Optional structured profile from the accept-invite form. Mirrors
// the admin prefill shape — anything the customer types here wins
// over the admin prefill stashed on the invitation row.
body('profile').optional().isObject(),
body('profile.salutation').optional({ nullable: true }).isString().isLength({ max: 32 }),
body('profile.first_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
body('profile.last_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
body('profile.display_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('profile.phone').optional({ nullable: true }).isString().isLength({ max: 40 }),
body('profile.company_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('profile.vat_id').optional({ nullable: true }).isString().isLength({ max: 40 }),
body('profile.address_line1').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('profile.address_line2').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('profile.postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }),
body('profile.city').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('profile.state').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('profile.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { token, name, password, profile } = req.body;
const policyError = validateCustomerPassword(password);
if (policyError) {
return res.status(400).json({
error: 'Password does not meet complexity requirements',
details: [policyError],
});
}
const result = await customerAccountsService.acceptInvitation({ token, name, password, profile });
res.json({ message: 'Invitation accepted', email: result.email });
} catch (error) {
if (error.code === 'CONFLICT' || error.statusCode === 409) {
return res.status(409).json({ error: error.message });
}
if (error.code === 'VALIDATION' || error.statusCode === 400) {
return res.status(400).json({ error: error.message });
}
logger.error('Customer invite accept error:', error);
res.status(500).json({ error: 'Failed to accept invitation' });
}
});
// ---- password reset (public) -------------------------------------------
/**
* GET /password-reset/:token (#354 follow-up).
*
* Validate a reset token without consuming it so the reset page can show
* "you're resetting the password for {{email}}" before the user submits.
*/
router.get('/password-reset/:token', [
param('token').isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(404).json({ error: 'Invalid reset link' });
const reset = await customerAccountsService.validatePasswordResetToken(req.params.token);
if (!reset) return res.status(404).json({ error: 'Invalid or expired reset link' });
res.json({ reset: { email: reset.email, expiresAt: reset.expires_at } });
} catch (error) {
logger.error('Customer reset lookup error:', error);
res.status(500).json({ error: 'Failed to validate reset link' });
}
});
/**
* POST /password-reset (#354 follow-up).
*
* Apply a reset: token + new password. Same simple password policy as
* the accept-invite path (8 chars, uppercase, digit). The service marks
* the reset row as used in the same transaction so a re-submitted token
* is rejected on the second click.
*/
router.post('/password-reset', [
body('token').isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
body('password').isString().isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const policyError = validateCustomerPassword(req.body.password);
if (policyError) {
return res.status(400).json({
error: 'Password does not meet complexity requirements',
details: [policyError],
});
}
const result = await customerAccountsService.applyPasswordReset({
token: req.body.token,
password: req.body.password,
});
res.json({ message: 'Password updated', email: result.email });
} catch (error) {
if (error.code === 'VALIDATION' || error.statusCode === 400) {
return res.status(400).json({ error: error.message });
}
logger.error('Customer reset apply error:', error);
res.status(500).json({ error: 'Failed to reset password' });
}
});
module.exports = router;
+3 -2
View File
@@ -4,6 +4,7 @@ const bcrypt = require('bcrypt');
const crypto = require('crypto'); const crypto = require('crypto');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { slugify } = require('../utils/slug');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const fs = require('fs').promises; const fs = require('fs').promises;
@@ -125,8 +126,8 @@ router.post('/', adminAuth, [
} }
} }
// Generate unique slug // Generate unique slug — slugify() handles accents (see #525).
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`; const baseSlug = `${event_type}-${slugify(event_name)}-${event_date}`;
let slug = baseSlug; let slug = baseSlug;
let counter = 1; let counter = 1;
+320 -75
View File
@@ -13,8 +13,14 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync } = require('../utils/routeHelpers'); const { handleAsync } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors'); const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage } = require('../services/imageProcessor'); const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
const downloadZipService = require('../services/downloadZipService'); const downloadZipService = require('../services/downloadZipService');
const {
getUseOriginalFilenames,
pickRawDownloadName,
getZipEntryNames,
} = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const { getStorage } = require('../services/storage'); const { getStorage } = require('../services/storage');
const fs = require('fs'); const fs = require('fs');
@@ -127,7 +133,12 @@ router.get('/:slug/info', async (req, res) => {
'hero_divider_style', 'hero_divider_style',
'hero_image_anchor', 'hero_image_anchor',
'is_draft', 'is_draft',
'default_photo_sort' 'default_photo_sort',
// Per-event promotional override (#440). Resolution into a
// ready-to-render markdown string happens below so the
// frontend doesn't have to know about modes.
'promo_mode',
'promo_markdown'
) )
.first(); .first();
@@ -187,7 +198,11 @@ router.get('/:slug/info', async (req, res) => {
header_style: event.header_style || 'standard', header_style: event.header_style || 'standard',
hero_divider_style: event.hero_divider_style || 'wave', hero_divider_style: event.hero_divider_style || 'wave',
hero_image_anchor: event.hero_image_anchor || 'center', hero_image_anchor: event.hero_image_anchor || 'center',
default_photo_sort: event.default_photo_sort || 'upload_date_desc' default_photo_sort: event.default_photo_sort || 'upload_date_desc',
// Per-event promotional override (#440). Frontend resolves
// 'inherit' against branding_promo_markdown from public settings.
promo_mode: event.promo_mode || 'inherit',
promo_markdown: event.promo_markdown || null
}); });
} catch (error) { } catch (error) {
console.error('Error fetching gallery info:', error); console.error('Error fetching gallery info:', error);
@@ -391,6 +406,38 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
overlay_protection: req.event.overlay_protection !== false overlay_protection: req.event.overlay_protection !== false
}; };
// Lightbox preview tier (#492). When the admin opts in, the
// photos response carries a preview_url alongside url/thumbnail_url
// — the lightbox uses preview_url when present and falls back to
// url when not, so existing galleries continue working before
// any preview has actually been generated.
let lightboxPreviewEnabled = false;
try {
const setting = await db('app_settings')
.where('setting_key', 'lightbox_preview_enabled')
.first();
if (setting) {
const raw = setting.setting_value;
// setting_value is JSON-stringified per migration 104; tolerate
// raw boolean/string for forward-compat.
const parsed = typeof raw === 'string' ? (() => {
try { return JSON.parse(raw); } catch { return raw; }
})() : raw;
lightboxPreviewEnabled = parsed === true || parsed === 'true' || parsed === 1;
}
} catch (e) {
// Setting missing / DB blip → fall back to off so the lightbox
// keeps working with the original. logger.debug to avoid noise.
logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message });
}
// #508: when the admin has flipped the "use original camera filenames"
// toggle (#493), the lightbox surfaces each photo's original_filename
// alongside the position counter so the photographer can map a guest's
// selection back to source files. Tied to the same toggle as downloads —
// one switch controls both surfaces.
const useOriginalFilenames = await getUseOriginalFilenames();
res.json({ res.json({
event: { event: {
@@ -418,6 +465,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
hero_image_anchor: req.event.hero_image_anchor || 'center', hero_image_anchor: req.event.hero_image_anchor || 'center',
default_photo_sort: req.event.default_photo_sort || 'upload_date_desc', default_photo_sort: req.event.default_photo_sort || 'upload_date_desc',
download_zip_ready: !!(req.event.download_zip_path && req.event.download_zip_generated_at), download_zip_ready: !!(req.event.download_zip_path && req.event.download_zip_generated_at),
// Mirror of the admin-side toggle so the lightbox can decide
// whether to surface original camera filenames (#508).
use_original_filenames: useOriginalFilenames,
...protectionSettings ...protectionSettings
}, },
categories: categories, categories: categories,
@@ -432,10 +482,24 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
return { return {
id: photo.id, id: photo.id,
filename: photo.filename, filename: photo.filename,
// Raw camera filename (or null for pre-migration-062 uploads).
// The lightbox renders it when `use_original_filenames` is on.
original_filename: photo.original_filename || null,
url: photoUrl, url: photoUrl,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null, thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
// Hero-optimized image URL (1920x1080) for full-width hero sections // Hero-optimized image URL (1920x1080) for full-width hero sections
hero_url: `/api/gallery/${req.params.slug}/hero/${photo.id}${wmQuery}`, hero_url: `/api/gallery/${req.params.slug}/hero/${photo.id}${wmQuery}`,
// Lightbox preview URL (#492). Only emitted when the admin
// has flipped lightbox_preview_enabled — the frontend
// lightbox reads preview_url with a fallback to url so
// installs that haven't opted in keep loading the original
// (current behaviour). Skipped for videos since they don't
// get a preview tier; lightbox will use the original .url.
preview_url: lightboxPreviewEnabled
&& photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
: null,
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`, secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`, download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type, type: photo.type,
@@ -587,6 +651,13 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1; const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled; const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
// #493: if the admin enabled "use original filenames", surface the
// pre-rename camera filename in Content-Disposition. Storage path is
// unchanged — only the user-visible download name is swapped.
const useOriginal = await getUseOriginalFilenames();
const downloadName = pickRawDownloadName(photo, useOriginal);
const contentDisposition = buildContentDisposition(downloadName);
if (shouldApplyWatermark) { if (shouldApplyWatermark) {
// Apply watermark and send // Apply watermark and send
// Use event watermark text if available, otherwise fall back to global settings // Use event watermark text if available, otherwise fall back to global settings
@@ -599,14 +670,21 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
res.set({ res.set({
'Content-Type': photo.mime_type || 'image/jpeg', 'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Disposition': `attachment; filename="${photo.filename}"`, 'Content-Disposition': contentDisposition,
'Content-Length': watermarkedBuffer.length 'Content-Length': watermarkedBuffer.length
}); });
res.send(watermarkedBuffer); res.send(watermarkedBuffer);
} else { } else {
// Send original file // res.download() builds Content-Disposition itself but doesn't emit the
res.download(filePath, photo.filename, (downloadError) => { // RFC 5987 filename* parameter, so unicode camera filenames would lose
// their bytes on download. Set the header explicitly and stream the
// file with res.sendFile-equivalent semantics.
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Disposition': contentDisposition,
});
res.sendFile(filePath, (downloadError) => {
if (downloadError) { if (downloadError) {
logger.error('Error streaming gallery download', { logger.error('Error streaming gallery download', {
slug: req.params.slug, slug: req.params.slug,
@@ -728,14 +806,20 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
// Add photos to archive — managed photos via storage backend, external via local path. // Add photos to archive — managed photos via storage backend, external via local path.
const { resolvePhotoStorageKey } = require('../services/photoResolver'); const { resolvePhotoStorageKey } = require('../services/photoResolver');
const storage = getStorage(); const storage = getStorage();
for (const photo of photos) { // #493: resolve a unique display filename per photo up-front so collisions
// get a deterministic `_1` suffix before the entries hit the archive.
const useOriginalBulk = await getUseOriginalFilenames();
const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk);
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
const storageKey = resolvePhotoStorageKey(req.event, photo); const storageKey = resolvePhotoStorageKey(req.event, photo);
const entryName = bulkEntryNames[i];
let archiveName; let archiveName;
if (hasMultipleTypes) { if (hasMultipleTypes) {
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages'; const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
archiveName = path.join(folderName, photo.filename); archiveName = path.join(folderName, entryName);
} else { } else {
archiveName = photo.filename; archiveName = entryName;
} }
try { try {
@@ -856,8 +940,12 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../services/photoResolver'); const { resolvePhotoStorageKey: resolveSelectedKey } = require('../services/photoResolver');
const { withLocalCopy: withSelectedLocalCopy } = require('../services/imageProcessor'); const { withLocalCopy: withSelectedLocalCopy } = require('../services/imageProcessor');
const selectedStorage = getStorage(); const selectedStorage = getStorage();
for (const photo of photos) { // #493: same display-name resolution as bulk download, with dedup.
const name = photo.filename || `photo-${photo.id}.jpg`; const useOriginalSelected = await getUseOriginalFilenames();
const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected);
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`;
const storageKey = resolveSelectedKey(req.event, photo); const storageKey = resolveSelectedKey(req.event, photo);
try { try {
if (shouldApplyWatermark && effectiveSettings) { if (shouldApplyWatermark && effectiveSettings) {
@@ -937,11 +1025,47 @@ router.get('/:slug/photo/:photoId',
}); });
} }
// Resolve the absolute file path for this photo, supporting both managed and external reference modes // Resolve where to read the photo bytes from. For external/reference
const { resolvePhotoFilePath } = require('../services/photoResolver'); // photos the source is always a local mount path. For managed photos
const fs = require('fs'); // we go through the storage abstraction so S3 deployments work too
// (#432 — previously this route did fs.* directly and 500'd in S3
// mode because the file wasn't on the container's local fs).
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver');
const storage = getStorage();
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
const useStorageBackend = !isExternal;
let filePath; let filePath = null; // Local fs path (external photos OR LocalFs storage)
let storageKey = null; // Relative storage key (managed photos via storage abstraction)
let stat;
let fileSize;
if (useStorageBackend) {
try {
storageKey = resolvePhotoStorageKey(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo storage key', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
photoPath: photo.path,
photoFilename: photo.filename
});
return res.status(404).json({ error: 'Photo file not found' });
}
stat = await storage.stat(storageKey);
if (!stat) {
logger.error('Photo not found in storage backend', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
storageKey
});
return res.status(404).json({ error: 'Photo file not found' });
}
fileSize = stat.size;
} else {
try { try {
filePath = resolvePhotoFilePath(req.event, photo); filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) { } catch (resolveError) {
@@ -955,8 +1079,6 @@ router.get('/:slug/photo/:photoId',
}); });
return res.status(404).json({ error: 'Photo file not found' }); return res.status(404).json({ error: 'Photo file not found' });
} }
// Verify file exists before attempting to serve
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
logger.error('Photo file does not exist at resolved path', { logger.error('Photo file does not exist at resolved path', {
slug: req.params.slug, slug: req.params.slug,
@@ -967,28 +1089,19 @@ router.get('/:slug/photo/:photoId',
}); });
return res.status(404).json({ error: 'Photo file not found' }); return res.status(404).json({ error: 'Photo file not found' });
} }
stat = fs.statSync(filePath);
// Log access - temporarily disabled for debugging fileSize = stat.size;
// await secureImageService.logImageAccess( }
// photoId,
// req.event.id,
// req.clientInfo,
// 'view_basic'
// );
// Handle video streaming with range requests // Handle video streaming with range requests
if (isVideo) { if (isVideo) {
const stat = fs.statSync(filePath);
const fileSize = stat.size;
const range = req.headers.range; const range = req.headers.range;
if (range) { if (range) {
// Parse range header
const parts = range.replace(/bytes=/, '').split('-'); const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10); const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1; const chunksize = (end - start) + 1;
const file = fs.createReadStream(filePath, { start, end });
res.writeHead(206, { res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
@@ -999,9 +1112,11 @@ router.get('/:slug/photo/:photoId',
'X-Protection-Level': 'basic' 'X-Protection-Level': 'basic'
}); });
const file = useStorageBackend
? await storage.getRange(storageKey, start, end)
: fs.createReadStream(filePath, { start, end });
file.pipe(res); file.pipe(res);
} else { } else {
// No range request, send entire file
res.writeHead(200, { res.writeHead(200, {
'Content-Length': fileSize, 'Content-Length': fileSize,
'Content-Type': photo.mime_type || 'video/mp4', 'Content-Type': photo.mime_type || 'video/mp4',
@@ -1009,35 +1124,47 @@ router.get('/:slug/photo/:photoId',
'Cache-Control': 'private, max-age=1800', 'Cache-Control': 'private, max-age=1800',
'X-Protection-Level': 'basic' 'X-Protection-Level': 'basic'
}); });
const file = useStorageBackend
fs.createReadStream(filePath).pipe(res); ? await storage.get(storageKey)
: fs.createReadStream(filePath);
file.pipe(res);
} }
return; return;
} }
// Handle images (existing logic) // Image path
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
// Generate ETag based on photo id, modification time, and watermark settings const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
// This ensures cache invalidation when watermark settings change
const stat = fs.statSync(filePath);
const watermarkHash = watermarkSettings?.enabled const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm'; : '-nowm';
const etag = `"${photoId}-${stat.mtime.getTime()}${watermarkHash}"`; const etag = `"${photoId}-${mtimeMs}${watermarkHash}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) { if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); return res.status(304).end();
} }
if (watermarkSettings && watermarkSettings.enabled) { if (watermarkSettings && watermarkSettings.enabled) {
// Try to serve pre-generated watermarked file for instant loading // Pre-generated watermarked file: served via the storage backend
// (managed) or directly from local fs (external).
if (photo.watermark_path) { if (photo.watermark_path) {
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
try { try {
// Check if pre-generated watermark file exists if (useStorageBackend) {
const wmStat = await storage.stat(photo.watermark_path);
if (wmStat) {
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Length': wmStat.size,
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
const wmStream = await storage.get(photo.watermark_path);
return wmStream.pipe(res);
}
} else {
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
if (fs.existsSync(watermarkFilePath)) { if (fs.existsSync(watermarkFilePath)) {
res.set({ res.set({
'Content-Type': photo.mime_type || 'image/jpeg', 'Content-Type': photo.mime_type || 'image/jpeg',
@@ -1047,15 +1174,19 @@ router.get('/:slug/photo/:photoId',
}); });
return res.sendFile(watermarkFilePath); return res.sendFile(watermarkFilePath);
} }
}
} catch (err) { } catch (err) {
// File doesn't exist or error, fall through to on-the-fly generation
logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`); logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
} }
} }
// Fallback: Apply watermark on-the-fly (slower, but ensures image is served) // Fallback: apply watermark on-the-fly. applyWatermark needs a
// Also queue regeneration for next time // local file path (sharp + fs.readFile) — for managed photos in
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); // S3 mode, withLocalCopy materializes to a tmp file and cleans up.
const watermarkedBuffer = useStorageBackend
? await withLocalCopy(storageKey, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings))
: await watermarkService.applyWatermark(filePath, watermarkSettings);
// Queue watermark generation in background for next request // Queue watermark generation in background for next request
watermarkGeneratorService.generateForPhoto(photo.id) watermarkGeneratorService.generateForPhoto(photo.id)
@@ -1063,23 +1194,28 @@ router.get('/:slug/photo/:photoId',
res.set({ res.set({
'Content-Type': photo.mime_type || 'image/jpeg', 'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes 'Cache-Control': 'private, max-age=1800',
'ETag': etag, 'ETag': etag,
'X-Protection-Level': 'basic' 'X-Protection-Level': 'basic'
}); });
res.send(watermarkedBuffer); res.send(watermarkedBuffer);
} else { } else {
// Send original file with basic protection headers
res.set({ res.set({
'Cache-Control': 'private, max-age=1800', 'Cache-Control': 'private, max-age=1800',
'ETag': etag, 'ETag': etag,
'X-Protection-Level': 'basic' 'X-Protection-Level': 'basic'
}); });
// Ensure absolute path for res.sendFile if (useStorageBackend) {
res.set('Content-Length', stat.size);
if (photo.mime_type) res.set('Content-Type', photo.mime_type);
const stream = await storage.get(storageKey);
stream.pipe(res);
} else {
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
res.sendFile(absolutePath); res.sendFile(absolutePath);
} }
}
} catch (error) { } catch (error) {
logger.error('Error serving photo:', { logger.error('Error serving photo:', {
error: error.message, error: error.message,
@@ -1120,7 +1256,16 @@ router.get('/:slug/thumbnail/:photoId',
return res.status(404).json({ error: 'Thumbnail generation failed' }); return res.status(404).json({ error: 'Thumbnail generation failed' });
} }
const thumbPath = path.join(getStoragePath(), thumbnailPath); // Read thumbnail metadata via the storage abstraction so we work in
// both LocalFs and S3 modes (#432). The previous fs.statSync on the
// resolved local path 500'd in S3 deployments because the thumbnail
// only exists in the bucket, not on the container's local fs.
const storage = getStorage();
const stat = await storage.stat(thumbnailPath);
if (!stat) {
logger.error(`Thumbnail not found in storage backend for photo ${photoId}`, { thumbnailPath });
return res.status(404).json({ error: 'Thumbnail not found' });
}
// Log thumbnail access // Log thumbnail access
await secureImageService.logImageAccess( await secureImageService.logImageAccess(
@@ -1133,13 +1278,12 @@ router.get('/:slug/thumbnail/:photoId',
// Check if watermarks are enabled and apply to thumbnail // Check if watermarks are enabled and apply to thumbnail
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
// Generate ETag based on photo id, thumbnail modification time, and watermark settings // ETag uses storage stat mtime + photo id + watermark hash.
const fs = require('fs'); const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const stat = fs.statSync(thumbPath);
const watermarkHash = watermarkSettings?.enabled const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm'; : '-nowm';
const etag = `"thumb-${photoId}-${stat.mtime.getTime()}${watermarkHash}"`; const etag = `"thumb-${photoId}-${mtimeMs}${watermarkHash}"`;
// Check if client has valid cached version // Check if client has valid cached version
if (req.headers['if-none-match'] === etag) { if (req.headers['if-none-match'] === etag) {
@@ -1157,12 +1301,17 @@ router.get('/:slug/thumbnail/:photoId',
}); });
if (watermarkSettings && watermarkSettings.enabled) { if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark to thumbnail // Watermarking needs a local file path (sharp + fs.readFile).
const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings); // Materialize via withLocalCopy — no-op in local mode, downloads
// to a tmp file then cleans up in S3 mode.
const watermarkedBuffer = await withLocalCopy(thumbnailPath, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings)
);
res.send(watermarkedBuffer); res.send(watermarkedBuffer);
} else { } else {
// Send file without watermark res.setHeader('Content-Length', stat.size);
res.sendFile(path.resolve(thumbPath)); const stream = await storage.get(thumbnailPath);
stream.pipe(res);
} }
} catch (error) { } catch (error) {
logger.error('Error serving thumbnail:', { logger.error('Error serving thumbnail:', {
@@ -1211,30 +1360,27 @@ router.get('/:slug/hero/:photoId',
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`); return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
} }
const heroFullPath = path.join(getStoragePath(), heroPath); // Hero images are always written via the storage abstraction (see
const fs = require('fs'); // imageProcessor.generateHeroImage), so they're a managed-storage
// key in both LocalFs and S3 modes (#432). Read via storage.
// Verify file exists before attempting to serve const storage = getStorage();
if (!fs.existsSync(heroFullPath)) { const stat = await storage.stat(heroPath);
logger.error('Hero image file does not exist at resolved path', { if (!stat) {
logger.error('Hero image file does not exist in storage backend', {
slug: req.params.slug, slug: req.params.slug,
photoId, photoId,
eventId: req.event.id, eventId: req.event.id,
resolvedPath: heroFullPath heroPath
}); });
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`); return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
} }
// Get file stats for ETag const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const stat = fs.statSync(heroFullPath); const etag = `"hero-${photoId}-${mtimeMs}"`;
const etag = `"hero-${photoId}-${stat.mtime.getTime()}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) { if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); return res.status(304).end();
} }
// Check if watermarks should be applied
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
res.set({ res.set({
@@ -1247,12 +1393,16 @@ router.get('/:slug/hero/:photoId',
}); });
if (watermarkSettings && watermarkSettings.enabled) { if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark to hero image // applyWatermark needs a local file path; materialize via
const watermarkedBuffer = await watermarkService.applyWatermark(heroFullPath, watermarkSettings); // withLocalCopy so this works in S3 mode too.
const watermarkedBuffer = await withLocalCopy(heroPath, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings)
);
res.send(watermarkedBuffer); res.send(watermarkedBuffer);
} else { } else {
// Send hero image without watermark res.setHeader('Content-Length', stat.size);
res.sendFile(path.resolve(heroFullPath)); const stream = await storage.get(heroPath);
stream.pipe(res);
} }
} catch (error) { } catch (error) {
logger.error('Error serving hero image:', { logger.error('Error serving hero image:', {
@@ -1266,6 +1416,101 @@ router.get('/:slug/hero/:photoId',
} }
); );
// Lightbox preview tier (#492). Aspect-preserved JPEG capped at 1920px
// long edge — admin-controlled opt-in via app_settings.lightbox_preview_enabled.
// Mirrors the hero route shape: same auth, ETag from preview mtime,
// fall back to original on any failure so the lightbox never shows a
// broken image. The watermark application path is preserved so a
// preview surfaced in the lightbox carries the same protection a
// guest would see on the full original.
router.get('/:slug/preview/:photoId',
verifyGalleryAccess,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Videos don't get a preview tier — fall through to the regular
// photo endpoint (which serves the source). The frontend should
// already be checking media_type before requesting /preview but
// belt-and-braces in case a stale tab does.
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
if (isVideo) {
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
// Lazy generation: ensurePreviewImage returns null on any
// failure (corrupt source, sharp OOM, storage unavailable, …).
// Fall back to the original so the lightbox always renders.
const previewPath = await ensurePreviewImage(photo);
if (!previewPath) {
logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`);
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
const storage = getStorage();
const stat = await storage.stat(previewPath);
if (!stat) {
logger.error('Preview file does not exist in storage backend', {
slug: req.params.slug, photoId, eventId: req.event.id, previewPath,
});
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const watermarkSettings = await watermarkService.getWatermarkSettings();
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"preview-${photoId}-${mtimeMs}${watermarkHash}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({
'Content-Type': 'image/jpeg',
// Cache aggressively — preview only changes on photo
// re-upload (which generates a new preview key) or settings
// regenerate (which writes a new mtime + ETag).
'Cache-Control': 'private, max-age=3600',
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Preview-Image': 'true',
'ETag': etag,
});
if (watermarkSettings && watermarkSettings.enabled) {
const watermarkedBuffer = await withLocalCopy(previewPath, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings)
);
res.send(watermarkedBuffer);
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(previewPath);
stream.pipe(res);
}
} catch (error) {
logger.error('Error serving preview image:', {
error: error.message,
photoId: req.params.photoId,
eventId: req.event?.id,
});
res.redirect(`/api/gallery/${req.params.slug}/photo/${req.params.photoId}`);
}
}
);
// Get feedback settings for gallery // Get feedback settings for gallery
router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => { router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => {
try { try {
+5
View File
@@ -33,6 +33,11 @@ router.get('/pages/:slug', async (req, res) => {
// toggle can be flipped back on, but it shouldn't leak via the API). // toggle can be flipped back on, but it shouldn't leak via the API).
use_external_url: !!page.use_external_url, use_external_url: !!page.use_external_url,
external_url: page.use_external_url && page.external_url ? page.external_url : null, external_url: page.use_external_url && page.external_url ? page.external_url : null,
// Footer visibility (#441). Default true for legacy rows; admins
// can hide a CMS page from the gallery footer when their
// jurisdiction doesn't require it (e.g. impressum / datenschutz
// outside DE/AT).
show_in_footer: page.show_in_footer !== false,
updated_at: page.updated_at updated_at: page.updated_at
}); });
} catch (error) { } catch (error) {
+32
View File
@@ -16,6 +16,7 @@ router.get('/', async (req, res) => {
.orWhereIn('setting_key', [ .orWhereIn('setting_key', [
'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai', 'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai',
'event_default_require_password', 'event_default_require_password',
'event_default_feedback_enabled',
'gallery_show_filter_bar', 'gallery_show_filter_bar',
'event_phone_field_enabled' 'event_phone_field_enabled'
]); ]);
@@ -65,6 +66,24 @@ router.get('/', async (req, res) => {
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false, branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text', branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
branding_hide_powered_by: settingsObject.branding_hide_powered_by === true, branding_hide_powered_by: settingsObject.branding_hide_powered_by === true,
// Footer overhaul (#441 + #440). Empty strings hide each social
// icon individually; promo_markdown empty hides the slot for
// events in 'inherit' mode.
branding_facebook_url: settingsObject.branding_facebook_url || '',
branding_instagram_url: settingsObject.branding_instagram_url || '',
branding_whatsapp_url: settingsObject.branding_whatsapp_url || '',
branding_twitter_url: settingsObject.branding_twitter_url || '',
branding_youtube_url: settingsObject.branding_youtube_url || '',
branding_promo_markdown: settingsObject.branding_promo_markdown || '',
branding_promo_position: settingsObject.branding_promo_position === 'below_footer'
? 'below_footer'
: 'above_footer',
// Promo content alignment (#482). Defaults to center so the
// banner aligns with the gallery footer; admin can flip to
// left or right via Settings → Branding.
branding_promo_alignment: ['left', 'center', 'right'].includes(settingsObject.branding_promo_alignment)
? settingsObject.branding_promo_alignment
: 'center',
// Force a specific color mode site-wide. When set, the user toggle // Force a specific color mode site-wide. When set, the user toggle
// is hidden and the value overrides per-theme/system preference. // is hidden and the value overrides per-theme/system preference.
// Allowed values: 'dark' | 'light' | null (null = no force). // Allowed values: 'dark' | 'light' | null (null = no force).
@@ -73,6 +92,15 @@ router.get('/', async (req, res) => {
: settingsObject.branding_force_color_mode === 'light' : settingsObject.branding_force_color_mode === 'light'
? 'light' ? 'light'
: null, : null,
// Login-page-only branding (#354 follow-up). Applies exclusively
// to /admin/login and /customer/login — the rest of the app keeps
// using branding_logo_size / branding_logo_max_height. Default
// true / 'medium' preserves the visual state shipped before the
// toggles existed.
branding_login_logo_frame_enabled: settingsObject.branding_login_logo_frame_enabled !== false,
branding_login_logo_size: ['small', 'medium', 'large', 'xlarge'].includes(settingsObject.branding_login_logo_size)
? settingsObject.branding_login_logo_size
: 'medium',
theme_config: settingsObject.theme_config || null, theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en', default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false, enable_analytics: settingsObject.general_enable_analytics !== false,
@@ -93,6 +121,10 @@ router.get('/', async (req, res) => {
event_require_expiration: settingsObject.event_require_expiration !== false, event_require_expiration: settingsObject.event_require_expiration !== false,
// Default value for "Require password" toggle in event creation form // Default value for "Require password" toggle in event creation form
event_default_require_password: settingsObject.event_default_require_password !== false, event_default_require_password: settingsObject.event_default_require_password !== false,
// Default value for the "Guest Feedback enabled" toggle (#520).
// Defaults to false (matches the prior hard-coded form default), so
// existing installs see no behaviour change until an admin flips it.
event_default_feedback_enabled: settingsObject.event_default_feedback_enabled === true,
// Phone-number field on events is opt-in (#322). // Phone-number field on events is opt-in (#322).
event_phone_field_enabled: settingsObject.event_phone_field_enabled === true, event_phone_field_enabled: settingsObject.event_phone_field_enabled === true,
// Whether to show the search/sort filter bar in public galleries (default: true) // Whether to show the search/sort filter bar in public galleries (default: true)
+13 -1
View File
@@ -8,6 +8,11 @@ const { formatBoolean } = require('../utils/dbCompat');
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver'); const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
const { withLocalCopy } = require('../services/imageProcessor'); const { withLocalCopy } = require('../services/imageProcessor');
const { getStorage } = require('../services/storage'); const { getStorage } = require('../services/storage');
const {
getUseOriginalFilenames,
pickRawDownloadName,
} = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const router = express.Router(); const router = express.Router();
@@ -339,9 +344,16 @@ router.get('/:slug/secure-download/:photoId/:token',
'download' 'download'
); );
// #493/#507: respect the original-filename toggle here too. The
// regular `/gallery/:slug/download/:photoId` route already does
// this — secure-images was missed in the original PR and ran
// even when the admin had opted into original camera filenames.
const useOriginal = await getUseOriginalFilenames();
const downloadName = pickRawDownloadName(photo, useOriginal);
res.set({ res.set({
'Content-Type': photo.mime_type || 'image/jpeg', 'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Disposition': `attachment; filename="${photo.filename}"`, 'Content-Disposition': buildContentDisposition(downloadName),
'Content-Length': fileBuffer.length, 'Content-Length': fileBuffer.length,
'X-Download-Protected': 'true' 'X-Download-Protected': 'true'
}); });
@@ -0,0 +1,262 @@
/**
* Regression test for PR the_luap/picpeak#500.
*
* The v1 upload endpoint POST /events/:id/photos used to accept any
* photo_categories.id, including ones belonging to a different event.
* apiTokenAuth has no per-event scoping, so this let a programmatic
* uploader silently mis-file photos under a category that doesn't
* belong to the target event.
*
* The fix scopes the lookup to (event_id = event.id OR is_global = true)
* see backend/migrations/legacy/004_add_categories_and_cms.js for the
* photo_categories columns. These tests verify both that the scoping
* clause is exactly that, and that the 400 response carries the new
* "Unknown or out-of-scope category_id" error string.
*
* Pattern lifted from src/routes/__tests__/adminAuth.test.js.
*/
const request = require('supertest');
const express = require('express');
const buildChain = ({ firstResult, insertResult } = {}) => ({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue(firstResult),
insert: jest.fn().mockResolvedValue(insertResult ?? [1]),
});
jest.mock('../../../database/db', () => {
const dbMock = jest.fn();
dbMock.raw = jest.fn();
dbMock.__setImplementations = (...chains) => {
dbMock.mockReset();
chains.forEach((chain) => {
dbMock.mockImplementationOnce(() => chain);
});
};
return {
db: dbMock,
logActivity: jest.fn().mockResolvedValue(undefined),
};
});
jest.mock('../../../middleware/apiTokenAuth', () => ({
apiTokenAuth: (req, _res, next) => {
req.apiToken = { id: 1, admin_id: 1, scopes: ['write'] };
req.admin = { id: 1, username: 'token-admin' };
next();
},
requireApiScope: () => (_req, _res, next) => next(),
}));
// photoUpload is built inside events.js (multer({...})), not imported
// from a shared module. Mock the multer factory so .single(field)
// returns middleware that injects a stub req.file synchronously.
//
// Caveat: `path` points at a file that doesn't exist on disk. The
// current 400-path tests short-circuit before the handler touches the
// filesystem. Any future test that exercises a happy-path category
// match must either create the file under beforeAll() or stub the
// `fs`/`fsSync` modules — otherwise `fsSync.statSync(tempPath)` will
// throw and the test will surface a misleading 500.
jest.mock('multer', () => {
const fakeUpload = {
single: () => (req, _res, next) => {
req.file = {
path: '/tmp/fake-v1-upload.jpg',
originalname: 'fake.jpg',
size: 1,
mimetype: 'image/jpeg',
};
next();
},
};
const factory = jest.fn(() => fakeUpload);
factory.diskStorage = jest.fn(() => ({}));
return factory;
});
// Stub sharp so the happy-path test doesn't actually decode an image
// (the temp file is a 0-byte placeholder — see the beforeAll below).
jest.mock('sharp', () => jest.fn(() => ({
metadata: jest.fn().mockResolvedValue({ width: 1920, height: 1080 }),
})));
// Thumbnail + storage are network/fs-heavy; stub to constant resolves
// so the test stays a pure unit test of the route handler's contract.
jest.mock('../../../services/imageProcessor', () => ({
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/fake_thumb.jpg'),
}));
jest.mock('../../../services/storage', () => ({
getStorage: jest.fn(() => ({
putFromFile: jest.fn().mockResolvedValue(undefined),
})),
}));
// webhookService.fire is wrapped in try/catch in the route, so a
// missing mock would still let the test pass — but stubbing it
// silences the predictable failure log so the test output stays clean.
jest.mock('../../../services/webhookService', () => ({
fire: jest.fn().mockResolvedValue(undefined),
}));
const fsSync = require('fs');
const { db } = require('../../../database/db');
const eventsRouter = require('../events');
const buildApp = () => {
const app = express();
app.use(express.json());
app.use('/', eventsRouter);
return app;
};
describe('v1 POST /events/:id/photos — category scoping', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('scopes the category lookup to event-owned or global rows', async () => {
const eventChain = buildChain({ firstResult: { id: 42, slug: 'wedding-2026' } });
const categoryChain = buildChain({ firstResult: null });
db.__setImplementations(eventChain, categoryChain);
// Use JSON body (express.json parses it before the mocked multer
// middleware runs). The route reads req.body.category_id either
// way — multer would have parsed the field as a string, json sends
// a string too.
// .expect(400) also pins the response status — without it a future
// regression that swallowed the error and returned 500 would still
// satisfy the scoping-call assertions below.
await request(buildApp())
.post('/events/42/photos')
.send({ category_id: '7' })
.expect(400);
// The category lookup chain receives the id filter…
expect(categoryChain.where).toHaveBeenCalledWith({ id: 7 });
// …and a single andWhere() with the scoping callback.
expect(categoryChain.andWhere).toHaveBeenCalledTimes(1);
const scopingCb = categoryChain.andWhere.mock.calls[0][0];
expect(typeof scopingCb).toBe('function');
// Invoke the callback against a knex-shaped builder spy and verify
// the OR-clause it builds: event_id = 42 OR is_global = true.
const builderSpy = {
where: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
};
scopingCb.call(builderSpy);
expect(builderSpy.where).toHaveBeenCalledWith({ event_id: 42 });
expect(builderSpy.orWhere).toHaveBeenCalledWith('is_global', true);
});
it('returns 400 with out-of-scope error when no category row matches', async () => {
db.__setImplementations(
buildChain({ firstResult: { id: 42, slug: 'wedding-2026' } }),
buildChain({ firstResult: null }),
);
const response = await request(buildApp())
.post('/events/42/photos')
.send({ category_id: '7' })
.expect(400);
expect(response.body).toEqual({
error: 'Unknown or out-of-scope category_id 7',
});
});
});
describe('v1 POST /events/:id/photos — happy path (#525)', () => {
const FAKE_TMP = '/tmp/fake-v1-upload.jpg';
beforeEach(() => {
jest.clearAllMocks();
// Recreate the temp file on every test — the handler calls
// fs.unlink(tempPath) after a successful upload, so a beforeAll
// would leave the second test without an inode for statSync to
// read (manifests as 500 Internal Server Error).
fsSync.writeFileSync(FAKE_TMP, '');
});
afterAll(() => {
try { fsSync.unlinkSync(FAKE_TMP); } catch { /* may have been unlinked by the handler */ }
});
it('inserts the photo and returns 201 with the resolved category_id', async () => {
// Three db() calls in sequence on the happy path:
// 1. events lookup
// 2. photo_categories lookup (returns a valid in-scope row)
// 3. photos insert returning the new id
const eventChain = buildChain({
firstResult: { id: 42, slug: 'wedding-2026', event_name: 'Wedding 2026' },
});
const categoryChain = buildChain({
firstResult: { id: 7, slug: 'ceremony', name: 'Ceremony', event_id: 42 },
});
const insertChain = {
...buildChain({ insertResult: [{ id: 101 }] }),
returning: jest.fn().mockResolvedValue([{ id: 101 }]),
};
// Override insert so the returning() call is chainable
insertChain.insert = jest.fn(() => insertChain);
db.__setImplementations(eventChain, categoryChain, insertChain);
const response = await request(buildApp())
.post('/events/42/photos')
.send({ category_id: '7' })
.expect(201);
// Response shape pins the v1 API contract — id + category_id are
// the fields the n8n / API-token use case depends on (see #500).
expect(response.body).toMatchObject({
id: 101,
category_id: 7,
size_bytes: 0,
thumbnail_path: 'thumbnails/fake_thumb.jpg',
});
expect(response.body.filename).toMatch(/^\d+_[a-f0-9]+\.jpg$/);
expect(response.body.path).toMatch(/^wedding-2026\/\d+_[a-f0-9]+\.jpg$/);
// The insert payload should carry the resolved category_id and the
// 'individual' photo type (the test category slug isn't 'collage').
const insertedRow = insertChain.insert.mock.calls[0][0];
expect(insertedRow).toMatchObject({
event_id: 42,
category_id: 7,
type: 'individual',
media_type: 'image',
mime_type: 'image/jpeg',
});
});
it('flips photo type to collage when the category slug is "collage"', async () => {
const eventChain = buildChain({
firstResult: { id: 42, slug: 'wedding-2026' },
});
const categoryChain = buildChain({
firstResult: { id: 9, slug: 'collage', name: 'Collage', event_id: 42 },
});
const insertChain = {
...buildChain(),
returning: jest.fn().mockResolvedValue([{ id: 202 }]),
};
insertChain.insert = jest.fn(() => insertChain);
db.__setImplementations(eventChain, categoryChain, insertChain);
await request(buildApp())
.post('/events/42/photos')
.send({ category_id: '9' })
.expect(201);
expect(insertChain.insert.mock.calls[0][0]).toMatchObject({
category_id: 9,
type: 'collage',
});
});
});
@@ -0,0 +1,218 @@
/**
* Regression tests for issue #550.
*
* Two related bugs in POST /v1/events:
* 1. color_theme was not accepted on the request body and never written
* to the events row. Editing such an event later in the admin UI
* snapped the theme picker to GALLERY_THEME_PRESETS.default and
* saving overwrote whatever theme was inherited visually.
* 2. event_feedback_settings row was never created, so the gallery UI
* read it as "feedback off" regardless of the global
* event_default_feedback_enabled toggle (#520).
*
* Test pattern mirrors events.category.test.js queue up db() chains
* with db.__setImplementations() in the exact order the handler invokes
* them, then assert against the captured payloads.
*/
const request = require('supertest');
const express = require('express');
const buildChain = ({ firstResult, insertResult, returningResult } = {}) => {
const chain = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue(firstResult),
insert: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]),
};
return chain;
};
jest.mock('../../../database/db', () => {
const dbMock = jest.fn();
dbMock.raw = jest.fn();
dbMock.__setImplementations = (...chains) => {
dbMock.mockReset();
chains.forEach((chain) => {
dbMock.mockImplementationOnce(() => chain);
});
};
return {
db: dbMock,
logActivity: jest.fn().mockResolvedValue(undefined),
};
});
jest.mock('../../../middleware/apiTokenAuth', () => ({
apiTokenAuth: (req, _res, next) => {
req.apiToken = { id: 1, admin_id: 1, scopes: ['admin'] };
req.admin = { id: 1, username: 'token-admin' };
next();
},
requireApiScope: () => (_req, _res, next) => next(),
}));
// bcrypt.hash is awaited twice per request (real path + dummy path).
// Stub it to a constant so tests don't burn CPU on bcrypt rounds.
jest.mock('bcrypt', () => ({
hash: jest.fn().mockResolvedValue('$2b$10$mocked-hash'),
}));
jest.mock('../../../services/shareLinkService', () => ({
buildShareLinkVariants: jest.fn().mockResolvedValue({
shareUrl: 'https://example.test/gallery/some-slug?t=abc',
shareLinkToStore: '/gallery/some-slug?t=abc',
}),
}));
// Webhook fire is in a try/catch; stub to silence the predictable
// failure log so test output stays clean.
jest.mock('../../../services/webhookService', () => ({
fire: jest.fn().mockResolvedValue(undefined),
buildEventSubject: jest.fn().mockReturnValue({}),
}));
const { db } = require('../../../database/db');
const eventsRouter = require('../events');
const buildApp = () => {
const app = express();
app.use(express.json());
app.use('/', eventsRouter);
return app;
};
const BASE_BODY = {
event_name: 'Issue 550 Wedding',
event_type: 'wedding',
event_date: '2026-06-15',
require_password: false,
};
describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('persists color_theme to the events row when provided', async () => {
// db() call sequence for this body (feedback_enabled omitted, no
// customer_phone, no slug collision):
// 1. app_settings.where('event_default_feedback_enabled').first()
// 2. events.where({ slug }).first() ← uniqueness probe
// 3. events.insert(...).returning('id')
// No event_feedback_settings insert because the global setting
// returns nothing (feedback stays off) — covered separately below.
const settingChain = buildChain({ firstResult: null });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 42 }] });
db.__setImplementations(settingChain, slugChain, insertChain);
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, color_theme: 'default' })
.expect(201);
const insertedRow = insertChain.insert.mock.calls[0][0];
expect(insertedRow).toMatchObject({
event_name: 'Issue 550 Wedding',
color_theme: 'default',
});
});
it('accepts a JSON-encoded theme string and persists it verbatim', async () => {
db.__setImplementations(
buildChain({ firstResult: null }),
buildChain({ firstResult: null }),
buildChain({ returningResult: [{ id: 43 }] }),
);
const customTheme = JSON.stringify({ primaryColor: '#ff0066' });
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, color_theme: customTheme })
.expect(201);
const insertedRow = db.mock.results[2].value.insert.mock.calls[0][0];
expect(insertedRow.color_theme).toBe(customTheme);
});
it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => {
// 3 db() calls when feedback_enabled is sent explicitly (the
// settings probe is skipped because feedbackEnabledInput !== undefined):
// 1. slug probe, 2. events insert, 3. feedback insert
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 50 }] });
const feedbackInsertChain = buildChain();
db.__setImplementations(slugChain, insertChain, feedbackInsertChain);
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, feedback_enabled: true })
.expect(201);
// db('event_feedback_settings') is the 3rd invocation.
expect(db).toHaveBeenNthCalledWith(3, 'event_feedback_settings');
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
expect(feedbackRow).toMatchObject({ event_id: 50 });
// formatBoolean() returns 1/0 on SQLite and true/false on PG. Either
// way the value must be truthy/falsy in the right places — assert by
// coercion so the test stays driver-agnostic.
expect(Boolean(feedbackRow.feedback_enabled)).toBe(true);
expect(Boolean(feedbackRow.allow_ratings)).toBe(true);
expect(Boolean(feedbackRow.allow_likes)).toBe(true);
expect(Boolean(feedbackRow.allow_comments)).toBe(true);
expect(Boolean(feedbackRow.allow_favorites)).toBe(true);
expect(Boolean(feedbackRow.require_name_email)).toBe(false);
expect(Boolean(feedbackRow.moderate_comments)).toBe(true);
expect(Boolean(feedbackRow.show_feedback_to_guests)).toBe(true);
});
it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => {
// settings probe returns a serialized "true" — fallback should kick
// in and the feedback row should still be written.
const settingChain = buildChain({
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' },
});
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 51 }] });
const feedbackInsertChain = buildChain();
db.__setImplementations(settingChain, slugChain, insertChain, feedbackInsertChain);
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
expect(db).toHaveBeenNthCalledWith(4, 'event_feedback_settings');
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
});
it('does NOT create a feedback row when global setting is unset and body omits feedback_enabled', async () => {
const settingChain = buildChain({ firstResult: null });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 52 }] });
db.__setImplementations(settingChain, slugChain, insertChain);
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
// Only 3 db() calls — the event_feedback_settings table is never
// touched because feedback_enabled resolved to false.
expect(db).toHaveBeenCalledTimes(3);
expect(db).not.toHaveBeenCalledWith('event_feedback_settings');
});
it('rejects non-boolean feedback_enabled with 400', async () => {
// Validators run before any db() call, so no chain queueing needed.
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
.expect(400);
});
});
+104 -6
View File
@@ -23,6 +23,9 @@ const { apiTokenAuth, requireApiScope } = require('../../middleware/apiTokenAuth
const { buildShareLinkVariants } = require('../../services/shareLinkService'); const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { generateThumbnail } = require('../../services/imageProcessor'); const { generateThumbnail } = require('../../services/imageProcessor');
const logger = require('../../utils/logger'); const logger = require('../../utils/logger');
const { slugify } = require('../../utils/slug');
const { formatBoolean } = require('../../utils/dbCompat');
const { parseBooleanInput } = require('../../utils/parsers');
const router = express.Router(); const router = express.Router();
@@ -51,8 +54,8 @@ const photoUpload = multer({
} }
}); });
const slugify = (s) => // slugify now imported from ../../utils/slug — shared with adminEvents
String(s).toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); // and events.js so the diacritic fix from #502 lands here too (#525).
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
// POST /events — create event // POST /events — create event
@@ -86,6 +89,8 @@ const slugify = (s) =>
* require_password: { type: boolean, default: true } * require_password: { type: boolean, default: true }
* password: { type: string, nullable: true, description: "Required when require_password is true." } * password: { type: string, nullable: true, description: "Required when require_password is true." }
* expires_at: { type: string, format: date-time, nullable: true } * expires_at: { type: string, format: date-time, nullable: true }
* color_theme: { type: string, nullable: true, description: "Preset name (e.g. 'default') or JSON-encoded ThemeConfig. Persisted as-is on the event row." }
* feedback_enabled: { type: boolean, nullable: true, description: "Enable guest feedback for this gallery. When omitted, falls back to the global event_default_feedback_enabled setting." }
* responses: * responses:
* 201: * 201:
* description: Event created * description: Event created
@@ -116,7 +121,9 @@ router.post(
body('admin_email').optional({ nullable: true, checkFalsy: true }).isEmail(), body('admin_email').optional({ nullable: true, checkFalsy: true }).isEmail(),
body('require_password').optional().isBoolean(), body('require_password').optional().isBoolean(),
body('password').optional({ nullable: true }).isString().isLength({ min: 6 }), body('password').optional({ nullable: true }).isString().isLength({ min: 6 }),
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601() body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('color_theme').optional({ nullable: true }).isString().trim(),
body('feedback_enabled').optional().isBoolean()
], ],
async (req, res) => { async (req, res) => {
try { try {
@@ -126,9 +133,28 @@ router.post(
event_name, event_type, event_date, event_name, event_type, event_date,
customer_name = null, customer_email = null, customer_phone = null, customer_name = null, customer_email = null, customer_phone = null,
admin_email = null, require_password = true, password, admin_email = null, require_password = true, password,
expires_at = null expires_at = null,
color_theme = null,
feedback_enabled: feedbackEnabledInput
} = req.body; } = req.body;
// Issue #550 — mirror the admin POST path so API-created events
// pick up the global "Enable Guest Feedback by default" toggle
// (event_default_feedback_enabled). Without this, the UI reads
// a missing event_feedback_settings row as "feedback off"
// regardless of the admin's chosen default.
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'event_default_feedback_enabled').first();
if (setting) {
try {
const parsed = JSON.parse(setting.setting_value);
if (typeof parsed === 'boolean') feedbackEnabledFallback = parsed;
} catch { /* keep false */ }
}
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
if (require_password && (!password || password.length < 6)) { if (require_password && (!password || password.length < 6)) {
return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' }); return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' });
} }
@@ -173,12 +199,36 @@ router.post(
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
created_by: req.admin.id, created_by: req.admin.id,
is_draft: false, is_draft: false,
// Issue #550 — without this, editing an API-created event in the
// admin UI snaps the theme picker to GALLERY_THEME_PRESETS.default
// and saving overwrites whatever theme was inherited visually.
color_theme,
...(customer_name ? { customer_name } : {}), ...(customer_name ? { customer_name } : {}),
...(customer_email ? { customer_email } : {}), ...(customer_email ? { customer_email } : {}),
...(persistPhone ? { customer_phone: persistPhone } : {}) ...(persistPhone ? { customer_phone: persistPhone } : {})
}).returning('id'); }).returning('id');
const id = insertResult[0]?.id || insertResult[0]; const id = insertResult[0]?.id || insertResult[0];
// Issue #550 — mirror adminEvents.js: create event_feedback_settings
// row when feedback is enabled, so the gallery actually shows
// feedback UI. Sub-flags default to the same values the admin form
// ships with (everything on except require_name_email).
if (feedback_enabled) {
await db('event_feedback_settings').insert({
event_id: id,
feedback_enabled: formatBoolean(true),
allow_ratings: formatBoolean(true),
allow_likes: formatBoolean(true),
allow_comments: formatBoolean(true),
allow_favorites: formatBoolean(true),
require_name_email: formatBoolean(false),
moderate_comments: formatBoolean(true),
show_feedback_to_guests: formatBoolean(true),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
await logActivity('event_created', { via: 'api_v1', event_type }, id, { await logActivity('event_created', { via: 'api_v1', event_type }, id, {
type: 'admin', id: req.admin.id, name: req.admin.username type: 'admin', id: req.admin.id, name: req.admin.username
}); });
@@ -338,6 +388,13 @@ router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res
* required: [photo] * required: [photo]
* properties: * properties:
* photo: { type: string, format: binary } * photo: { type: string, format: binary }
* category_id:
* type: integer
* description: |
* Optional. If provided, the photo is filed under the
* given photo_categories.id (must belong to the event
* or be a global category). If omitted, the photo
* lands uncategorized.
* responses: * responses:
* 201: * 201:
* description: Photo uploaded * description: Photo uploaded
@@ -351,6 +408,7 @@ router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res
* path: { type: string } * path: { type: string }
* thumbnail_path: { type: string, nullable: true } * thumbnail_path: { type: string, nullable: true }
* size_bytes: { type: integer } * size_bytes: { type: integer }
* category_id: { type: integer, nullable: true }
* 400: { description: No file or invalid type } * 400: { description: No file or invalid type }
* 404: { description: Event not found } * 404: { description: Event not found }
*/ */
@@ -368,6 +426,38 @@ router.post(
const event = await db('events').where({ id: req.params.id }).first(); const event = await db('events').where({ id: req.params.id }).first();
if (!event) return res.status(404).json({ error: 'Event not found' }); if (!event) return res.status(404).json({ error: 'Event not found' });
// Optional category assignment, mirroring the admin upload route
// (adminPhotos.js). Multipart form field `category_id`. If the
// category looks up to a "collage" slug, the photo's `type` flips
// accordingly so existing collage-aware UI paths still work.
const rawCategoryId = req.body?.category_id;
const parsedCategoryId = rawCategoryId ? parseInt(rawCategoryId, 10) : NaN;
let categoryId = null;
let photoType = 'individual';
if (!Number.isNaN(parsedCategoryId)) {
// Scope to categories owned by this event (event_id = event.id) or
// marked global (is_global = true) — see migration
// backend/migrations/legacy/004_add_categories_and_cms.js. An API
// token inherits its owning admin's powers (no per-event scoping
// in apiTokenAuth), so accepting any category_id would silently
// mis-file uploads under a category belonging to a different event.
const category = await db('photo_categories')
.where({ id: parsedCategoryId })
.andWhere(function () {
this.where({ event_id: event.id }).orWhere('is_global', true);
})
.first();
if (!category) {
return res.status(400).json({
error: `Unknown or out-of-scope category_id ${parsedCategoryId}`,
});
}
categoryId = category.id;
if (category.slug === 'collage' || category.slug === 'collages') {
photoType = 'collage';
}
}
const ext = path.extname(req.file.originalname); const ext = path.extname(req.file.originalname);
const finalName = `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`; const finalName = `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`;
// photo.path is stored relative to events/active so resolvePhotoStorageKey // photo.path is stored relative to events/active so resolvePhotoStorageKey
@@ -408,7 +498,8 @@ router.post(
original_filename: req.file.originalname, original_filename: req.file.originalname,
path: relPath, path: relPath,
thumbnail_path: thumbRel, thumbnail_path: thumbRel,
type: 'individual', type: photoType,
category_id: categoryId,
size_bytes: stat.size, size_bytes: stat.size,
width, width,
height, height,
@@ -432,7 +523,14 @@ router.post(
}); });
} catch (e) { /* non-fatal */ } } catch (e) { /* non-fatal */ }
res.status(201).json({ id, filename: finalName, path: relPath, thumbnail_path: thumbRel, size_bytes: stat.size }); res.status(201).json({
id,
filename: finalName,
path: relPath,
thumbnail_path: thumbRel,
size_bytes: stat.size,
category_id: categoryId
});
} catch (error) { } catch (error) {
logger.error('v1 POST /events/:id/photos failed', { error: error.message }); logger.error('v1 POST /events/:id/photos failed', { error: error.message });
if (tempPath) await fs.unlink(tempPath).catch(() => {}); if (tempPath) await fs.unlink(tempPath).catch(() => {});
+52 -5
View File
@@ -9,6 +9,12 @@ const { queueEmail, getSupportEmail } = require('./emailProcessor');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const feedbackService = require('./feedbackService'); const feedbackService = require('./feedbackService');
const { getStorage } = require('./storage'); const { getStorage } = require('./storage');
const { resolvePhotoStorageKey } = require('./photoResolver');
const { getUseOriginalFilenames } = require('./downloadFilenameService');
const {
sanitizeForZipEntry,
uniquifyZipNames,
} = require('../utils/filenameSanitizer');
async function archiveEvent(event) { async function archiveEvent(event) {
const storage = getStorage(); const storage = getStorage();
@@ -54,6 +60,40 @@ async function archiveEvent(event) {
// the zip directly from the storage backend. // the zip directly from the storage backend.
const photoEntries = await storage.list(eventPrefix); const photoEntries = await storage.list(eventPrefix);
// #493: optionally rename zip entries to use original camera filenames.
// Build a Map<storage_key, original_filename> from the photos table so we
// can swap the basename of each entry while keeping the folder structure
// (e.g. `individual/DSC_1234.jpg` instead of `individual/slug_001.jpg`).
const useOriginal = await getUseOriginalFilenames();
const originalsByKey = new Map();
if (useOriginal) {
const photoRows = await db('photos').where('event_id', event.id).select('*');
for (const photoRow of photoRows) {
if (!photoRow.original_filename) continue;
try {
const key = resolvePhotoStorageKey(event, photoRow);
if (key) originalsByKey.set(key, photoRow.original_filename);
} catch {
// External-mode rows have no managed key; skip silently.
}
}
}
// Compute (subfolder, displayName) up front so collisions across the
// whole zip can be resolved deterministically with `_N` suffixes.
const photoNames = photoEntries.map((entry) => {
const rel = entry.key.startsWith(`${eventPrefix}/`)
? entry.key.slice(eventPrefix.length + 1)
: entry.key;
if (!useOriginal) return rel;
const originalBase = originalsByKey.get(entry.key);
if (!originalBase) return rel;
const sep = rel.lastIndexOf('/');
const folder = sep >= 0 ? rel.slice(0, sep + 1) : '';
return `${folder}${sanitizeForZipEntry(originalBase)}`;
});
const dedupedNames = uniquifyZipNames(photoNames);
let totalBytes = 0; let totalBytes = 0;
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const output = fs.createWriteStream(tmpArchive); const output = fs.createWriteStream(tmpArchive);
@@ -67,10 +107,9 @@ async function archiveEvent(event) {
archive.pipe(output); archive.pipe(output);
const append = async () => { const append = async () => {
for (const entry of photoEntries) { for (let i = 0; i < photoEntries.length; i += 1) {
const nameInZip = entry.key.startsWith(`${eventPrefix}/`) const entry = photoEntries[i];
? entry.key.slice(eventPrefix.length + 1) const nameInZip = dedupedNames[i];
: entry.key;
const stream = await storage.get(entry.key); const stream = await storage.get(entry.key);
archive.append(stream, { name: nameInZip }); archive.append(stream, { name: nameInZip });
} }
@@ -129,7 +168,10 @@ async function archiveEvent(event) {
); );
} }
// Delete thumbnails for this event's photos. // Delete derived images (thumbnails / heroes / previews / watermarks)
// for this event's photos. The originals are inside the zip; the
// derived tiers are throwaway and will be regenerated lazily on
// restore (or not at all for archived events that nobody opens).
const photos = await db('photos').where('event_id', event.id); const photos = await db('photos').where('event_id', event.id);
for (const photo of photos) { for (const photo of photos) {
if (photo.thumbnail_path) { if (photo.thumbnail_path) {
@@ -138,6 +180,11 @@ async function archiveEvent(event) {
if (photo.hero_path) { if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {}); await storage.delete(photo.hero_path).catch(() => {});
} }
// Lightbox preview tier (#492). Same disposable-derived
// semantics as thumbnails / heroes — wipe on archive.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
// Best effort: remove watermarked variants too if a refactor added them. // Best effort: remove watermarked variants too if a refactor added them.
if (photo.watermark_path) { if (photo.watermark_path) {
await storage.delete(photo.watermark_path).catch(() => {}); await storage.delete(photo.watermark_path).catch(() => {});
+10
View File
@@ -361,6 +361,16 @@ async function getFilesToBackupInternal(includeArchived = true) {
} }
await scanDirectory(path.join(storagePath, 'thumbnails'), files, storagePath); await scanDirectory(path.join(storagePath, 'thumbnails'), files, storagePath);
// Lightbox preview tier (#492). Cheap to back up — typically a few
// hundred KB per photo — and saves admins the regenerate cycle on
// a restore. Tolerated when missing (admins who never enabled the
// feature won't have the folder; scanDirectory short-circuits on
// ENOENT cleanly).
await scanDirectory(path.join(storagePath, 'previews'), files, storagePath);
// Heroes too — same logic; admins who picked a hero photo for the
// gallery header had its 1920x1080 file generated and was missed
// by the original backup walk before this addition.
await scanDirectory(path.join(storagePath, 'heroes'), files, storagePath);
await scanDirectory(path.join(storagePath, 'uploads'), files, storagePath); await scanDirectory(path.join(storagePath, 'uploads'), files, storagePath);
return files; return files;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,117 @@
/**
* Download filename resolution for the
* `general_use_original_filenames_for_downloads` setting (#493).
*
* Two responsibilities:
* - Cache the boolean setting so per-download reads don't hit the DB.
* - Map photos display filenames (sanitized + dedup'd for zip entries).
*
* Storage paths are NOT touched: callers still locate files via
* `resolvePhotoStorageKey` / `resolvePhotoFilePath`. Only the user-visible
* download/zip-entry name changes when the setting is on.
*/
const { db } = require('../database/db');
const logger = require('../utils/logger');
const {
sanitizeForContentDisposition,
sanitizeForZipEntry,
uniquifyZipNames,
} = require('../utils/filenameSanitizer');
const SETTING_KEY = 'general_use_original_filenames_for_downloads';
const CACHE_TTL_MS = 60_000;
let cached = null; // boolean | null
let cachedAt = 0;
function clearCache() {
cached = null;
cachedAt = 0;
}
/**
* Read the toggle. Cached for CACHE_TTL_MS to keep per-download reads cheap.
* Falls back to `false` (current behaviour) on any error.
*/
async function getUseOriginalFilenames() {
const now = Date.now();
if (cached !== null && now - cachedAt < CACHE_TTL_MS) {
return cached;
}
try {
const row = await db('app_settings')
.where('setting_key', SETTING_KEY)
.first();
let value = false;
if (row && row.setting_value !== null && row.setting_value !== undefined) {
const raw = row.setting_value;
if (typeof raw === 'boolean') {
value = raw;
} else if (typeof raw === 'string') {
// setting_value is JSON-stringified on write (see adminSettings PUT /general).
try {
value = JSON.parse(raw) === true;
} catch {
value = raw === 'true';
}
} else {
value = Boolean(raw);
}
}
cached = value;
cachedAt = now;
return value;
} catch (err) {
logger.warn('downloadFilenameService.getUseOriginalFilenames error', { error: err.message });
return cached === null ? false : cached;
}
}
/**
* Pick the raw (unsanitised) filename to use for a single photo, given the
* toggle state. Falls back to `photo.filename` whenever the original is missing
* (legacy uploads before migration 062, or external-mode rows where it was
* never populated).
*/
function pickRawDownloadName(photo, useOriginal) {
if (useOriginal && photo && photo.original_filename) {
return photo.original_filename;
}
return (photo && photo.filename) || `photo-${photo && photo.id}.jpg`;
}
/**
* Header-safe filename for `Content-Disposition`. Pair with
* `buildContentDisposition()` from filenameSanitizer when the caller wants
* RFC 5987 unicode support; this helper returns only the ASCII fallback for
* routes that already construct the header by hand.
*/
function getDownloadFilenameForHeader(photo, useOriginal) {
return sanitizeForContentDisposition(pickRawDownloadName(photo, useOriginal));
}
/**
* Build a list of unique, zip-safe entry names for an ordered list of photos.
*
* @param {Array} photos photos in zip order
* @param {boolean} useOriginal toggle state
* @returns {string[]} same length as `photos`, with `_1` / `_2` suffixes on
* any duplicates (deterministic across runs because order is preserved)
*/
function getZipEntryNames(photos, useOriginal) {
const raw = photos.map((p) => sanitizeForZipEntry(pickRawDownloadName(p, useOriginal)));
return uniquifyZipNames(raw);
}
module.exports = {
SETTING_KEY,
clearCache,
getUseOriginalFilenames,
pickRawDownloadName,
getDownloadFilenameForHeader,
getZipEntryNames,
};
+11 -3
View File
@@ -23,6 +23,7 @@ const { db } = require('../database/db');
const watermarkService = require('./watermarkService'); const watermarkService = require('./watermarkService');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver'); const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
const { getStorage } = require('./storage'); const { getStorage } = require('./storage');
const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const DEBOUNCE_MS = 5000; const DEBOUNCE_MS = 5000;
@@ -134,6 +135,11 @@ class DownloadZipService {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-zipbuild-')); tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-zipbuild-'));
const tmpPath = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}-all.zip`); const tmpPath = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}-all.zip`);
// #493: resolve display filenames (with collision suffix) before the
// streaming starts so the loop just indexes the precomputed array.
const useOriginal = await getUseOriginalFilenames();
const entryNames = getZipEntryNames(photos, useOriginal);
// Build zip — level 0 (store only) since photos are already compressed // Build zip — level 0 (store only) since photos are already compressed
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const output = fs.createWriteStream(tmpPath); const output = fs.createWriteStream(tmpPath);
@@ -147,19 +153,21 @@ class DownloadZipService {
const hasMultipleTypes = uniqueTypes > 1; const hasMultipleTypes = uniqueTypes > 1;
const addPhotos = async () => { const addPhotos = async () => {
for (const photo of photos) { for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
// Check if build was invalidated // Check if build was invalidated
if (this.versions.get(eventId) !== version) { if (this.versions.get(eventId) !== version) {
archive.abort(); archive.abort();
return reject(new Error('Build invalidated')); return reject(new Error('Build invalidated'));
} }
const entryName = entryNames[i];
let archiveName; let archiveName;
if (hasMultipleTypes) { if (hasMultipleTypes) {
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages'; const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
archiveName = path.join(folderName, photo.filename); archiveName = path.join(folderName, entryName);
} else { } else {
archiveName = photo.filename; archiveName = entryName;
} }
// External-mode photos still live on local disk; managed photos go // External-mode photos still live on local disk; managed photos go
+12 -1
View File
@@ -116,7 +116,9 @@ async function getRecipientLanguage(email, eventId = null) {
.where('setting_key', 'general_default_language') .where('setting_key', 'general_default_language')
.first(); .first();
if (langSetting && langSetting.setting_value) { if (langSetting && langSetting.setting_value) {
return langSetting.setting_value; let lang = langSetting.setting_value;
try { lang = JSON.parse(lang); } catch (_) {}
if (typeof lang === 'string' && lang.trim()) return lang.trim();
} }
} catch (error) { } catch (error) {
logger.error('Error fetching app settings language:', error); logger.error('Error fetching app settings language:', error);
@@ -140,6 +142,7 @@ async function getRecipientLanguage(email, eventId = null) {
{ domains: ['.nl', '.be'], language: 'nl' }, { domains: ['.nl', '.be'], language: 'nl' },
{ domains: ['.br', '.pt'], language: 'pt' }, { domains: ['.br', '.pt'], language: 'pt' },
{ domains: ['.ru', '.su'], language: 'ru' }, { domains: ['.ru', '.su'], language: 'ru' },
{ domains: ['.es'], language: 'es' },
]; ];
for (const { domains, language: lang } of domainLanguageMap) { for (const { domains, language: lang } of domainLanguageMap) {
if (domains.some(d => domain.endsWith(d))) { if (domains.some(d => domain.endsWith(d))) {
@@ -391,6 +394,11 @@ const HTML_PASSTHROUGH_KEYS = new Set([
'welcome_message', // already HTML (formatWelcomeMessage escapes + nl2br) 'welcome_message', // already HTML (formatWelcomeMessage escapes + nl2br)
'gallery_link', // server-generated URL (adminEvents.js) 'gallery_link', // server-generated URL (adminEvents.js)
'client_link', // server-generated URL (adminEvents.js) 'client_link', // server-generated URL (adminEvents.js)
// customer_gallery_assigned template (#354 follow-up): server-rendered
// <ul> of newly-added galleries. Built in customerAccountsService from
// trusted DB rows (event_name comes from admin-owned events; the date
// is server-rendered) — escaping it here would double-escape the markup.
'gallery_list_html',
]); ]);
const { escapeHtml } = require('../utils/formatters'); const { escapeHtml } = require('../utils/formatters');
@@ -492,6 +500,7 @@ async function processTemplate(template, variables, language = 'en') {
nl: '(Om veiligheidsredenen niet weergegeven)', nl: '(Om veiligheidsredenen niet weergegeven)',
pt: '(Não exibido por motivos de segurança)', pt: '(Não exibido por motivos de segurança)',
ru: '(Не показано в целях безопасности)', ru: '(Не показано в целях безопасности)',
es: '(No se muestra por razones de seguridad)',
}; };
const noPasswordI18n = { const noPasswordI18n = {
en: 'No password required', en: 'No password required',
@@ -499,6 +508,7 @@ async function processTemplate(template, variables, language = 'en') {
nl: 'Geen wachtwoord vereist', nl: 'Geen wachtwoord vereist',
pt: 'Nenhuma senha necessária', pt: 'Nenhuma senha necessária',
ru: 'Пароль не требуется', ru: 'Пароль не требуется',
es: 'No se requiere contraseña',
}; };
// Sent by the publish-from-draft flow (adminEvents.js): by the time the // Sent by the publish-from-draft flow (adminEvents.js): by the time the
// event is published, only the bcrypt hash is stored, so the plaintext // event is published, only the bcrypt hash is stored, so the plaintext
@@ -510,6 +520,7 @@ async function processTemplate(template, variables, language = 'en') {
nl: 'Het wachtwoord dat u bij het aanmaken van de galerij hebt ingesteld', nl: 'Het wachtwoord dat u bij het aanmaken van de galerij hebt ingesteld',
pt: 'A senha definida ao criar a galeria', pt: 'A senha definida ao criar a galeria',
ru: 'Пароль, заданный при создании галереи', ru: 'Пароль, заданный при создании галереи',
es: 'La contraseña que estableciste al crear la galería',
}; };
if (processedVariables.gallery_password === '{{password_security_message}}') { if (processedVariables.gallery_password === '{{password_security_message}}') {
+19 -1
View File
@@ -1,6 +1,7 @@
const chokidar = require('chokidar'); const chokidar = require('chokidar');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const sharp = require('sharp');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor'); const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
@@ -92,6 +93,22 @@ async function processNewPhoto(filePath) {
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg'); const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
// Capture image dimensions so aspect-aware layouts (masonry / mosaic /
// justified) can size each card to the photo's real proportions
// instead of the 800×600 fallback in MasonryGalleryLayout (#447).
// Skip videos — those would need ffprobe.
let dimensions = null;
if (!isVideo) {
try {
const metadata = await sharp(filePath).metadata();
if (metadata.width && metadata.height) {
dimensions = { width: metadata.width, height: metadata.height };
}
} catch (err) {
logger.debug(`Could not read image dimensions for ${filename}: ${err.message}`);
}
}
// Check if photo already exists (by filename or path, to handle replacements) // Check if photo already exists (by filename or path, to handle replacements)
const existingPhoto = await db('photos') const existingPhoto = await db('photos')
.where({ event_id: event.id }) .where({ event_id: event.id })
@@ -110,7 +127,8 @@ async function processNewPhoto(filePath) {
thumbnail_path: relativeThumbPath, thumbnail_path: relativeThumbPath,
type: isVideo ? 'video' : photoType, type: isVideo ? 'video' : photoType,
size_bytes: stats.size, size_bytes: stats.size,
mime_type: mimeType mime_type: mimeType,
...(dimensions && { width: dimensions.width, height: dimensions.height })
}).returning('id'); }).returning('id');
const photoId = insertResult[0]?.id || insertResult[0]; const photoId = insertResult[0]?.id || insertResult[0];
+108 -3
View File
@@ -1,11 +1,18 @@
const { db } = require('../database/db'); const { db } = require('../database/db');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { ensureThumbnail } = require('./imageProcessor');
const { getStorage } = require('./storage');
const SOCIAL_CRAWLER_PATTERNS = [ const SOCIAL_CRAWLER_PATTERNS = [
/facebookexternalhit/i, /facebookexternalhit/i,
/facebot/i, /facebot/i,
/Twitterbot/i, /Twitterbot/i,
// WhatsApp's main app crawler is "WhatsApp/X.Y.Z"; the Business
// API and some Cloud API senders use "WhatsAppBot" or "wa-bot/" —
// detect both so API-driven sends get the rich preview too (#521).
/WhatsApp/i, /WhatsApp/i,
/WhatsAppBot/i,
/wa-bot/i,
/Slackbot/i, /Slackbot/i,
/TelegramBot/i, /TelegramBot/i,
/SkypeUriPreview/i, /SkypeUriPreview/i,
@@ -22,7 +29,12 @@ const SOCIAL_CRAWLER_PATTERNS = [
/Mastodon/i, /Mastodon/i,
/Bluesky/i, /Bluesky/i,
/OpenGraph/i, /OpenGraph/i,
/opengraph/i /opengraph/i,
// Generic preview/scrape services commonly used in business
// messaging stacks (Twilio, LinkPreview.net, etc.). Match the
// canonical lowercase substring; the /i flag handles case.
/LinkPreview/i,
/Slack-ImgProxy/i
]; ];
function isSocialCrawler(userAgent) { function isSocialCrawler(userAgent) {
@@ -149,10 +161,27 @@ async function buildOgMetadata(slug, requestPath) {
description = `Photo gallery from ${eventName}.`; description = `Photo gallery from ${eventName}.`;
} }
// Per-event hero-photo opt-in (#474). When the admin has flipped
// events.og_image_share_enabled AND a hero_photo_id is set AND that
// photo has a generated thumbnail, point og:image at the public
// cover endpoint instead of the brand logo. Falls back silently to
// the logo on any of those misses so a half-configured event still
// gets a polished link preview rather than a broken image.
let image = logoUrl;
if (event.og_image_share_enabled && event.hero_photo_id) {
const heroPhoto = await db('photos')
.where({ id: event.hero_photo_id, event_id: event.id })
.select('id', 'thumbnail_path')
.first();
if (heroPhoto && heroPhoto.thumbnail_path) {
image = `${base}/og/gallery/${event.slug}/cover`;
}
}
return { return {
title, title,
description, description,
image: logoUrl, image,
url: `${base}/gallery/${event.slug}`, url: `${base}/gallery/${event.slug}`,
siteName, siteName,
eventName, eventName,
@@ -210,9 +239,85 @@ async function handleGalleryOgRequest(req, res) {
} }
} }
/**
* Public cover-image endpoint for OG/Twitter Card previews (#474).
*
* Streams the gallery's hero-photo thumbnail unauthenticated but
* ONLY when the admin has flipped events.og_image_share_enabled on
* that event. Any miss (slug not found, opt-in not set, no hero, no
* thumbnail) returns 404; buildOgMetadata above falls back to the
* brand logo for the og:image when this would 404, so callers never
* see a broken-image preview.
*
* Why a dedicated endpoint instead of reusing /api/gallery/:slug/
* thumbnail/:photoId the latter is gated by verifyGalleryAccess
* (gallery JWT or per-event password). Social crawlers don't carry
* either, so we need a separate, explicitly-public path that the
* admin opted into.
*/
async function handleGalleryOgCover(req, res) {
try {
const { slug } = req.params;
if (!slug || !/^[a-zA-Z0-9_-]{1,255}$/.test(slug)) {
res.status(400).type('text/plain').send('Invalid gallery slug');
return;
}
const event = await resolveSlug(slug);
if (!event || !event.og_image_share_enabled || !event.hero_photo_id) {
res.status(404).type('text/plain').send('Cover not available');
return;
}
const photo = await db('photos')
.where({ id: event.hero_photo_id, event_id: event.id })
.first();
if (!photo) {
res.status(404).type('text/plain').send('Cover not available');
return;
}
const thumbnailPath = await ensureThumbnail(photo);
if (!thumbnailPath) {
res.status(404).type('text/plain').send('Cover not available');
return;
}
const storage = getStorage();
const stat = await storage.stat(thumbnailPath);
if (!stat) {
res.status(404).type('text/plain').send('Cover not available');
return;
}
// ETag = thumbnail mtime + photo id so a regenerated thumb (e.g.
// after the admin changes thumbnail fit mode) busts crawler
// caches. Keep the cache window short on the response itself —
// crawlers like WhatsApp re-fetch eagerly; admins shouldn't have
// to wait an hour for a swap to land in chat previews.
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const etag = `"og-cover-${photo.id}-${mtimeMs}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=300',
'X-Content-Type-Options': 'nosniff',
'ETag': etag,
});
if (stat.size) res.setHeader('Content-Length', stat.size);
const stream = await storage.get(thumbnailPath);
stream.pipe(res);
} catch (error) {
logger.error('Failed to stream gallery OG cover', { error: error.message });
res.status(500).type('text/plain').send('Internal server error');
}
}
module.exports = { module.exports = {
isSocialCrawler, isSocialCrawler,
buildOgMetadata, buildOgMetadata,
renderOgHtml, renderOgHtml,
handleGalleryOgRequest handleGalleryOgRequest,
handleGalleryOgCover
}; };
+198 -16
View File
@@ -15,7 +15,13 @@ sharp.concurrency(2); // Limit concurrent operations
// Default thumbnail settings // Default thumbnail settings
const DEFAULT_THUMBNAIL_WIDTH = 300; const DEFAULT_THUMBNAIL_WIDTH = 300;
const DEFAULT_THUMBNAIL_HEIGHT = 300; const DEFAULT_THUMBNAIL_HEIGHT = 300;
const DEFAULT_THUMBNAIL_FIT = 'cover'; // 'cover' for square crops // 'inside' preserves the source aspect ratio (output ≤ width × height).
// This is the right default for masonry / mosaic / justified layouts —
// the gallery sizes each card from photo.width/height and renders the
// thumbnail with object-cover, so a thumb that already matches the
// source aspect doesn't get re-cropped (#447). Admins who want
// uniform 1:1 grid tiles can switch to 'cover' in the thumbnail settings.
const DEFAULT_THUMBNAIL_FIT = 'inside';
const DEFAULT_THUMBNAIL_QUALITY = 85; const DEFAULT_THUMBNAIL_QUALITY = 85;
const DEFAULT_THUMBNAIL_FORMAT = 'jpeg'; const DEFAULT_THUMBNAIL_FORMAT = 'jpeg';
@@ -24,6 +30,15 @@ const DEFAULT_HERO_WIDTH = 1920;
const DEFAULT_HERO_HEIGHT = 1080; const DEFAULT_HERO_HEIGHT = 1080;
const DEFAULT_HERO_QUALITY = 85; const DEFAULT_HERO_QUALITY = 85;
// Preview tier (#492). Aspect-preserved downscale for the lightbox so
// guests don't pay the full 512 MB original on every photo open.
// Same long edge as the hero (admins are already sizing for it) and
// quality 85 — JPEG artefacts at this size are imperceptible to clients
// browsing on phones / Retina laptops, and storage cost stays modest
// (~200500 KB per photo vs originals at multi-MB).
const DEFAULT_PREVIEW_LONG_EDGE = 1920;
const DEFAULT_PREVIEW_QUALITY = 85;
// Helper to parse setting value (handles both JSON-encoded and plain values) // Helper to parse setting value (handles both JSON-encoded and plain values)
function parseSettingValue(value) { function parseSettingValue(value) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
@@ -101,10 +116,17 @@ const contentTypeFor = (format) => {
* *
* Callers must ensure the source is on the local filesystem. For S3 mode * Callers must ensure the source is on the local filesystem. For S3 mode
* regeneration flows, fetch via `withLocalCopy(storage, sourceKey, fn)` first. * regeneration flows, fetch via `withLocalCopy(storage, sourceKey, fn)` first.
*
* options.outputBasename override the basename portion of the thumbnail
* filename (default: basename of imagePath). Used for external/reference
* photos where the source basename can collide across events (#423) the
* import path passes a per-photo unique basename so two events both
* referencing `IMG_0001.jpg` don't clobber each other's thumbnail.
*/ */
async function generateThumbnail(imagePath, options = {}) { async function generateThumbnail(imagePath, options = {}) {
const filename = path.basename(imagePath); const sourceBasename = path.basename(imagePath);
const thumbnailFilename = `thumb_${filename}`; const outputBasename = options.outputBasename || sourceBasename;
const thumbnailFilename = `thumb_${outputBasename}`;
const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename); const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename);
const storage = getStorage(); const storage = getStorage();
@@ -168,7 +190,7 @@ async function generateThumbnail(imagePath, options = {}) {
return thumbnailRelKey; return thumbnailRelKey;
} catch (error) { } catch (error) {
const msg = (error && error.message) ? error.message : String(error); const msg = (error && error.message) ? error.message : String(error);
logger.error(`Failed to generate thumbnail for ${filename}: ${msg}`); logger.error(`Failed to generate thumbnail for ${sourceBasename}: ${msg}`);
// Clean up any partially uploaded object // Clean up any partially uploaded object
await storage.delete(thumbnailRelKey).catch(() => {}); await storage.delete(thumbnailRelKey).catch(() => {});
@@ -220,22 +242,25 @@ async function withLocalCopy(sourceKey, fn) {
} }
/** /**
* Regenerate thumbnail if it's broken or missing * Regenerate thumbnail if it's broken or missing.
*
* Works for both managed photos (stored via the storage backend, possibly
* S3) and external/reference photos (#423 sourced from a local mount
* outside the managed storage tree, e.g. NAS over SMB/NFS). External
* photos historically had thumbnail_path=null, which forced the gallery
* to fall back to streaming the full original on every tile minutes of
* load time for a 100-photo NAS-mounted gallery.
*/ */
async function ensureThumbnail(photo) { async function ensureThumbnail(photo) {
const { resolvePhotoStorageKey } = require('./photoResolver'); const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
let sourceKey;
try {
const event = await db('events').where('id', photo.event_id).first(); const event = await db('events').where('id', photo.event_id).first();
sourceKey = resolvePhotoStorageKey(event, photo); if (!event) {
logger.info(`Ensuring thumbnail for photo ${photo.id} from key: ${sourceKey}`); logger.error(`ensureThumbnail: event ${photo.event_id} not found for photo ${photo.id}`);
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
logger.error(`Failed to resolve original key for thumbnail (photo ${photo.id}): ${msg}`);
return null; return null;
} }
// Check if thumbnail exists and is valid // Check if thumbnail exists and is valid (works for any source).
if (photo.thumbnail_path) { if (photo.thumbnail_path) {
const isValid = await isThumbnailValid(photo.thumbnail_path); const isValid = await isThumbnailValid(photo.thumbnail_path);
if (isValid) { if (isValid) {
@@ -244,10 +269,38 @@ async function ensureThumbnail(photo) {
logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`); logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`);
} }
// Generate new thumbnail (sources via withLocalCopy so this works in S3 mode) const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
const newThumbnailPath = await withLocalCopy(sourceKey, (localPath) =>
let newThumbnailPath;
if (isExternal) {
// External: source is on a local mount path. No withLocalCopy needed
// (storage-backend abstraction doesn't apply — this is a direct fs
// read). Use a per-photo unique outputBasename so two events both
// referencing the same NAS basename can't clobber each other's thumb.
let localPath;
try {
localPath = resolvePhotoFilePath(event, photo);
} catch (e) {
logger.error(`Failed to resolve external file for thumbnail (photo ${photo.id}): ${e.message}`);
return null;
}
const sourceBasename = path.basename(photo.external_relpath || photo.filename || `photo-${photo.id}`);
const outputBasename = `ext${photo.id}_${sourceBasename}`;
logger.info(`Ensuring thumbnail for external photo ${photo.id} from ${localPath}`);
newThumbnailPath = await generateThumbnail(localPath, { regenerate: true, outputBasename });
} else {
let sourceKey;
try {
sourceKey = resolvePhotoStorageKey(event, photo);
} catch (e) {
logger.error(`Failed to resolve original key for thumbnail (photo ${photo.id}): ${e.message}`);
return null;
}
logger.info(`Ensuring thumbnail for photo ${photo.id} from key: ${sourceKey}`);
newThumbnailPath = await withLocalCopy(sourceKey, (localPath) =>
generateThumbnail(localPath, { regenerate: true }) generateThumbnail(localPath, { regenerate: true })
); );
}
if (newThumbnailPath) { if (newThumbnailPath) {
await db('photos') await db('photos')
@@ -432,6 +485,132 @@ async function ensureHeroImage(photo) {
return null; return null;
} }
/**
* Generate a lightbox preview image (#492).
*
* Aspect-preserving downscale (`fit: 'inside'`) capped at
* DEFAULT_PREVIEW_LONG_EDGE. Distinct from generateHeroImage:
* - hero 1920x1080 cover-cropped (gallery hero header banner)
* - preview 1920px long edge, aspect preserved (lightbox tile)
*
* Output to `previews/preview_<filename>` so an admin who flips the
* setting back off can wipe the folder cleanly without touching
* thumbnails or heroes.
*/
async function generatePreviewImage(imagePath, options = {}) {
const filename = path.basename(imagePath);
const previewFilename = `preview_${filename}`;
const previewRelKey = path.posix.join('previews', previewFilename);
const storage = getStorage();
if (options.regenerate) {
await storage.delete(previewRelKey).catch(() => {});
}
try {
const metadata = await sharp(imagePath).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('Invalid image metadata - file may be incomplete');
}
const longEdge = options.longEdge || DEFAULT_PREVIEW_LONG_EDGE;
const quality = options.quality || DEFAULT_PREVIEW_QUALITY;
let sharpInstance = sharp(imagePath, {
limitInputPixels: 268402689, // ~16k x 16k max
sequentialRead: true,
failOnError: false,
});
// Strip EXIF — same privacy reasoning as thumbnails/heroes.
sharpInstance = sharpInstance.withMetadata(false);
// fit: 'inside' + withoutEnlargement keeps small originals at
// their native size (no upscaling artefacts) and shrinks larger
// ones until both dimensions fit inside longEdge×longEdge.
sharpInstance = sharpInstance.resize(longEdge, longEdge, {
withoutEnlargement: true,
fit: 'inside',
});
sharpInstance = sharpInstance.jpeg({
quality,
progressive: true,
mozjpeg: true,
});
const buffer = await sharpInstance.toBuffer();
if (!buffer || buffer.length === 0) {
throw new Error('Generated preview image is empty');
}
await storage.put(previewRelKey, buffer, { contentType: 'image/jpeg' });
logger.info(`Generated preview image for ${filename}${previewRelKey}`);
return previewRelKey;
} catch (error) {
const msg = (error && error.message) ? error.message : String(error);
logger.error(`Failed to generate preview image for ${filename}: ${msg}`);
await storage.delete(previewRelKey).catch(() => {});
return null;
}
}
/**
* Validate an existing preview file is non-empty + readable by Sharp.
* Mirrors isHeroValid / isThumbnailValid.
*/
async function isPreviewValid(previewPath) {
const storage = getStorage();
try {
const stat = await storage.stat(previewPath);
if (!stat || stat.size === 0) return false;
if (storage.kind() === 'local') {
const localPath = storage.resolveLocalPath(previewPath);
await sharp(localPath).metadata();
}
return true;
} catch {
return false;
}
}
/**
* Lazy-generate the preview image for a photo if missing or invalid.
* Returns the storage key or null on failure (callers fall back to
* the original URL so the lightbox never shows a broken image).
*/
async function ensurePreviewImage(photo) {
const { resolvePhotoStorageKey } = require('./photoResolver');
let sourceKey;
try {
const event = await db('events').where('id', photo.event_id).first();
sourceKey = resolvePhotoStorageKey(event, photo);
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
logger.error(`Failed to resolve original key for preview (photo ${photo.id}): ${msg}`);
return null;
}
if (photo.preview_path) {
const ok = await isPreviewValid(photo.preview_path);
if (ok) return photo.preview_path;
logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`);
}
const newPreviewPath = await withLocalCopy(sourceKey, (localPath) =>
generatePreviewImage(localPath, { regenerate: true })
);
if (newPreviewPath) {
await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath });
return newPreviewPath;
}
return null;
}
/** /**
* Extract capture date from EXIF metadata * Extract capture date from EXIF metadata
*/ */
@@ -481,6 +660,9 @@ module.exports = {
generateHeroImage, generateHeroImage,
isHeroValid, isHeroValid,
ensureHeroImage, ensureHeroImage,
generatePreviewImage,
isPreviewValid,
ensurePreviewImage,
extractCaptureDate, extractCaptureDate,
withLocalCopy, withLocalCopy,
}; };
+23
View File
@@ -17,9 +17,11 @@
const path = require('path'); const path = require('path');
const mime = require('mime-types'); const mime = require('mime-types');
const sharp = require('sharp');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { getStorage } = require('./storage'); const { getStorage } = require('./storage');
const { withLocalCopy } = require('./imageProcessor');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const POLL_INTERVAL_MS = parseInt(process.env.STORAGE_AUTO_IMPORT_INTERVAL_MS || `${5 * 60 * 1000}`, 10); const POLL_INTERVAL_MS = parseInt(process.env.STORAGE_AUTO_IMPORT_INTERVAL_MS || `${5 * 60 * 1000}`, 10);
@@ -98,6 +100,26 @@ async function processEvent(event, storage) {
const isVideo = mimeType.startsWith('video/'); const isVideo = mimeType.startsWith('video/');
if (!isImage && !isVideo) continue; if (!isImage && !isVideo) continue;
// Capture image dimensions so aspect-aware layouts (masonry /
// mosaic / justified) can size each card to the photo's real
// proportions instead of the 800×600 fallback (#447). Materialize
// a tmp local copy via withLocalCopy — withLocalCopy handles the
// S3 download + cleanup. Skip videos (would need ffprobe).
let dimensions = null;
if (isImage) {
try {
dimensions = await withLocalCopy(entry.key, async (localPath) => {
const metadata = await sharp(localPath).metadata();
if (metadata.width && metadata.height) {
return { width: metadata.width, height: metadata.height };
}
return null;
});
} catch (err) {
logger.debug(`[s3AutoImporter] could not read dimensions for ${entry.key}: ${err.message}`);
}
}
try { try {
const insertResult = await db('photos').insert({ const insertResult = await db('photos').insert({
event_id: event.id, event_id: event.id,
@@ -110,6 +132,7 @@ async function processEvent(event, storage) {
mime_type: mimeType, mime_type: mimeType,
source_origin: 'managed', source_origin: 'managed',
uploaded_at: new Date().toISOString(), uploaded_at: new Date().toISOString(),
...(dimensions && { width: dimensions.width, height: dimensions.height }),
}).returning('id'); }).returning('id');
const photoId = insertResult[0]?.id || insertResult[0]; const photoId = insertResult[0]?.id || insertResult[0];
@@ -76,6 +76,11 @@ class LocalFsStorage {
return fs.createReadStream(abs); return fs.createReadStream(abs);
} }
async getRange(relPath, start, end) {
const abs = this._resolve(relPath);
return fs.createReadStream(abs, { start, end });
}
async getToFile(relPath, localPath) { async getToFile(relPath, localPath) {
const abs = this._resolve(relPath); const abs = this._resolve(relPath);
await fsp.mkdir(path.dirname(localPath), { recursive: true }); await fsp.mkdir(path.dirname(localPath), { recursive: true });
@@ -80,6 +80,10 @@ class S3StorageBackend {
return this.adapter.downloadStream(this._key(relPath)); return this.adapter.downloadStream(this._key(relPath));
} }
async getRange(relPath, start, end) {
return this.adapter.downloadStream(this._key(relPath), { range: `bytes=${start}-${end}` });
}
async getToFile(relPath, localPath) { async getToFile(relPath, localPath) {
await fsp.mkdir(path.dirname(localPath), { recursive: true }); await fsp.mkdir(path.dirname(localPath), { recursive: true });
await this.adapter.download(this._key(relPath), localPath); await this.adapter.download(this._key(relPath), localPath);
@@ -29,6 +29,7 @@
* @property {(relPath: string, body: NodeJS.ReadableStream | Buffer, options?: PutOptions) => Promise<void>} put * @property {(relPath: string, body: NodeJS.ReadableStream | Buffer, options?: PutOptions) => Promise<void>} put
* @property {(relPath: string, localPath: string, options?: PutOptions) => Promise<void>} putFromFile * @property {(relPath: string, localPath: string, options?: PutOptions) => Promise<void>} putFromFile
* @property {(relPath: string) => Promise<NodeJS.ReadableStream>} get - Returns a readable stream of the object body. * @property {(relPath: string) => Promise<NodeJS.ReadableStream>} get - Returns a readable stream of the object body.
* @property {(relPath: string, start: number, end: number) => Promise<NodeJS.ReadableStream>} getRange - Returns a readable stream of the object body for the inclusive byte range [start, end]. Used by video range-request handlers.
* @property {(relPath: string, localPath: string) => Promise<void>} getToFile - Streams the object to a local path (creates parent dirs). * @property {(relPath: string, localPath: string) => Promise<void>} getToFile - Streams the object to a local path (creates parent dirs).
* @property {(relPath: string) => Promise<boolean>} exists * @property {(relPath: string) => Promise<boolean>} exists
* @property {(relPath: string) => Promise<StatResult|null>} stat - Null if missing. * @property {(relPath: string) => Promise<StatResult|null>} stat - Null if missing.
@@ -172,75 +172,82 @@ async function checkAndNotifyUpdates() {
} }
/** /**
* Force send update notification (for manual trigger from admin UI) * Send a TEST notification email to the configured recipients (manual
* trigger from the admin "Send Test Email" button on the Update
* Notifications page).
*
* Uses the version_update_test template (migration 087) which is
* explicitly labelled as a configuration check rather than a real update
* notice. Crucially this path does NOT require updateAvailable to be
* true it sends regardless of whether the instance is on the latest
* version, so admins can verify their SMTP + recipient list work before
* an actual update lands (#418).
*
* Does NOT update last_notified_version that field is owned by the
* real-update path so a test send doesn't shadow a future genuine
* notification for the same version.
*/ */
async function sendUpdateNotificationNow() { async function sendTestUpdateNotification() {
logger.info('Manually triggering update notification...'); logger.info('Sending test update notification email...');
try { try {
// Check for available updates
const updateInfo = await checkForUpdates(true); // Force refresh
if (!updateInfo.updateAvailable) {
return { success: false, message: 'No updates available' };
}
const newVersion = updateInfo.latest.forChannel;
const settings = await getUpdateNotificationSettings(); const settings = await getUpdateNotificationSettings();
// Get recipients
const recipients = await getNotificationRecipients(settings.recipients); const recipients = await getNotificationRecipients(settings.recipients);
if (recipients.length === 0) { if (recipients.length === 0) {
return { success: false, message: 'No recipients configured' }; return { success: false, message: 'No recipients configured' };
} }
// Ensure email transporter is initialized // checkForUpdates is best-effort here — we want the version + channel
await initializeTransporter(); // for the email body, but a transient failure shouldn't block the test
// send. Fall back to env-derived defaults so the email still goes out.
let updateInfo;
try {
updateInfo = await checkForUpdates(true);
} catch (error) {
logger.warn('checkForUpdates failed during test send, using fallbacks:', error.message);
updateInfo = {
current: process.env.npm_package_version || 'unknown',
channel: process.env.UPDATE_CHANNEL || 'stable'
};
}
// Send email to each recipient
const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`;
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable'; const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
await initializeTransporter();
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
for (const email of recipients) { for (const email of recipients) {
try { try {
await sendTemplateEmail(email, 'version_update_available', { await sendTemplateEmail(email, 'version_update_test', {
current_version: updateInfo.current, current_version: updateInfo.current,
new_version: newVersion,
channel: channelLabel, channel: channelLabel,
release_notes_url: releaseNotesUrl recipient_email: email
}); });
successCount++; successCount++;
} catch (error) { } catch (error) {
errorCount++; errorCount++;
logger.error(`Failed to send update notification to ${email}:`, error); logger.error(`Failed to send test update notification to ${email}:`, error);
} }
} }
// Update last notified version
if (successCount > 0) {
await updateLastNotifiedVersion(newVersion);
}
return { return {
success: successCount > 0, success: successCount > 0,
newVersion,
successCount, successCount,
errorCount, errorCount,
totalRecipients: recipients.length totalRecipients: recipients.length
}; };
} catch (error) { } catch (error) {
logger.error('Error sending manual update notification:', error); logger.error('Error sending test update notification:', error);
return { success: false, message: error.message }; return { success: false, message: error.message };
} }
} }
module.exports = { module.exports = {
checkAndNotifyUpdates, checkAndNotifyUpdates,
sendUpdateNotificationNow, sendTestUpdateNotification,
getUpdateNotificationSettings, getUpdateNotificationSettings,
getNotificationRecipients getNotificationRecipients
}; };
+93
View File
@@ -0,0 +1,93 @@
/**
* Tests for the shared slug util extracted in #525 from the inline
* pipelines in adminEvents.js, events.js, v1/events.js, adminArchives.js.
*
* Two contracts to pin:
* 1. ASCII inputs produce byte-identical output to the previous
* inline pipelines, so existing event/archive slugs in the DB
* keep resolving via the same lookup path after the refactor.
* 2. Accented characters (Portuguese, German, French, Spanish) are
* transliterated to their ASCII bases (Decoração decoracao)
* instead of being dropped (Decoração decorao) as the legacy
* pipelines did same fix as #502 for category slugs.
*/
const { slugify } = require('../slug');
describe('slugify — ASCII parity with the legacy event-style pipeline', () => {
// Replays the exact transformation used by adminEvents.js before the
// refactor: lowercase → replace [^a-z0-9] with '-' → collapse → trim.
const legacy = (s) =>
String(s).toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const samples = [
'Wedding 2026',
' Hello World ',
'birthday-party-42',
'event_with_underscores',
'CamelCase Event Name',
'',
'event.with.dots',
'event!@#$%^&*()chars',
'2026-06-12',
];
it.each(samples)('matches legacy output for ASCII input: %j', (input) => {
expect(slugify(input)).toBe(legacy(input));
});
});
describe('slugify — accented characters (the #502 fix, now shared)', () => {
// The legacy pipeline produced f-mlia for "Família" because the í
// got replaced with '-' rather than being NFD-normalised to 'i'.
// These tests pin the corrected behaviour across the locales the
// app already ships in (de, es, fr, nl, pt, ru).
it.each([
['Decoração', 'decoracao'],
['Família', 'familia'],
['Recepção', 'recepcao'],
['Über uns', 'uber-uns'],
['Niño', 'nino'],
['Fête de famille', 'fete-de-famille'],
['L\'Évènement', 'l-evenement'],
['Crème Brûlée', 'creme-brulee'],
])('transliterates %j → %j', (input, expected) => {
expect(slugify(input)).toBe(expected);
});
it('CJK and other scripts without NFD decompositions still strip cleanly', () => {
// NFD doesn't decompose Chinese characters to ASCII, so they get
// dropped by the [^a-z0-9]+ replace. Output is sensible if not
// perfect — the surrounding ASCII tokens survive.
expect(slugify('Photo 混合 Test')).toBe('photo-test');
// Pure-CJK names collapse to empty after trim — caller's job to
// handle (typically by appending a uniqueness suffix).
expect(slugify('婚礼')).toBe('');
});
});
describe('slugify — input edge cases', () => {
it('returns empty string for null / undefined / empty', () => {
expect(slugify(null)).toBe('');
expect(slugify(undefined)).toBe('');
expect(slugify('')).toBe('');
});
it('coerces non-string input to string before slugifying', () => {
expect(slugify(2026)).toBe('2026');
expect(slugify(true)).toBe('true');
});
it('collapses any run of non-alphanumeric chars into a single dash', () => {
expect(slugify('a!@#$%b')).toBe('a-b');
expect(slugify('a b\t\nc')).toBe('a-b-c');
});
it('trims leading and trailing dashes', () => {
expect(slugify('---hello---')).toBe('hello');
expect(slugify('!!!world!!!')).toBe('world');
});
});
+121 -1
View File
@@ -1,3 +1,5 @@
const path = require('path');
/** /**
* Sanitize a string to be used as a filename component * Sanitize a string to be used as a filename component
* @param {string} str - The string to sanitize * @param {string} str - The string to sanitize
@@ -51,7 +53,125 @@ function generatePhotoFilename(eventName, categoryName, counter, extension) {
return `${sanitizedEvent}_${sanitizedCategory}_${paddedCounter}${extension}`; return `${sanitizedEvent}_${sanitizedCategory}_${paddedCounter}${extension}`;
} }
/**
* Strip characters that are unsafe inside a Content-Disposition `filename="..."`
* token: CR/LF/NUL (header injection), backslashes, double-quotes, and other
* control bytes. Returns an ASCII-only fallback name (non-ASCII bytes are
* dropped pair with `buildContentDisposition()` which also emits a
* RFC 5987 `filename*=UTF-8''…` parameter so modern clients see unicode).
*
* Path separators are stripped so an `original_filename` like `../../etc/passwd`
* can never be coaxed into a directory write on a client that honours paths.
*/
function sanitizeForContentDisposition(name) {
if (!name) return 'download';
let sanitized = String(name)
// Header-breaking bytes
.replace(/[\r\n\0]/g, '')
// Other ASCII control characters (0x010x1F, 0x7F)
// eslint-disable-next-line no-control-regex
.replace(/[\x01-\x1F\x7F]/g, '')
// Path separators and quote chars that would close the quoted-string
.replace(/[/\\"]/g, '_')
.trim();
// Strip any non-ASCII for the legacy `filename=` token. The `filename*=`
// parameter carries the unicode form.
// eslint-disable-next-line no-control-regex
sanitized = sanitized.replace(/[^\x20-\x7E]/g, '_');
// Collapse runs of underscores introduced by replacement.
sanitized = sanitized.replace(/_{2,}/g, '_').replace(/^[_.]+|_+$/g, '');
return sanitized || 'download';
}
/**
* Build a full `Content-Disposition` header value with both an ASCII
* fallback (`filename="…"`) and an RFC 5987 unicode form
* (`filename*=UTF-8''…`). This is what RFC 6266 §4 recommends for any
* filename that may contain non-ASCII bytes (which `photos.original_filename`
* can, since it's the raw `multer.file.originalname`).
*/
function buildContentDisposition(name, disposition = 'attachment') {
const safeName = name ? String(name) : 'download';
const asciiFallback = sanitizeForContentDisposition(safeName);
// RFC 5987: percent-encode every byte that isn't an attr-char. encodeURIComponent
// is a superset of attr-char (it encodes `*'%` etc.) — close enough and
// browser-compatible.
const encoded = encodeURIComponent(safeName).replace(/['()]/g, escape);
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}
/**
* Sanitize a string for use as a zip-entry name. Preserves spaces,
* parentheses, and unicode (modern zip readers handle UTF-8 entry names),
* but strips path-traversal sequences and platform-reserved characters so
* extracting the zip can never escape its target directory.
*/
function sanitizeForZipEntry(name) {
if (!name) return 'download';
let sanitized = String(name)
// Header-breaking bytes (shouldn't appear in zip but cheap defence)
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1F\x7F]/g, '')
// Normalise path separators to underscore so `evil/../passwd` becomes
// `evil_.._passwd` instead of an actual subpath.
.replace(/[/\\]/g, '_')
// Strip leading dots so `..` can't become an upward reference.
.replace(/^\.+/, '')
.trim();
return sanitized || 'download';
}
/**
* Deterministically rename duplicate names by appending `_1`, `_2`, before
* the extension. Input order is preserved; the first occurrence keeps its
* original name. Used when a bulk-download zip is built with original camera
* filenames and two photos in the same event happen to share one (e.g. same
* camera body across two shoot days).
*
* @param {string[]} names
* @returns {string[]} new array of the same length, with collisions resolved
*/
function uniquifyZipNames(names) {
const seen = new Map();
const out = new Array(names.length);
for (let i = 0; i < names.length; i += 1) {
const original = names[i] || 'download';
if (!seen.has(original)) {
seen.set(original, 0);
out[i] = original;
continue;
}
// Find the next free `_N` suffix. We bump the stored counter so the
// next collision picks the *next* number instead of starting from 1 again.
let n = seen.get(original) + 1;
const ext = path.extname(original);
const stem = ext ? original.slice(0, -ext.length) : original;
let candidate;
do {
candidate = `${stem}_${n}${ext}`;
n += 1;
} while (seen.has(candidate));
seen.set(original, n - 1);
seen.set(candidate, 0);
out[i] = candidate;
}
return out;
}
module.exports = { module.exports = {
sanitizeFilename, sanitizeFilename,
generatePhotoFilename generatePhotoFilename,
sanitizeForContentDisposition,
buildContentDisposition,
sanitizeForZipEntry,
uniquifyZipNames,
}; };
+35
View File
@@ -0,0 +1,35 @@
/**
* URL-safe slug generation shared across event, archive, and v1 upload
* routes (#525 follow-up to #502). Previously every caller had its own
* inline `name.toLowerCase().replace(/[^a-z0-9]/g, '-')` pipeline, each
* with the same latent bug: JS's `\w` and the ASCII alphanumeric class
* silently drop non-ASCII letters instead of transliterating them
* (`Decoração` `decorao`, `Família` `f-mlia`).
*
* Fix mirrors #502: NFD-normalize so accented characters split into a
* base letter + combining mark, then strip the combining-mark range
* (U+0300U+036F) so the ASCII base survives. Single regex pass after
* that `[^a-z0-9]+` collapses any run of non-alphanumerics into one
* dash, no separate collapse step needed.
*
* For pure-ASCII input the output is byte-identical to the previous
* inline pipelines, so existing slugs continue to round-trip cleanly
* via lookups; only new inserts with non-ASCII names start producing
* the corrected slugs.
*
* Not exported as the default category slug `adminCategories.js`
* intentionally preserves underscores (the legacy category pipeline
* used `\w` not `[a-z0-9]`), so changing it here would silently shift
* "wedding_party" "wedding-party" on new inserts. Categories keep
* their own pipeline as fixed in #502.
*/
function slugify(input) {
return String(input ?? '')
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
module.exports = { slugify };
+30 -5
View File
@@ -12,6 +12,20 @@ const logger = require('./logger');
* @param {string} reason - Reason for revocation * @param {string} reason - Reason for revocation
* @param {Object} metadata - Additional metadata * @param {Object} metadata - Additional metadata
*/ */
/**
* Resolve the per-token unique identifier used as the lookup key in
* revoked_tokens.token_id. Customer JWTs (#354) use `customerId` instead
* of `id`, so the original `${payload.id}-${payload.iat}` produced
* `undefined-…` keys for every customer token and silently collided
* across all customer logins. Falling back to customerId and finally
* to a stable hash of the payload keeps the key unique per token.
*/
function buildTokenId(payload) {
if (payload.jti) return payload.jti;
const subject = payload.id ?? payload.customerId ?? payload.guestId ?? payload.eventId ?? 'anon';
return `${subject}-${payload.iat}-${payload.type || 'unknown'}`;
}
async function revokeToken(token, reason, metadata = {}) { async function revokeToken(token, reason, metadata = {}) {
try { try {
// Extract token info without full verification (it might be compromised) // Extract token info without full verification (it might be compromised)
@@ -23,18 +37,29 @@ async function revokeToken(token, reason, metadata = {}) {
// Decode payload // Decode payload
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
// user_id is integer-typed in revoked_tokens; for non-admin tokens
// we may not have an integer (customer) or any id at all (gallery
// tokens use eventId). Coerce to null instead of letting an
// undefined/string slip through and cause an INSERT type error.
const userIdNumeric = Number.isInteger(payload.id) ? payload.id : null;
// onConflict.ignore: revoking an already-revoked token is a no-op,
// not an error. Hits the unique (token_id) index when the same JWT
// is logged out twice (e.g. duplicate /logout from two tabs, or a
// session-expiry path that races with an explicit logout). The
// previous insert was authoritative; nothing to do.
await db('revoked_tokens').insert({ await db('revoked_tokens').insert({
token_id: payload.jti || `${payload.id}-${payload.iat}`, // JWT ID or fallback token_id: buildTokenId(payload),
user_id: payload.id, user_id: userIdNumeric,
token_type: payload.type, token_type: payload.type,
revoked_at: new Date().toISOString(), revoked_at: new Date().toISOString(),
expires_at: new Date(payload.exp * 1000).toISOString(), expires_at: new Date(payload.exp * 1000).toISOString(),
reason, reason,
metadata: JSON.stringify(metadata) metadata: JSON.stringify(metadata)
}); }).onConflict('token_id').ignore();
logger.info('Token revoked', { logger.info('Token revoked', {
userId: payload.id, userId: payload.id ?? payload.customerId ?? null,
tokenType: payload.type, tokenType: payload.type,
reason reason
}); });
@@ -53,7 +78,7 @@ async function revokeToken(token, reason, metadata = {}) {
*/ */
async function isTokenRevoked(decodedToken) { async function isTokenRevoked(decodedToken) {
try { try {
const tokenId = decodedToken.jti || `${decodedToken.id}-${decodedToken.iat}`; const tokenId = buildTokenId(decodedToken);
const revoked = await db('revoked_tokens') const revoked = await db('revoked_tokens')
.where('token_id', tokenId) .where('token_id', tokenId)
+48 -9
View File
@@ -2,20 +2,34 @@ const ADMIN_COOKIE_NAME = 'admin_token';
const GALLERY_COOKIE_NAME = 'gallery_token'; const GALLERY_COOKIE_NAME = 'gallery_token';
const GALLERY_COOKIE_PREFIX = 'gallery_token_'; const GALLERY_COOKIE_PREFIX = 'gallery_token_';
const GUEST_COOKIE_PREFIX = 'guest_token_'; const GUEST_COOKIE_PREFIX = 'guest_token_';
// Customer-account session cookie (#354). Distinct name from the admin
// cookie so a single browser can hold both an admin and a customer
// session without one clobbering the other (e.g. for the admin
// dogfooding the customer dashboard).
const CUSTOMER_COOKIE_NAME = 'customer_token';
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
/** /**
* Cookie "Secure" flag mode: * Cookie "Secure" flag mode:
* - true always set Secure (HTTPS-only) * - true always set Secure (HTTPS-only cookie won't be sent over HTTP at all)
* - false never set Secure (allow plain HTTP) * - false never set Secure (allow plain HTTP cookie has no in-flight protection)
* - 'auto' decide per-request based on req.secure (X-Forwarded-Proto * - 'auto' decide per-request based on req.secure (X-Forwarded-Proto
* via Express `trust proxy`). Useful when the same deployment * via Express `trust proxy`). Emits Secure when actual HTTPS is
* is reachable over both HTTPS (via reverse proxy) and LAN HTTP. * detected, omits it on plain HTTP. This is the right default
* for deployments reachable via both HTTPS (reverse proxy) and
* LAN HTTP, and for first-time installs that haven't set up a
* reverse proxy yet.
* *
* Default: follows NODE_ENV (production true, dev false) unchanged * Default:
* from previous behavior. Users who want the auto mode must opt in with * - production 'auto' (#427: previously hard `true`, which caused silent
* COOKIE_SECURE=auto in their .env. * login loops over HTTP because the browser drops the
* Secure cookie. 'auto' is strictly more lenient than `true`
* on real HTTPS req.secure is true Secure flag still
* emitted so this is not a security regression for
* reverse-proxy deployments. Users who explicitly want the
* HTTPS-only behaviour can still set COOKIE_SECURE=true.)
* - dev false (allow http://localhost in browsers without HSTS gymnastics)
*/ */
const secureCookieMode = (() => { const secureCookieMode = (() => {
const raw = typeof process.env.COOKIE_SECURE === 'string' const raw = typeof process.env.COOKIE_SECURE === 'string'
@@ -24,8 +38,10 @@ const secureCookieMode = (() => {
if (raw === 'auto') return 'auto'; if (raw === 'auto') return 'auto';
if (raw === 'true') return true; if (raw === 'true') return true;
if (raw === 'false') return false; if (raw === 'false') return false;
// No env var set → legacy default // No env var set → infer from NODE_ENV. Production defaults to 'auto'
return process.env.NODE_ENV === 'production'; // (per-request) rather than hard `true` so first-time HTTP installs don't
// silently fail (#427).
return process.env.NODE_ENV === 'production' ? 'auto' : false;
})(); })();
const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax'; const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax';
const cookieDomain = process.env.COOKIE_DOMAIN; const cookieDomain = process.env.COOKIE_DOMAIN;
@@ -102,6 +118,15 @@ function clearAdminAuthCookie(res) {
res.clearCookie(ADMIN_COOKIE_NAME, buildClearCookieOptions()); res.clearCookie(ADMIN_COOKIE_NAME, buildClearCookieOptions());
} }
function setCustomerAuthCookie(res, token) {
if (!token) return;
res.cookie(CUSTOMER_COOKIE_NAME, token, buildCookieOptionsWithExpiry(res));
}
function clearCustomerAuthCookie(res) {
res.clearCookie(CUSTOMER_COOKIE_NAME, buildClearCookieOptions());
}
function setGalleryAuthCookies(res, token, slug) { function setGalleryAuthCookies(res, token, slug) {
if (!token) return; if (!token) return;
const options = buildCookieOptionsWithExpiry(res); const options = buildCookieOptionsWithExpiry(res);
@@ -138,6 +163,16 @@ function getAdminTokenFromRequest(req) {
return req.cookies?.[ADMIN_COOKIE_NAME] || null; return req.cookies?.[ADMIN_COOKIE_NAME] || null;
} }
/**
* Customer JWT (#354). Cookie-only deliberately no Authorization
* header fallback so an admin Bearer token attached by the shared
* events.service.ts auto-auth path can't accidentally satisfy a
* customer-only endpoint and trigger "wrong token type" downstream.
*/
function getCustomerTokenFromRequest(req) {
return req.cookies?.[CUSTOMER_COOKIE_NAME] || null;
}
function getGalleryTokenFromRequest(req, slug) { function getGalleryTokenFromRequest(req, slug) {
const header = req.headers?.authorization; const header = req.headers?.authorization;
if (header && header.startsWith('Bearer ')) { if (header && header.startsWith('Bearer ')) {
@@ -198,12 +233,16 @@ module.exports = {
GALLERY_COOKIE_NAME, GALLERY_COOKIE_NAME,
GALLERY_COOKIE_PREFIX, GALLERY_COOKIE_PREFIX,
GUEST_COOKIE_PREFIX, GUEST_COOKIE_PREFIX,
CUSTOMER_COOKIE_NAME,
sanitizeSlugForCookie, sanitizeSlugForCookie,
setAdminAuthCookie, setAdminAuthCookie,
clearAdminAuthCookie, clearAdminAuthCookie,
setCustomerAuthCookie,
clearCustomerAuthCookie,
setGalleryAuthCookies, setGalleryAuthCookies,
clearGalleryAuthCookies, clearGalleryAuthCookies,
getAdminTokenFromRequest, getAdminTokenFromRequest,
getCustomerTokenFromRequest,
getGalleryTokenFromRequest, getGalleryTokenFromRequest,
getGuestTokenFromRequest, getGuestTokenFromRequest,
}; };
+37
View File
@@ -3,6 +3,43 @@
set -e set -e
# Permission handling (#484): the image starts as root so this script can
# chown bind-mounted host volumes to UID 1001 (nodejs) before dropping
# privileges via su-exec. This avoids the fresh-install restart loop where
# the host directory's UID (commonly 1000) didn't match the container's
# hard-coded nodejs user. Compose deployments that pin `user:` to something
# other than root skip this branch — they own permissions themselves and hit
# the preflight check below instead.
if [ "$(id -u)" = "0" ]; then
if ! chown -R nodejs:nodejs /app/storage /app/data /app/logs 2>/dev/null; then
echo "ERROR: failed to chown /app/storage, /app/data, /app/logs to nodejs (UID 1001)." >&2
echo " This usually means the host filesystem rejects chown (e.g. NFS without root squash" >&2
echo " disabled, or a SELinux/AppArmor policy blocking the operation)." >&2
echo " Workaround: pre-chown the host directories to 1001:1001 and pin 'user: \"1001:1001\"'" >&2
echo " in your compose file so this script never tries to chown them itself." >&2
echo " See https://docs.picpeak.app/deployment/docker#permissions" >&2
exit 1
fi
exec su-exec nodejs:nodejs "$0" "$@"
fi
# Belt-and-suspenders: if we got here as non-root (compose `user:` override),
# verify the bind mounts are actually writable before proceeding. Failing
# loud here beats the previous behavior — silent mkdir-||-true at line 69
# followed by a confusing migration error and a restart loop.
_uid="$(id -u)"
_gid="$(id -g)"
for _dir in /app/storage /app/data /app/logs; do
if [ ! -w "$_dir" ]; then
echo "ERROR: $_dir is not writable by UID $_uid." >&2
echo " Either drop the 'user:' override from your compose file so the container starts as" >&2
echo " root and can self-fix permissions, or run on the host:" >&2
echo " chown -R $_uid:$_gid <host-mount-for-$_dir>" >&2
echo " See https://docs.picpeak.app/deployment/docker#permissions" >&2
exit 1
fi
done
host="${DB_HOST:-postgres}" host="${DB_HOST:-postgres}"
port="${DB_PORT:-5432}" port="${DB_PORT:-5432}"
user="${DB_USER:-picpeak}" user="${DB_USER:-picpeak}"
+24 -3
View File
@@ -15,7 +15,13 @@ services:
- picpeak-network - picpeak-network
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak}"] # `pg_isready -U <user>` without -d defaults to probing a database
# whose name matches the user — postgres then logs constant
# `FATAL: database "picpeak" does not exist` even though the
# actual DB is `picpeak_prod`. Pinning -d to DB_NAME makes the
# probe hit the real database and silences the log noise that
# made #484's reporter think the install was broken.
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak} -d ${DB_NAME:-picpeak}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -64,8 +70,13 @@ services:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
# Backend exposes /health on internal port 3000 # Backend exposes /health on internal port 3000.
test: ["CMD", "curl", "-f", "http://localhost:3000/health"] # The backend image only ships wget (Alpine base) — using curl
# here makes `docker ps` show the container as `unhealthy`
# indefinitely even when /health responds. Mirrors the wget-based
# HEALTHCHECK already declared in backend/Dockerfile so docker
# compose, plain `docker run`, and `docker ps` all agree.
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -77,6 +88,16 @@ services:
container_name: picpeak-frontend container_name: picpeak-frontend
# Note: Pre-built frontend uses Nginx to proxy /api to backend:3001. # Note: Pre-built frontend uses Nginx to proxy /api to backend:3001.
# Prefer keeping API base as '/api' in builds to avoid CORS. # Prefer keeping API base as '/api' in builds to avoid CORS.
environment:
# Substituted into index.html at container start (see frontend/
# docker-entrypoint.sh) so social link previews reaching the
# static SPA shell (WhatsApp Business API, Twilio, LinkPreview,
# etc. — see #521) show the configured brand instead of the
# generic "PicPeak" default. Defaults applied when unset; restart
# the frontend container after changing for the new title to
# take effect.
- BRAND_TITLE=${BRAND_TITLE:-PicPeak}
- BRAND_DESCRIPTION=${BRAND_DESCRIPTION:-Photo gallery shared with PicPeak.}
ports: ports:
- "${FRONTEND_PORT:-3000}:80" - "${FRONTEND_PORT:-3000}:80"
networks: networks:
+9 -5
View File
@@ -31,11 +31,11 @@ services:
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001} - ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}
- STORAGE_PATH=/app/storage - STORAGE_PATH=/app/storage
# Optional: run container as matching host user to avoid bind mount permission issues # No `user:` directive — as of #484, the container starts as root,
- PUID=${PUID:-1001} # chowns the bind mounts to nodejs (UID 1001), then drops privileges
- PGID=${PGID:-1001} # via su-exec. PUID/PGID env vars are no longer read; if you need
# Use host-matching user ID/GID so bind-mounted folders are writable # a different runtime UID, pre-chown the host dirs and pin
user: "${PUID:-1001}:${PGID:-1001}" # `user: "<uid>:<gid>"` here.
volumes: volumes:
- ./events:/app/events - ./events:/app/events
- ./data:/app/data - ./data:/app/data
@@ -121,6 +121,10 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=${NODE_ENV:-production} - 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: ports:
- "${FRONTEND_PORT:-3000}:80" - "${FRONTEND_PORT:-3000}:80"
depends_on: depends_on:
+19 -3
View File
@@ -33,8 +33,11 @@ FROM nginx:1.28-alpine
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs) # Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache RUN apk upgrade --no-cache
# Install runtime dependencies # Install runtime dependencies. `gettext` provides envsubst, used by
RUN apk add --no-cache curl # docker-entrypoint.sh for the BRAND_TITLE / BRAND_DESCRIPTION runtime
# substitution into index.html (#521 — runtime fix for self-hosters
# on the pre-built GHCR image who can't override at build time).
RUN apk add --no-cache curl gettext
# Remove default nginx config # Remove default nginx config
RUN rm -rf /etc/nginx/conf.d/* RUN rm -rf /etc/nginx/conf.d/*
@@ -45,6 +48,16 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built application from builder stage # Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# Snapshot index.html as a template so the entrypoint always renders
# from a known-good source — not from its own previous substitution.
# Container restarts can change BRAND_TITLE freely; the rendered file
# is recomputed from the .tpl each time.
RUN mv /usr/share/nginx/html/index.html /usr/share/nginx/html/index.html.tpl
# Runtime entrypoint that envsubsts the template and execs nginx
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set permissions (nginx user already exists in nginx:alpine) # Set permissions (nginx user already exists in nginx:alpine)
RUN chown -R nginx:nginx /usr/share/nginx/html && \ RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \ chown -R nginx:nginx /var/cache/nginx && \
@@ -62,5 +75,8 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
# Switch to non-root user # Switch to non-root user
USER nginx USER nginx
# Start nginx # Start nginx via the entrypoint so each container start re-renders
# index.html from the template against the current BRAND_TITLE /
# BRAND_DESCRIPTION env vars (defaults applied when unset).
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+10
View File
@@ -15,8 +15,18 @@ RUN npm ci --legacy-peer-deps
# Copy source code # Copy source code
COPY . . COPY . .
# Run as non-root: the node:alpine image ships a `node` user (uid 1000).
RUN chown -R node:node /app
USER node
# Expose the development server port # Expose the development server port
EXPOSE 3005 EXPOSE 3005
# Vite returns 200 on '/' once it's serving the SPA — good enough for a
# liveness probe in dev. start-period is long because cold dep-optimize on
# first run can take 20-30s.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3005/ || exit 1
# Start development server # Start development server
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3005"] CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3005"]
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# Frontend container entrypoint (#521).
#
# Renders /usr/share/nginx/html/index.html from a build-time .tpl
# snapshot, substituting BRAND_TITLE / BRAND_DESCRIPTION env vars into
# the static HTML head. This is what self-hosters running the pre-built
# GHCR image use to brand their link-preview fallback — see the matching
# comment in frontend/index.html for the three-path architecture
# (per-event OG endpoint, crawler-detected SPA shell, and this static
# fallback that catches WhatsApp Business / Twilio / LinkPreview).
#
# Re-runs on every container start. The .tpl is the immutable source so
# changing BRAND_TITLE in compose env and `docker compose up -d frontend`
# is enough — no rebuild required.
#
# Locked to BRAND_TITLE + BRAND_DESCRIPTION explicitly (rather than
# letting envsubst expand every ${...} it finds) so the JS bundle's
# template literals in /assets/*.js stay untouched if anyone ever
# accidentally points the substitution at them.
set -eu
: "${BRAND_TITLE:=PicPeak}"
: "${BRAND_DESCRIPTION:=Photo gallery shared with PicPeak.}"
export BRAND_TITLE BRAND_DESCRIPTION
TEMPLATE=/usr/share/nginx/html/index.html.tpl
RENDERED=/usr/share/nginx/html/index.html
if [ -f "$TEMPLATE" ]; then
envsubst '${BRAND_TITLE} ${BRAND_DESCRIPTION}' < "$TEMPLATE" > "$RENDERED"
else
# Template missing — image build skipped the .tpl rename for some
# reason. Don't crash: nginx can still serve whatever is at
# $RENDERED (probably the unsubstituted output of `npm run build`).
# Log loudly so it's visible during boot.
echo "[frontend-entrypoint] WARN: $TEMPLATE missing; serving $RENDERED as-is." >&2
fi
exec "$@"

Some files were not shown because too many files have changed in this diff Show More