Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b86669f1e1 | |||
| 80296282e8 | |||
| 5551c89bda | |||
| dbde67c0fa | |||
| 3ceeccd85a | |||
| fd8ee5d52c | |||
| e016f510b6 | |||
| d5a37df2c4 | |||
| d5823c79d9 | |||
| 04795219a0 | |||
| 9ae1f79769 | |||
| 54b185db47 | |||
| 578397bc6b | |||
| 2a309c75a7 | |||
| b5e7f9cec1 | |||
| 08fa2e9b63 | |||
| 7ef0e40e7c | |||
| 1b521e761c | |||
| ce1ccf0a4d | |||
| 793aa1247e | |||
| b351d17ee9 | |||
| 5488de3383 | |||
| 6b6ac64346 | |||
| 27e9b0535e | |||
| dba98f1325 | |||
| 1cf492f4b9 | |||
| 955ada4945 | |||
| 9607b4666c | |||
| 8c36f80471 | |||
| c1e8e0d73a | |||
| 3e39112a12 | |||
| efa6b4a205 | |||
| 53139b8cb8 | |||
| b2bbf7efb5 | |||
| 600c29db8a | |||
| 4d92fb4590 | |||
| 03cf09a7bf | |||
| c900be92dd | |||
| 5311588baf | |||
| dae47518fb | |||
| fd15be6247 | |||
| cf10da29ca | |||
| baf5f27fb4 | |||
| 83fc58a523 | |||
| 8b72721812 | |||
| e0ad7ac2a7 | |||
| 085a2e5fed | |||
| a0ebc97cdd | |||
| 4d3f2470bc | |||
| 8f0108ce23 | |||
| bdd973eaf9 | |||
| c042a33431 | |||
| 633a2ae724 | |||
| e8c2212dad | |||
| 4b4ecfdf71 | |||
| b960639035 | |||
| 3465b55abc | |||
| d44e1adba7 | |||
| 7b8ee1c148 | |||
| b2b46d311d | |||
| 42c5cda38c | |||
| 91d47590ae | |||
| db3f1b83ce | |||
| 2d5a2ad78a | |||
| 763fd4593f | |||
| df83b3e923 | |||
| 92bb9e1a12 | |||
| 6901e2661e | |||
| 1505775678 | |||
| 2619049a95 | |||
| acb387f69c | |||
| ebc7da21be | |||
| afcdf0d389 | |||
| 4da6726e57 | |||
| a747eb351d | |||
| 4fc07d9282 | |||
| 941453fd7f | |||
| 482e91bbf8 | |||
| aa8cca165d | |||
| 5a9ca4ad57 | |||
| aac60fa895 | |||
| 51890e1aa5 | |||
| 1e7806961f | |||
| 2e828fcf9f | |||
| 99e60a2433 | |||
| 061712ebf1 | |||
| 98f3c3df41 | |||
| 33de294d57 | |||
| 38343e62de | |||
| 9d2db9a73b | |||
| d2d55098d6 | |||
| 577c4bdf29 | |||
| 86b33d4dda | |||
| f12062f1e7 | |||
| 848430e72b | |||
| 165ebce8d1 | |||
| 3a490844e1 | |||
| 72c2b5c796 | |||
| 826e43ebac | |||
| 019b0c0301 | |||
| 7eeef2ba98 | |||
| 365582e678 | |||
| 3083c748b9 | |||
| 61f1d13210 | |||
| 06b33ced94 | |||
| d4198e1bbc | |||
| 62b3ed6364 | |||
| 87834a7fff | |||
| 4225cd153f | |||
| 1e4f762871 | |||
| d300426390 | |||
| b6b58d0659 | |||
| a135544cab | |||
| 57ea08e1ed | |||
| d39406b241 | |||
| d4155c4611 | |||
| 0b0b1bb2d5 | |||
| 409ddf9c93 | |||
| bc58520cd2 | |||
| d1034ce1c6 | |||
| a803491cf4 | |||
| 3869e5c0dc | |||
| 1cbb0c4cff | |||
| 6750f5d3b0 | |||
| c3256dc6bf | |||
| 067e460a4d | |||
| 3678193ae2 | |||
| 74eacbc78f | |||
| 37bf894412 | |||
| 506b5c3dc4 | |||
| ab6db37326 | |||
| eb2ce290a7 | |||
| 8a4c1a7c0a | |||
| 4d3836fb2e | |||
| 75499992eb | |||
| 62643f241b | |||
| 64f606152f | |||
| e2a698e892 | |||
| d1d71dba25 | |||
| bb81fa5f4b | |||
| 03e19893b3 | |||
| 279314e4b7 | |||
| 730912a3f4 | |||
| ff9fb64e75 | |||
| 9cbbe74051 | |||
| 2e1c71c1ab | |||
| f6ca713a6e | |||
| 197cd8e1e0 | |||
| 681b440381 | |||
| 3daeac9e53 | |||
| 7febba2d9c | |||
| 0a3a53763c | |||
| 85a60a2dc7 | |||
| e74e73a3a0 |
+10
-6
@@ -68,6 +68,16 @@ EMAIL_FROM=noreply@yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
|
||||
# Static HTML title + description used for social link previews when the
|
||||
# fetcher doesn't trigger the per-event OG endpoint — most notably the
|
||||
# WhatsApp Business API and various 3rd-party preview-service caches
|
||||
# (#521). Set these to your brand so link previews aren't generic.
|
||||
# Substituted into index.html at frontend-container start, so changes
|
||||
# take effect on the next `docker compose up -d frontend` — no rebuild
|
||||
# required.
|
||||
BRAND_TITLE=PicPeak
|
||||
BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
|
||||
|
||||
# API URL for email assets (logos, images in notification emails)
|
||||
# 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.
|
||||
@@ -97,12 +107,6 @@ UPDATE_CHECK_ENABLED=true
|
||||
# Timezone
|
||||
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)
|
||||
VITE_UMAMI_URL=
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
|
||||
@@ -173,6 +173,17 @@ jobs:
|
||||
- name: Run Trivy vulnerability scanner (per-arch, by digest)
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
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'
|
||||
@@ -379,6 +390,12 @@ jobs:
|
||||
- name: Run Trivy vulnerability scanner (per-arch, by digest)
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
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'
|
||||
|
||||
@@ -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 ADMIN_EMAIL=admin@smoke.local \
|
||||
-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
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "3.49.1-beta.0"
|
||||
".": "3.55.0-beta.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
{
|
||||
".": "2.6.1"
|
||||
}
|
||||
{".":"3.44.0"}
|
||||
|
||||
+475
-210
@@ -5,266 +5,493 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.49.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.49.0-beta.0...v3.49.1-beta.0) (2026-05-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** scan multi-arch images per-arch by digest, pin trivy-action ([#476](https://github.com/the-luap/picpeak/issues/476)) ([1144e9d](https://github.com/the-luap/picpeak/commit/1144e9d1625eb69d8e2a53b1a3aa1b515798fc80))
|
||||
|
||||
## [3.49.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.48.1-beta.0...v3.49.0-beta.0) (2026-05-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **og:** per-event opt-in to use hero photo as social-share preview ([#474](https://github.com/the-luap/picpeak/issues/474)) ([d856340](https://github.com/the-luap/picpeak/commit/d856340f0d230ad26539ba1088f739f03aaafc13))
|
||||
* **og:** per-event opt-in to use hero photo as social-share preview ([#474](https://github.com/the-luap/picpeak/issues/474)) ([0bc7e2a](https://github.com/the-luap/picpeak/commit/0bc7e2af171d1a4c6e91ba541d293b90c17c111b))
|
||||
|
||||
## [3.48.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.48.0-beta.0...v3.48.1-beta.0) (2026-05-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **customer-routes:** Cache-Control: no-store on customer endpoints ([#470](https://github.com/the-luap/picpeak/issues/470)) ([3122dd0](https://github.com/the-luap/picpeak/commit/3122dd08a8bc08deb937236aaa3f750119a979b9))
|
||||
|
||||
## [3.48.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.47.2-beta.0...v3.48.0-beta.0) (2026-05-12)
|
||||
## [3.44.0](https://github.com/the-luap/picpeak/compare/v3.43.1...v3.44.0) (2026-05-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api/v1:** accept category_id on POST /events/:id/photos ([2d5a2ad](https://github.com/the-luap/picpeak/commit/2d5a2ad78a5f6c101315214399a9c158ff0549da))
|
||||
* **api/v1:** accept category_id on POST /events/:id/photos ([6901e26](https://github.com/the-luap/picpeak/commit/6901e2661ed74e69f19c52ce046ee911b818d463))
|
||||
* **branding:** Customer dashboard header toggles in Branding page ([b252cb6](https://github.com/the-luap/picpeak/commit/b252cb67eb3645224a279fbbe9c14871438473f8))
|
||||
* **branding:** toggle login-page logo frame + size ([75e41eb](https://github.com/the-luap/picpeak/commit/75e41eba036d07e6abaab301db42d16e77dd01be))
|
||||
* **clients:** scaffold top-level Clients section with sub-nav around Accounts ([9091ed4](https://github.com/the-luap/picpeak/commit/9091ed4012a85400f216ebfb40d5720c2c86a826))
|
||||
* customer accounts ([#354](https://github.com/the-luap/picpeak/issues/354)) — recurring logins, profile, password reset, branded customer surface ([fe52953](https://github.com/the-luap/picpeak/commit/fe5295373b0bfeec1e81086206ffab7ce1b91094))
|
||||
* **customers:** "Manage galleries" dialog on customer detail page ([6d1af7a](https://github.com/the-luap/picpeak/commit/6d1af7a0113b6e3de44ab9bf2645591f4d6d68b4))
|
||||
* **customers:** "Manage galleries" dialog with immediate access revocation + section reorder + portal-flag revert ([9be9296](https://github.com/the-luap/picpeak/commit/9be9296eb58d7733d0b5f8ce6eb48e9832f65e7c))
|
||||
* **customers:** customer portal ([#354](https://github.com/the-luap/picpeak/issues/354)) on top of feature-flags reorg ([087ef45](https://github.com/the-luap/picpeak/commit/087ef45942a8a51d09af2cd8ec85aca330f6cf7f))
|
||||
* **customers:** email customer when admin adds new gallery access ([c02c947](https://github.com/the-luap/picpeak/commit/c02c947463011de80d9af3e947fc6d1caadc3313))
|
||||
* **customers:** replace-assignments endpoint for a single customer ([5377b88](https://github.com/the-luap/picpeak/commit/5377b88e0e0c27ecde18c2a366fb7d06f279f2ec))
|
||||
* **gallery:** revoke customer-minted JWTs when assignment is removed ([55a5846](https://github.com/the-luap/picpeak/commit/55a5846f6f802a1bc1910bb046325fe272a1b584))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **customer:** don't log customer out on transient session-refresh errors ([9e418c7](https://github.com/the-luap/picpeak/commit/9e418c759ce508adf6025e0740468d8229938ffe))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* **customer-portal:** make the global flag UI-only, drop the kill-switch middleware ([3f44193](https://github.com/the-luap/picpeak/commit/3f4419356a4f30509052a6d00b71485af2c17f85))
|
||||
|
||||
## [3.47.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.47.1-beta.0...v3.47.2-beta.0) (2026-05-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **activity-log:** smart feature_flags_updated rendering + 33 missing activity types ([4703fd5](https://github.com/the-luap/picpeak/commit/4703fd574f57bfab327ffefec5986bb08b240c95))
|
||||
|
||||
## [3.47.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.47.0-beta.0...v3.47.1-beta.0) (2026-05-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **features:** customer-portal card uses 'Clients' to match sidebar wording ([441cc41](https://github.com/the-luap/picpeak/commit/441cc419377055a5872f71afb19036cc5c932b58))
|
||||
* **features:** customer-portal card uses 'Clients' to match sidebar wording ([dec2f5d](https://github.com/the-luap/picpeak/commit/dec2f5d3d2224b0d66f02bb9a562b4ddeaac77df))
|
||||
|
||||
## [3.47.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.46.3-beta.0...v3.47.0-beta.0) (2026-05-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **downloads:** preserve original camera filenames on download (opt-in) ([#493](https://github.com/the-luap/picpeak/issues/493)) ([826e43e](https://github.com/the-luap/picpeak/commit/826e43ebac940782be234630c50bbd54a3250f98))
|
||||
* **downloads:** preserve original camera filenames on download (opt-in) ([#493](https://github.com/the-luap/picpeak/issues/493)) ([7eeef2b](https://github.com/the-luap/picpeak/commit/7eeef2ba98a71a8b948cabf7f1c65eb3eb271d22))
|
||||
* **email-templates:** categorise + link to feature flags ([84c06af](https://github.com/the-luap/picpeak/commit/84c06affb73687529e35dbbac15801deb41dc4f2))
|
||||
* **email-templates:** categorise + sub-categorise + link to feature flags ([2cae3fe](https://github.com/the-luap/picpeak/commit/2cae3fe47deb667af5991ae1a90e3b5698693119))
|
||||
* **email-templates:** group Templates UI by category + Feature off chip ([5ec26fc](https://github.com/the-luap/picpeak/commit/5ec26fc9981028163cab122e229c83cdbbf35828))
|
||||
* **email-templates:** group Templates UI by category with core sub-sections ([53eecb6](https://github.com/the-luap/picpeak/commit/53eecb6f83ff75f1f3c75d68cee5e46d114ac8d1))
|
||||
* **email-templates:** seed missing locale translations + post-075 templates ([e3150e4](https://github.com/the-luap/picpeak/commit/e3150e42130cacf19dc7beadb8c86c76c0fff340))
|
||||
* **email-templates:** seed missing nl/pt/ru/fr translations ([358f7ee](https://github.com/the-luap/picpeak/commit/358f7ee99e2941ad179d5859178b35a80886542f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **email-templates:** backfill subcategory + customer password reset translations ([2343a16](https://github.com/the-luap/picpeak/commit/2343a162df070cd5bd6abdc331bc5ca4aa283132))
|
||||
|
||||
## [3.46.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.46.2-beta.0...v3.46.3-beta.0) (2026-05-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **branding:** socials + promo round-trip from DB to form ([#460](https://github.com/the-luap/picpeak/issues/460)) ([bd2288e](https://github.com/the-luap/picpeak/commit/bd2288e6a01786cec0649b3326189e9737db359e))
|
||||
* **branding:** socials + promo round-trip from DB to form ([#460](https://github.com/the-luap/picpeak/issues/460)) ([ae64a6a](https://github.com/the-luap/picpeak/commit/ae64a6acbc119f78394e65cee7bf49910e1c7013))
|
||||
|
||||
## [3.46.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.46.1-beta.0...v3.46.2-beta.0) (2026-05-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **customer-portal:** post-merge fixes for event save, theme fonts, and customer→gallery handoff ([9776d8a](https://github.com/the-luap/picpeak/commit/9776d8a6fcccb5e19e7c652b7e47557213d85731))
|
||||
* **events:** CustomerAccountPicker hooks order crashed /admin/events/new ([2a7ae07](https://github.com/the-luap/picpeak/commit/2a7ae0702dfc56e69bf845ded19888c29519414a))
|
||||
|
||||
## [3.46.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.46.0-beta.0...v3.46.1-beta.0) (2026-05-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **import:** capture photo dimensions in fileWatcher + s3AutoImporter ([#447](https://github.com/the-luap/picpeak/issues/447)) ([5b14854](https://github.com/the-luap/picpeak/commit/5b148542e6f2396ce47e3b6c301f186f9af9adec))
|
||||
|
||||
## [3.46.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.45.1-beta.0...v3.46.0-beta.0) (2026-05-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **branding:** Customer dashboard header toggles in Branding page ([b252cb6](https://github.com/the-luap/picpeak/commit/b252cb67eb3645224a279fbbe9c14871438473f8))
|
||||
* customer accounts ([#354](https://github.com/the-luap/picpeak/issues/354)) — recurring logins, profile, password reset, branded customer surface ([fe52953](https://github.com/the-luap/picpeak/commit/fe5295373b0bfeec1e81086206ffab7ce1b91094))
|
||||
* **customers:** customer portal ([#354](https://github.com/the-luap/picpeak/issues/354)) on top of feature-flags reorg ([087ef45](https://github.com/the-luap/picpeak/commit/087ef45942a8a51d09af2cd8ec85aca330f6cf7f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** restore COOKIE_SECURE='auto' default for production ([adfa29e](https://github.com/the-luap/picpeak/commit/adfa29e91eeea52aa672e38269c389a5178d9e5a))
|
||||
* **customer:** unwrap /customer/* from RequireFeature gate ([da08a58](https://github.com/the-luap/picpeak/commit/da08a5828ab855365a2a2a6f4854b09eb409907a))
|
||||
* **server:** drop missing requireCustomerPortal middleware import ([4fa7225](https://github.com/the-luap/picpeak/commit/4fa72257329942a6b598fa90c83c6bca7586fe33))
|
||||
* **server:** mount /api/admin/feature-flags route ([f048011](https://github.com/the-luap/picpeak/commit/f048011324bfa4cee8f89b0131b68dd520446ca2))
|
||||
|
||||
## [3.45.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.45.0-beta.0...v3.45.1-beta.0) (2026-05-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **create-event:** branding-default theme survives eventTypes refetch ([d62c529](https://github.com/the-luap/picpeak/commit/d62c529b0278a9ac790b22f46f49004df71112ea))
|
||||
* **create-event:** branding-default theme survives eventTypes refetch ([#323](https://github.com/the-luap/picpeak/issues/323)-B) ([37d487d](https://github.com/the-luap/picpeak/commit/37d487db86fe9fc11facff6d2c0bb9a24e6f6277))
|
||||
|
||||
## [3.45.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.44.2-beta.0...v3.45.0-beta.0) (2026-05-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **events:** default Guest Feedback ON via admin setting ([#520](https://github.com/the-luap/picpeak/issues/520)) ([3465b55](https://github.com/the-luap/picpeak/commit/3465b55abc98e52cf58ba46b811f4ec115d53012))
|
||||
* **footer:** hideable legal links + socials + promo banner ([#441](https://github.com/the-luap/picpeak/issues/441) + [#440](https://github.com/the-luap/picpeak/issues/440)) ([f3505c2](https://github.com/the-luap/picpeak/commit/f3505c2631cc5b4ae13a0f0d593a1ddd95fefdcb))
|
||||
|
||||
## [3.44.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.44.1-beta.0...v3.44.2-beta.0) (2026-05-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **events:** clamp page state when totalPages drops below current page ([#442](https://github.com/the-luap/picpeak/issues/442)) ([b4e30a4](https://github.com/the-luap/picpeak/commit/b4e30a4293c77e6dc34e955741ee113b9d530718))
|
||||
* **events:** clamp page state when totalPages drops below current page ([#442](https://github.com/the-luap/picpeak/issues/442)) ([9c4a96f](https://github.com/the-luap/picpeak/commit/9c4a96fe977b7a0907f5dea99491385195b76184))
|
||||
|
||||
## [3.44.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.44.0-beta.0...v3.44.1-beta.0) (2026-05-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **events:** admins can clear expiration on edit even when 'Require expiration' is ON ([#426](https://github.com/the-luap/picpeak/issues/426)) ([3fd8af3](https://github.com/the-luap/picpeak/commit/3fd8af3d56b54f81cc20b75109cc212d23fc84c1))
|
||||
* **events:** admins can clear expiration on edit even when "Require expiration" is ON ([#426](https://github.com/the-luap/picpeak/issues/426)) ([e544561](https://github.com/the-luap/picpeak/commit/e54456135cc8605fad949a955261cecc3f986355))
|
||||
|
||||
## [3.44.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.43.3-beta.0...v3.44.0-beta.0) (2026-05-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **settings:** Features tab + sidebar reorg with feature-flag gating ([c3798e1](https://github.com/the-luap/picpeak/commit/c3798e19c8f928eac0ca1d7694d1ecfd78a1e437))
|
||||
* **settings:** Features tab + sidebar reorg with feature-flag gating ([15e3336](https://github.com/the-luap/picpeak/commit/15e333681fe4ce94afa8e5b477b339b179d0195a))
|
||||
|
||||
## [3.43.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.43.2-beta.0...v3.43.3-beta.0) (2026-05-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** serve thumbnails / photos / hero via storage abstraction ([#432](https://github.com/the-luap/picpeak/issues/432)) ([d3007b0](https://github.com/the-luap/picpeak/commit/d3007b0dd29d37a46ce26e8b4eb15908e0f8e3d2))
|
||||
|
||||
## [3.43.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.43.1-beta.0...v3.43.2-beta.0) (2026-05-09)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **contributing:** update branch reference from main to beta ([ed37caf](https://github.com/the-luap/picpeak/commit/ed37caf3d898d9b2db985e6c6ff203457fd4aa38))
|
||||
|
||||
## [3.43.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.43.0-beta.0...v3.43.1-beta.0) (2026-05-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **event:** correct updating client access ([d00f6fa](https://github.com/the-luap/picpeak/commit/d00f6fa7de59bdbd30efe9ce824e795cf05616f4))
|
||||
* **event:** ensure client share token is generated only when necessary ([916580a](https://github.com/the-luap/picpeak/commit/916580adefd464417729e418aa1727b67566fea3))
|
||||
|
||||
## [3.43.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.7-beta.0...v3.43.0-beta.0) (2026-05-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **footer:** hideable legal links + socials + promo banner ([#441](https://github.com/the-luap/picpeak/issues/441) + [#440](https://github.com/the-luap/picpeak/issues/440)) ([3a731e7](https://github.com/the-luap/picpeak/commit/3a731e7c95a75f6db42f903b1e6e560c3e024e6a))
|
||||
* **gallery:** revoke customer-minted JWTs when assignment is removed ([55a5846](https://github.com/the-luap/picpeak/commit/55a5846f6f802a1bc1910bb046325fe272a1b584))
|
||||
* **i18n:** add Spanish (es) locale ([#510](https://github.com/the-luap/picpeak/issues/510)) ([061712e](https://github.com/the-luap/picpeak/commit/061712ebf143a58102334201c09a52e4c96880f0))
|
||||
* **install:** skip legacy chain when modern bootstrap fingerprint detected ([#530](https://github.com/the-luap/picpeak/issues/530)) ([8f0108c](https://github.com/the-luap/picpeak/commit/8f0108ce233f457d6a0f4f3dbc3e1b0a7217e74e))
|
||||
* **lightbox:** medium-resolution preview tier ([#492](https://github.com/the-luap/picpeak/issues/492)) ([3083c74](https://github.com/the-luap/picpeak/commit/3083c748b92fe7800044125524d053f5eeb28d4a))
|
||||
* **lightbox:** medium-resolution preview tier ([#492](https://github.com/the-luap/picpeak/issues/492)) ([61f1d13](https://github.com/the-luap/picpeak/commit/61f1d132104f485cfde9bd874e1c0ffdc042c4af))
|
||||
* **lightbox:** multi-photo Web Share save-to-Photos on iOS ([#557](https://github.com/the-luap/picpeak/issues/557)) ([d5823c7](https://github.com/the-luap/picpeak/commit/d5823c79d9a187461c0126adcff7f4374cd0e8aa))
|
||||
* **lightbox:** save photo to Photos app on mobile via Web Share ([#531](https://github.com/the-luap/picpeak/issues/531)) ([b2bbf7e](https://github.com/the-luap/picpeak/commit/b2bbf7efb5ded63e8c7438d5288d351cb9124ce8))
|
||||
* **lightbox:** surface original camera filenames ([#508](https://github.com/the-luap/picpeak/issues/508)) ([33de294](https://github.com/the-luap/picpeak/commit/33de294d570ced267d89223cb398ef8bdf99e91d))
|
||||
* **localization:** add French translations for fit options in thumbnails ([2c12885](https://github.com/the-luap/picpeak/commit/2c1288583fe787c1514c3cace0e2d92a212bb3a3))
|
||||
* **localization:** add i18next configuration and CLI commands for localization management ([74e87b9](https://github.com/the-luap/picpeak/commit/74e87b968b3152603dbfca72fae06372b7c7f519))
|
||||
* **localization:** add i18next extraction helper & refactor backup configuration component to tsx ([e7228b0](https://github.com/the-luap/picpeak/commit/e7228b07805a40aa67ccb7e3592848218eabbca2))
|
||||
* **localization:** add missing translations ([86ee6c8](https://github.com/the-luap/picpeak/commit/86ee6c80aa1f26b2ad47775337ebed301193e662))
|
||||
* **localization:** improve English translations for clarity and consistency ([46b99c6](https://github.com/the-luap/picpeak/commit/46b99c629215832e66d9215a210fd6eaf8c89fb4))
|
||||
* **localization:** update thumbnail settings and add fit options translations ([5fc427c](https://github.com/the-luap/picpeak/commit/5fc427c74b5cdda6ce1568006d9a94abdc692b3b))
|
||||
* **og:** per-event opt-in to use hero photo as social-share preview ([#474](https://github.com/the-luap/picpeak/issues/474)) ([d856340](https://github.com/the-luap/picpeak/commit/d856340f0d230ad26539ba1088f739f03aaafc13))
|
||||
* **og:** per-event opt-in to use hero photo as social-share preview ([#474](https://github.com/the-luap/picpeak/issues/474)) ([0bc7e2a](https://github.com/the-luap/picpeak/commit/0bc7e2af171d1a4c6e91ba541d293b90c17c111b))
|
||||
* **settings:** Features tab + sidebar reorg with feature-flag gating ([c3798e1](https://github.com/the-luap/picpeak/commit/c3798e19c8f928eac0ca1d7694d1ecfd78a1e437))
|
||||
* **settings:** Features tab + sidebar reorg with feature-flag gating ([15e3336](https://github.com/the-luap/picpeak/commit/15e333681fe4ce94afa8e5b477b339b179d0195a))
|
||||
* **translations:** add French language support and improve localization handling ([a5db4bd](https://github.com/the-luap/picpeak/commit/a5db4bd46e6a5cc84c9563588f90d3461c277528))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **activity-log:** smart feature_flags_updated rendering + 33 missing activity types ([4703fd5](https://github.com/the-luap/picpeak/commit/4703fd574f57bfab327ffefec5986bb08b240c95))
|
||||
* **activity-log:** smart feature_flags_updated rendering + 33 missing types ([fad2de5](https://github.com/the-luap/picpeak/commit/fad2de5abe1da07bbc7503a462efac8f686029c7))
|
||||
* **admin-users:** normalise date fields to ISO across DB drivers ([#485](https://github.com/the-luap/picpeak/issues/485)) ([d300426](https://github.com/the-luap/picpeak/commit/d3004263905edeffe59065e201f203a6768b4384))
|
||||
* **admin-users:** normalise date fields to ISO across DB drivers ([#485](https://github.com/the-luap/picpeak/issues/485)) ([b6b58d0](https://github.com/the-luap/picpeak/commit/b6b58d0659fc8caee68a65e112780a1122d55907))
|
||||
* **admin:** test email always sends, regardless of update availability ([#418](https://github.com/the-luap/picpeak/issues/418)) ([9326a42](https://github.com/the-luap/picpeak/commit/9326a427b32458dfdaa01530bac66cda84ed7b72))
|
||||
* **admin:** test email always sends, regardless of update availability ([#418](https://github.com/the-luap/picpeak/issues/418)) ([c2b1854](https://github.com/the-luap/picpeak/commit/c2b1854df631354a977d737e8a00ddbc6fa8889f))
|
||||
* **api/v1:** accept color_theme + create feedback row on event create ([#550](https://github.com/the-luap/picpeak/issues/550)) ([7ef0e40](https://github.com/the-luap/picpeak/commit/7ef0e40e7cee2ab6eeea4fe75c558e930e31241d))
|
||||
* **api/v1:** accept color_theme + create feedback row on event create ([#550](https://github.com/the-luap/picpeak/issues/550)) ([1b521e7](https://github.com/the-luap/picpeak/commit/1b521e761c3e2cc6c885d03ef746aa7e77e6f067))
|
||||
* **api/v1:** scope category lookup to event_owned or global ([92bb9e1](https://github.com/the-luap/picpeak/commit/92bb9e1a12f77ce5e8c1286716198362b2bfdff2))
|
||||
* **auth:** default COOKIE_SECURE to 'auto' in production + first-install UX ([#427](https://github.com/the-luap/picpeak/issues/427)) ([e1c9382](https://github.com/the-luap/picpeak/commit/e1c93823c4a3095dd2afa618f393a061806f18a3))
|
||||
* **auth:** default COOKIE_SECURE to 'auto' in production + first-install UX ([#427](https://github.com/the-luap/picpeak/issues/427)) ([5c7de96](https://github.com/the-luap/picpeak/commit/5c7de96b7fda9ca037a01b93fabe69d1be224893))
|
||||
* **auth:** restore COOKIE_SECURE='auto' default for production ([adfa29e](https://github.com/the-luap/picpeak/commit/adfa29e91eeea52aa672e38269c389a5178d9e5a))
|
||||
* **brand-title:** runtime substitution so GHCR-image users can override ([#521](https://github.com/the-luap/picpeak/issues/521) follow-up) ([efa6b4a](https://github.com/the-luap/picpeak/commit/efa6b4a2059f1da52ef435ec84bc548040dfa7e5))
|
||||
* **branding:** socials + promo round-trip from DB to form ([#460](https://github.com/the-luap/picpeak/issues/460)) ([bd2288e](https://github.com/the-luap/picpeak/commit/bd2288e6a01786cec0649b3326189e9737db359e))
|
||||
* **branding:** socials + promo round-trip from DB to form ([#460](https://github.com/the-luap/picpeak/issues/460)) ([ae64a6a](https://github.com/the-luap/picpeak/commit/ae64a6acbc119f78394e65cee7bf49910e1c7013))
|
||||
* **bug-batch-518:** lightbox comments toggle + further fixes ([633a2ae](https://github.com/the-luap/picpeak/commit/633a2ae72405ae1fc885cb710ed896476ffee467))
|
||||
* **categories:** strip diacritics from auto-generated slugs ([a747eb3](https://github.com/the-luap/picpeak/commit/a747eb351d1cf0ed269751cb685b504993d33dde))
|
||||
* **categories:** strip diacritics from auto-generated slugs ([848430e](https://github.com/the-luap/picpeak/commit/848430e72b39f61a78e3f967770abe6df9a730f2))
|
||||
* **ci:** pin TRIVY_PLATFORM per matrix arch (post-[#477](https://github.com/the-luap/picpeak/issues/477) follow-up) ([6750f5d](https://github.com/the-luap/picpeak/commit/6750f5d3b06f6312629e81c4c84100c572746b69))
|
||||
* **ci:** pin TRIVY_PLATFORM per matrix arch (post-[#477](https://github.com/the-luap/picpeak/issues/477) follow-up) ([c3256dc](https://github.com/the-luap/picpeak/commit/c3256dc6bf49a2352dfe38b804d757683bb3ac22))
|
||||
* **ci:** scan multi-arch images per-arch by digest, pin trivy-action ([#476](https://github.com/the-luap/picpeak/issues/476)) ([1144e9d](https://github.com/the-luap/picpeak/commit/1144e9d1625eb69d8e2a53b1a3aa1b515798fc80))
|
||||
* **ci:** scan multi-arch images per-arch by digest, pin trivy-action ([#476](https://github.com/the-luap/picpeak/issues/476)) ([caf0d61](https://github.com/the-luap/picpeak/commit/caf0d618572b7fca0b28776ee13dcce2e0da0b99))
|
||||
* **ci:** trivy-action tag is v0.36.0 (was 0.28.0 — does not exist) ([40e176c](https://github.com/the-luap/picpeak/commit/40e176cb46b2286d64a998937357b9e31cfaf04d))
|
||||
* **create-event:** branding-default theme survives eventTypes refetch ([d62c529](https://github.com/the-luap/picpeak/commit/d62c529b0278a9ac790b22f46f49004df71112ea))
|
||||
* **create-event:** branding-default theme survives eventTypes refetch ([#323](https://github.com/the-luap/picpeak/issues/323)-B) ([37d487d](https://github.com/the-luap/picpeak/commit/37d487db86fe9fc11facff6d2c0bb9a24e6f6277))
|
||||
* **create-event:** re-apply Branding theme on stale→fresh settings ([#323](https://github.com/the-luap/picpeak/issues/323)-B) ([401abf7](https://github.com/the-luap/picpeak/commit/401abf7a27cb73dd7fb8399f4c05644c95767093))
|
||||
* **customer-portal:** post-merge fixes for event save, theme fonts, and customer→gallery handoff ([9776d8a](https://github.com/the-luap/picpeak/commit/9776d8a6fcccb5e19e7c652b7e47557213d85731))
|
||||
* **customer-routes:** Cache-Control: no-store on customer endpoints ([#470](https://github.com/the-luap/picpeak/issues/470)) ([3122dd0](https://github.com/the-luap/picpeak/commit/3122dd08a8bc08deb937236aaa3f750119a979b9))
|
||||
* **customer:** customer sidebar active state matches admin pattern ([8d9d0be](https://github.com/the-luap/picpeak/commit/8d9d0bea836e80870c343220b1c253844ac01590))
|
||||
* **customer:** don't log customer out on transient session-refresh errors ([9e418c7](https://github.com/the-luap/picpeak/commit/9e418c759ce508adf6025e0740468d8229938ffe))
|
||||
* **customer:** preserve slug-scoped gallery tokens on auth provider mount ([7ac1d14](https://github.com/the-luap/picpeak/commit/7ac1d1473860796ea0925dd77454218f2b1f0020))
|
||||
* **customer:** unwrap /customer/* from RequireFeature gate ([da08a58](https://github.com/the-luap/picpeak/commit/da08a5828ab855365a2a2a6f4854b09eb409907a))
|
||||
* **downloads:** apply original-filename toggle to individual downloads too ([#507](https://github.com/the-luap/picpeak/issues/507)) ([38343e6](https://github.com/the-luap/picpeak/commit/38343e62ded3d65efcea5c74b7c1e169807754d6))
|
||||
* **email-templates:** backfill subcategory + customer password reset translations ([2343a16](https://github.com/the-luap/picpeak/commit/2343a162df070cd5bd6abdc331bc5ca4aa283132))
|
||||
* **email:** parse JSON-encoded language setting before using as locale ([ebc7da2](https://github.com/the-luap/picpeak/commit/ebc7da21bea90ff84f5351bef4fd3c2605d3a68f))
|
||||
* **email:** parse JSON-encoded language setting before using as locale ([f12062f](https://github.com/the-luap/picpeak/commit/f12062f1e7be2974addbc50923f4427d3fba5a1c))
|
||||
* **event:** correct updating client access ([d00f6fa](https://github.com/the-luap/picpeak/commit/d00f6fa7de59bdbd30efe9ce824e795cf05616f4))
|
||||
* **event:** ensure client share token is generated only when necessary ([916580a](https://github.com/the-luap/picpeak/commit/916580adefd464417729e418aa1727b67566fea3))
|
||||
* **events:** admins can clear expiration on edit even when 'Require expiration' is ON ([#426](https://github.com/the-luap/picpeak/issues/426)) ([3fd8af3](https://github.com/the-luap/picpeak/commit/3fd8af3d56b54f81cc20b75109cc212d23fc84c1))
|
||||
* **events:** admins can clear expiration on edit even when "Require expiration" is ON ([#426](https://github.com/the-luap/picpeak/issues/426)) ([e544561](https://github.com/the-luap/picpeak/commit/e54456135cc8605fad949a955261cecc3f986355))
|
||||
* **events:** clamp page state when totalPages drops below current page ([#442](https://github.com/the-luap/picpeak/issues/442)) ([b4e30a4](https://github.com/the-luap/picpeak/commit/b4e30a4293c77e6dc34e955741ee113b9d530718))
|
||||
* **events:** clamp page state when totalPages drops below current page ([#442](https://github.com/the-luap/picpeak/issues/442)) ([9c4a96f](https://github.com/the-luap/picpeak/commit/9c4a96fe977b7a0907f5dea99491385195b76184))
|
||||
* **events:** CustomerAccountPicker hooks order crashed /admin/events/new ([2a7ae07](https://github.com/the-luap/picpeak/commit/2a7ae0702dfc56e69bf845ded19888c29519414a))
|
||||
* **events:** preserve branding inheritance when saving events with null color_theme ([d5a37df](https://github.com/the-luap/picpeak/commit/d5a37df2c41425511dc8a1f974088bebb768f0d5))
|
||||
* **events:** strip customer_account_ids from update spread ([dde72a1](https://github.com/the-luap/picpeak/commit/dde72a1b1b3154e3effb9166291fdb8d59f16207))
|
||||
* **events:** TDZ ReferenceError on /admin/events from [#442](https://github.com/the-luap/picpeak/issues/442) fix ([#454](https://github.com/the-luap/picpeak/issues/454)) ([2f63188](https://github.com/the-luap/picpeak/commit/2f63188a345ce8e337723045a60b3a9306a9a840))
|
||||
* **events:** typed-DELETE confirmation for bulk delete ([#417](https://github.com/the-luap/picpeak/issues/417)) ([e165ee5](https://github.com/the-luap/picpeak/commit/e165ee5d9fa805c704a64f91c9514bf0ab75b5b8))
|
||||
* **events:** typed-DELETE confirmation for bulk delete ([#417](https://github.com/the-luap/picpeak/issues/417)) ([99e420b](https://github.com/the-luap/picpeak/commit/99e420b1b9783a1d6b4eb892c09d0af3340bf314))
|
||||
* **external-media:** pre-generate thumbnails so reference-mode galleries load fast ([#423](https://github.com/the-luap/picpeak/issues/423)) ([e2ffd9f](https://github.com/the-luap/picpeak/commit/e2ffd9f93d228f9e16408fe424c65b26db67ac8e))
|
||||
* **external-media:** pre-generate thumbnails so reference-mode galleries load fast ([#423](https://github.com/the-luap/picpeak/issues/423)) ([f3d0f16](https://github.com/the-luap/picpeak/commit/f3d0f161c9e554a5149e6b4eafdb0ac42bebf277))
|
||||
* **features-tab:** icon tiles + preview pills follow CI accent ([15d01f3](https://github.com/the-luap/picpeak/commit/15d01f375628d3c1ead05d48a09c6509c508a848))
|
||||
* **features:** customer-portal card uses 'Clients' to match sidebar wording ([441cc41](https://github.com/the-luap/picpeak/commit/441cc419377055a5872f71afb19036cc5c932b58))
|
||||
* **features:** customer-portal card uses 'Clients' to match sidebar wording ([dec2f5d](https://github.com/the-luap/picpeak/commit/dec2f5d3d2224b0d66f02bb9a562b4ddeaac77df))
|
||||
* **feedback:** three guest-mode bugs from [#538](https://github.com/the-luap/picpeak/issues/538) (filter, like state, count leak) ([c900be9](https://github.com/the-luap/picpeak/commit/c900be92dd490b21aabb10fd56b6fbc3da444ee0))
|
||||
* **feedback:** three guest-mode bugs reported in [#538](https://github.com/the-luap/picpeak/issues/538) ([5311588](https://github.com/the-luap/picpeak/commit/5311588baf3c6acfc971cb014a142d6b4b153aa1))
|
||||
* **gallery:** hide Like button when guest feedback is off ([#506](https://github.com/the-luap/picpeak/issues/506)) ([9d2db9a](https://github.com/the-luap/picpeak/commit/9d2db9a73b71967fe4658c53d29ef5984f20bc69))
|
||||
* **gallery:** serve thumbnails / photos / hero via storage abstraction ([#432](https://github.com/the-luap/picpeak/issues/432)) ([d3007b0](https://github.com/the-luap/picpeak/commit/d3007b0dd29d37a46ce26e8b4eb15908e0f8e3d2))
|
||||
* **gallery:** serve thumbnails / photos / hero via storage abstraction ([#432](https://github.com/the-luap/picpeak/issues/432)) ([83d79f4](https://github.com/the-luap/picpeak/commit/83d79f4d39f2a68c8cb905cbd1b46d53bba80f49))
|
||||
* **header:** hide language name on mobile to free the title ([#523](https://github.com/the-luap/picpeak/issues/523)) ([4b4ecfd](https://github.com/the-luap/picpeak/commit/4b4ecfdf7143c8f353355ecd6d5ee14bbf50c9bb))
|
||||
* **i18n:** drive customer "Preferred language" select from SUPPORTED_LANGUAGES ([#510](https://github.com/the-luap/picpeak/issues/510)) ([51890e1](https://github.com/the-luap/picpeak/commit/51890e1aa5bacb5cfb5c9dc6e59770bd18406a66))
|
||||
* **i18n:** settings page resets UI language to server default ([482e91b](https://github.com/the-luap/picpeak/commit/482e91bbf8b8deee361ffc8b031094fb99cc569d))
|
||||
* **import:** capture photo dimensions in fileWatcher + s3AutoImporter ([#447](https://github.com/the-luap/picpeak/issues/447)) ([5b14854](https://github.com/the-luap/picpeak/commit/5b148542e6f2396ce47e3b6c301f186f9af9adec))
|
||||
* **import:** capture photo dimensions in fileWatcher + s3AutoImporter ([#447](https://github.com/the-luap/picpeak/issues/447)) ([936a277](https://github.com/the-luap/picpeak/commit/936a277eb8695a54e65eaaa5a75ce43cff54c5db))
|
||||
* **install:** defer events.hero_photo_id FK to break circular reference ([#484](https://github.com/the-luap/picpeak/issues/484)) ([62b3ed6](https://github.com/the-luap/picpeak/commit/62b3ed636414d358c0c73712b732207fc6fa1200))
|
||||
* **install:** defer events.hero_photo_id FK to break circular reference ([#484](https://github.com/the-luap/picpeak/issues/484)) ([87834a7](https://github.com/the-luap/picpeak/commit/87834a7fff57a53bb1060ad7061dd6d279922f42))
|
||||
* **install:** drop racy migration step + add missing frontend container ([#484](https://github.com/the-luap/picpeak/issues/484)) ([d4155c4](https://github.com/the-luap/picpeak/commit/d4155c46117eb1db6255ebac0ea47e6fc3e99801))
|
||||
* **install:** self-chowning entrypoint kills fresh-install restart loop ([#484](https://github.com/the-luap/picpeak/issues/484)) ([42c5cda](https://github.com/the-luap/picpeak/commit/42c5cda38c0deeb4e61554e9e4a913bd5cd0b980))
|
||||
* **install:** self-chowning entrypoint kills fresh-install restart loop ([#484](https://github.com/the-luap/picpeak/issues/484)) ([1505775](https://github.com/the-luap/picpeak/commit/15057756788eacc75dd9ff64541cac7418f368f2))
|
||||
* **install:** silence clean-install postgres log noise ([#484](https://github.com/the-luap/picpeak/issues/484)) ([99e60a2](https://github.com/the-luap/picpeak/commit/99e60a243321a06f659d811babbcda9ffef655c4))
|
||||
* **install:** silence clean-install postgres log noise ([#484](https://github.com/the-luap/picpeak/issues/484)) ([86b33d4](https://github.com/the-luap/picpeak/commit/86b33d4ddaf98e1f32473832b0f89565750174e5))
|
||||
* **install:** silence pg healthcheck noise + drop legacy workers container ([#484](https://github.com/the-luap/picpeak/issues/484)) ([d39406b](https://github.com/the-luap/picpeak/commit/d39406b2414cdfcae84e8175d90821dd3a5287bb))
|
||||
* **install:** silence pg healthcheck noise + drop legacy workers container ([#484](https://github.com/the-luap/picpeak/issues/484)) ([0b0b1bb](https://github.com/the-luap/picpeak/commit/0b0b1bb2d529dbaae8e49891d8d5e8019b971838))
|
||||
* **install:** skip legacy chain on recovery-state DBs + schema-drift CI ([#530](https://github.com/the-luap/picpeak/issues/530)) ([a0ebc97](https://github.com/the-luap/picpeak/commit/a0ebc97cdd871041ff3cfdcc7276c413ac89d24f))
|
||||
* **lightbox+events:** Android download lag, multi-photo Web Share re-land, theme branding inheritance ([e016f51](https://github.com/the-luap/picpeak/commit/e016f510b6cc57a9ed1b59e2ee24fedd5d7097c3))
|
||||
* **lightbox:** align swipe-neighbour height + stop black flash on commit ([#505](https://github.com/the-luap/picpeak/issues/505)) ([d2d5509](https://github.com/the-luap/picpeak/commit/d2d55098d6d899c74b3b32b46b8194d06e7cda7e))
|
||||
* **lightbox:** eliminate download lag on Android by skipping the blob round-trip ([0479521](https://github.com/the-luap/picpeak/commit/04795219a0b66fdd1ef73748d803adfdfc0d676f))
|
||||
* **lightbox:** fill the heart icon when liked ([#538](https://github.com/the-luap/picpeak/issues/538) follow-up) ([3e39112](https://github.com/the-luap/picpeak/commit/3e39112a1276c194259d936dad813f3b0fc2dc3f))
|
||||
* **lightbox:** fill the heart icon when liked ([#538](https://github.com/the-luap/picpeak/issues/538) follow-up) ([600c29d](https://github.com/the-luap/picpeak/commit/600c29db8a75fa44e72da55bc5288908de614d9d))
|
||||
* **lightbox:** hide comments toggle when allow_comments=false ([#518](https://github.com/the-luap/picpeak/issues/518)) ([d44e1ad](https://github.com/the-luap/picpeak/commit/d44e1adba7a444b03511e9402cd39d25fe5acafe))
|
||||
* **lightbox:** pan zoomed image with single-finger touch on mobile ([#532](https://github.com/the-luap/picpeak/issues/532)) ([53139b8](https://github.com/the-luap/picpeak/commit/53139b8cb87e0669fe38089f38848a59ce3cbb28))
|
||||
* **lightbox:** restrict Web Share save-to-Photos path to iOS ([#554](https://github.com/the-luap/picpeak/issues/554)) ([578397b](https://github.com/the-luap/picpeak/commit/578397bc6b27b56ccf3bf1f2f244e0e0053c493a))
|
||||
* **lightbox:** restrict Web Share save-to-Photos path to iOS ([#554](https://github.com/the-luap/picpeak/issues/554)) ([2a309c7](https://github.com/the-luap/picpeak/commit/2a309c75a74be3af3eb67758f8d65f801ef3019a))
|
||||
* **nginx:** honour outer X-Forwarded-Proto when behind a reverse proxy ([#547](https://github.com/the-luap/picpeak/issues/547)) ([b351d17](https://github.com/the-luap/picpeak/commit/b351d17ee99528dd4251e74dfc47cd1fe289d9c3))
|
||||
* **nginx:** honour outer X-Forwarded-Proto when behind a reverse proxy ([#547](https://github.com/the-luap/picpeak/issues/547)) ([5488de3](https://github.com/the-luap/picpeak/commit/5488de3383d33d8a037587dd9112d36ea035c465))
|
||||
* **og:** brandable static title + wider crawler UA coverage ([#521](https://github.com/the-luap/picpeak/issues/521)) ([b960639](https://github.com/the-luap/picpeak/commit/b96063903513fcc4cbe0e72f59ccbc37d7b1c0ab))
|
||||
* **promo-banner:** center by default + admin alignment selector ([#482](https://github.com/the-luap/picpeak/issues/482)) ([d1034ce](https://github.com/the-luap/picpeak/commit/d1034ce1c65c31b06005cfd0c047dba3251579ab))
|
||||
* **promo-banner:** center by default + admin alignment selector ([#482](https://github.com/the-luap/picpeak/issues/482)) ([a803491](https://github.com/the-luap/picpeak/commit/a803491cf477c0d62e23d6a019e0d252e2c537f8))
|
||||
* **public-site:** honor dark theme surface colors ([8b72721](https://github.com/the-luap/picpeak/commit/8b727218127db2a738ad5a4381358076c1575c8a))
|
||||
* recover three orphaned commits from [#527](https://github.com/the-luap/picpeak/issues/527) (BRAND_TITLE runtime, Web Share, pan zoom) ([9607b46](https://github.com/the-luap/picpeak/commit/9607b4666c0abf22e54b43be3e87e3243db2cdbc))
|
||||
* **security:** scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage ([7abfeb9](https://github.com/the-luap/picpeak/commit/7abfeb91cc7bbb9b6853146dbfe16b8d9835bcb3))
|
||||
* **security:** scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage ([6b6191a](https://github.com/the-luap/picpeak/commit/6b6191a4260650e21c45f6153cac1b142bf8483a))
|
||||
* **server:** drop missing requireCustomerPortal middleware import ([4fa7225](https://github.com/the-luap/picpeak/commit/4fa72257329942a6b598fa90c83c6bca7586fe33))
|
||||
* **server:** mount /api/admin/feature-flags route ([f048011](https://github.com/the-luap/picpeak/commit/f048011324bfa4cee8f89b0131b68dd520446ca2))
|
||||
* settings page resets UI language to server default ([165ebce](https://github.com/the-luap/picpeak/commit/165ebce8d1226cde22a36df28c0c45c2d79e2423))
|
||||
* **settings:** neutralize sidebar icons for a consistent palette ([2f00bbd](https://github.com/the-luap/picpeak/commit/2f00bbdd90ef38feca886e834e753d4c4b60c11d))
|
||||
* **settings:** readable contrast on accent-tinted icon tiles + pills ([bf7ef14](https://github.com/the-luap/picpeak/commit/bf7ef14626dc3a0e18f20813abe2a101d02c6d5b))
|
||||
* **theme:** 'Same as body' heading font no longer inherits stale value ([35f5b86](https://github.com/the-luap/picpeak/commit/35f5b86d0f6a56627aac2abfe5228b5935709b08))
|
||||
* **upload:** restore configurable batch-size for reverse proxies ([#509](https://github.com/the-luap/picpeak/issues/509)) ([98f3c3d](https://github.com/the-luap/picpeak/commit/98f3c3df4184b6d59b6c6b8e5f11b12b362320af))
|
||||
* **upload:** wire drag-and-drop on admin + user upload zones ([#504](https://github.com/the-luap/picpeak/issues/504)) ([577c4bd](https://github.com/the-luap/picpeak/commit/577c4bdf29e107d11044a218ed5a6ba359e060c7))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* **customer-portal:** make the global flag UI-only, drop the kill-switch middleware ([3f44193](https://github.com/the-luap/picpeak/commit/3f4419356a4f30509052a6d00b71485af2c17f85))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **contributing:** update branch reference from main to beta ([ed37caf](https://github.com/the-luap/picpeak/commit/ed37caf3d898d9b2db985e6c6ff203457fd4aa38))
|
||||
* **contributing:** update branch reference from main to beta ([c114749](https://github.com/the-luap/picpeak/commit/c1147499212ef64e9d8ded89c38d84ab0adc5346))
|
||||
* **localization:** enhance French language support and improve i18next configuration ([d1bc5e0](https://github.com/the-luap/picpeak/commit/d1bc5e030f55c15bf09f37b97f8e1608578a2395))
|
||||
|
||||
## [Unreleased]
|
||||
## [3.43.1](https://github.com/the-luap/picpeak/compare/v3.43.0...v3.43.1) (2026-05-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **event:** fix updating client access ([ee85e1d](https://github.com/the-luap/picpeak/commit/ee85e1d))
|
||||
* **security:** backport 18 dependency CVE patches from beta (3.42.2 stable) ([74eacbc](https://github.com/the-luap/picpeak/commit/74eacbc78f7efd5c499ae1647b716d3234096c39))
|
||||
* **security:** patch 18 dependency CVEs (axios + transitives + nodemailer + i18next-http-backend) ([37bf894](https://github.com/the-luap/picpeak/commit/37bf894412b4da0f0507dd8f1384e6f101ce14b2))
|
||||
|
||||
## [3.43.0](https://github.com/the-luap/picpeak/compare/v3.42.1...v3.43.0) (2026-05-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **i18n:** add French (fr) language support with full translation coverage
|
||||
* **i18n:** add i18next configuration with language detection and namespace setup
|
||||
* **i18n:** add CLI commands for localization management (extraction, validation)
|
||||
* **i18n:** add `i18nextExtractionHelper` developer script for auditing missing translation keys
|
||||
* **i18n:** complete and restructure translation files for EN, DE, NL, PT, RU with consistent key naming
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **admin:** convert `BackupConfiguration`, `BackupDashboard`, and `BackupManagement` from JSX to TSX with full i18n support
|
||||
* **admin:** remove stale `.d.ts` declaration files replaced by TSX components
|
||||
* **i18n:** clean up `useLocalizedTimeAgo` hook and update `useLocalizedDate`
|
||||
|
||||
## [3.42.7-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.6-beta.0...v3.42.7-beta.0) (2026-05-09)
|
||||
* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0))
|
||||
* add bulk category editing for photos ([#157](https://github.com/the-luap/picpeak/issues/157)) ([eca36c7](https://github.com/the-luap/picpeak/commit/eca36c70a23f18f937a9f5bddeff855e18f364c3))
|
||||
* add category hero/cover photo selection ([#163](https://github.com/the-luap/picpeak/issues/163)) ([6c30e2c](https://github.com/the-luap/picpeak/commit/6c30e2c2edd19a24d4f30a9558690bb7e2331b32))
|
||||
* add configurable upload batch size for reverse proxy compatibility ([#208](https://github.com/the-luap/picpeak/issues/208)) ([02a46e0](https://github.com/the-luap/picpeak/commit/02a46e083d68cfdb355b5a4fe4a8da7d667050b9))
|
||||
* add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments ([#298](https://github.com/the-luap/picpeak/issues/298)) ([b1dfbe4](https://github.com/the-luap/picpeak/commit/b1dfbe4c2fe271d8087974d02cf724f04058bdc9))
|
||||
* add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments ([#298](https://github.com/the-luap/picpeak/issues/298)) ([15a8ab4](https://github.com/the-luap/picpeak/commit/15a8ab41fd1c94e3397d300b161cd1fdd459ea05))
|
||||
* add customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad))
|
||||
* add Dutch (nl) locale and fix missing translation keys across all locales ([b54a80d](https://github.com/the-luap/picpeak/commit/b54a80d251bcbb9a126e32eeaef522688bc810c6))
|
||||
* add Dutch locale and fix missing translation keys ([e32da68](https://github.com/the-luap/picpeak/commit/e32da68cbdfa430d62cbb1057ea418dc6b2f14fb))
|
||||
* add Gallery Premium and Gallery Story layouts (Beta) ([e179def](https://github.com/the-luap/picpeak/commit/e179def3cceefe5fd6acd5574f2986e4f9e223ef))
|
||||
* add hero image focal point picker with anchor positioning ([#162](https://github.com/the-luap/picpeak/issues/162)) ([734868a](https://github.com/the-luap/picpeak/commit/734868abc23731b0ac9ad73e799194df1e6aa6ab))
|
||||
* add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([608bbd5](https://github.com/the-luap/picpeak/commit/608bbd50e7b31d49c7516a00e96f284fa16e2777))
|
||||
* Add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([ef2ae00](https://github.com/the-luap/picpeak/commit/ef2ae00ff20b754c2f2ed797e18c146d12d7f31a))
|
||||
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) ([e081b56](https://github.com/the-luap/picpeak/commit/e081b56a44bf9fdaa3dd225d5dd4dde35bfe83d3))
|
||||
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) + security fixes ([cd1d504](https://github.com/the-luap/picpeak/commit/cd1d50474f673b759c2f9401fdbe209a84773e39))
|
||||
* add optional event date and expiration settings ([3079eaa](https://github.com/the-luap/picpeak/commit/3079eaa2e5d1728c2c0f315626cc253e4b08edc2))
|
||||
* add optional event date and expiration settings ([2151147](https://github.com/the-luap/picpeak/commit/2151147f2d3134448ff32130da44678e2942d73c)), closes [#118](https://github.com/the-luap/picpeak/issues/118)
|
||||
* add original filename preservation and Lightroom export support ([a59f414](https://github.com/the-luap/picpeak/commit/a59f41463f960a3a74ce3933dc7db84ee3a2018d))
|
||||
* add original filename preservation and Lightroom export support ([9872ad3](https://github.com/the-luap/picpeak/commit/9872ad3aef6488b359c5499a6dc3d8bfbfa48fde))
|
||||
* add per-event custom logo upload with bug fixes ([85170b8](https://github.com/the-luap/picpeak/commit/85170b883f504d83f1d862abb3f4e46741074826))
|
||||
* add per-event hero logo customization options ([0790a1d](https://github.com/the-luap/picpeak/commit/0790a1ddad774af89827a0a392e9fae0a945bff2))
|
||||
* add per-gallery thumbnail scale setting ([#172](https://github.com/the-luap/picpeak/issues/172)) ([#251](https://github.com/the-luap/picpeak/issues/251)) ([ee46088](https://github.com/the-luap/picpeak/commit/ee46088985ebbbb81d16e5bac23be2060c94397f))
|
||||
* add photo cap per event and Portuguese (pt-BR) locale ([1fa222e](https://github.com/the-luap/picpeak/commit/1fa222e9c4c26e525c7899e368988c6b0b08da85))
|
||||
* add photo cap per event and Portuguese locale ([088de43](https://github.com/the-luap/picpeak/commit/088de43f09f974d444f50452ef1117315c289ebc))
|
||||
* add quilted layout, fix mosaic, and backfill photo dimensions ([#146](https://github.com/the-luap/picpeak/issues/146)) ([46ed1bc](https://github.com/the-luap/picpeak/commit/46ed1bc276867a25b27bf22cd9b9d7e879a6947b))
|
||||
* add thumbnail settings UI to admin panel ([3a30fea](https://github.com/the-luap/picpeak/commit/3a30fea862034d64fbc7188fc25292594a9319e2))
|
||||
* add thumbnail settings UI to admin settings page ([#206](https://github.com/the-luap/picpeak/issues/206)) ([7d6d2f5](https://github.com/the-luap/picpeak/commit/7d6d2f56883a4402f0d97c95b0432a8a783c8024))
|
||||
* add update instructions dialog, email notifications, and capture date sorting ([50c0990](https://github.com/the-luap/picpeak/commit/50c09904a9434f988ab32a07da5d24db0e02065e)), closes [#181](https://github.com/the-luap/picpeak/issues/181)
|
||||
* add visual WYSIWYG email template editor ([#229](https://github.com/the-luap/picpeak/issues/229)) ([04a7ea8](https://github.com/the-luap/picpeak/commit/04a7ea80f95d6aeb474b145292e75f45fb85c66d))
|
||||
* **branding:** 8-token CI palette + force color mode + dark-mode consistency ([8050927](https://github.com/the-luap/picpeak/commit/80509276074b8125b6d676839afabb0b6f89206f))
|
||||
* **branding:** force color mode (dark or light) site-wide ([5a162fc](https://github.com/the-luap/picpeak/commit/5a162fc8bec47a49cb1bcaa92ff72e197e8d2e42))
|
||||
* **branding:** inline force color mode with auto-save + clearer palette help text ([67d7d8d](https://github.com/the-luap/picpeak/commit/67d7d8d3fa25ceab0eda02b291f2e220b222f84a))
|
||||
* **branding:** per-family generic fallback via meta.json ([dcff451](https://github.com/the-luap/picpeak/commit/dcff4515721482e06c2ef1c1eb34f9e12754c07c))
|
||||
* **branding:** preview each font in its own face in the picker dropdown ([b4f9b65](https://github.com/the-luap/picpeak/commit/b4f9b65f1df4c400f22b28f20e1304ec57279d33))
|
||||
* **branding:** self-hosted webfonts with filesystem scanner ([d04bf28](https://github.com/the-luap/picpeak/commit/d04bf288084144bf53ef0ba988fa32ed703d7351))
|
||||
* **branding:** self-hosted webfonts with filesystem scanner ([bac51fe](https://github.com/the-luap/picpeak/commit/bac51fe69a39f85381f445e8da6cd63cf5826fc4))
|
||||
* **cms:** add external URL toggle for imprint and privacy pages ([b2c8161](https://github.com/the-luap/picpeak/commit/b2c8161a43c2d0b09d6783e791b3f26862254824))
|
||||
* **cms:** add per-page external URL override — backend ([66423bb](https://github.com/the-luap/picpeak/commit/66423bb65e83b6204509c9a98d783ba8255c3364))
|
||||
* **cms:** admin UI for external imprint/privacy URL ([a4e3d10](https://github.com/the-luap/picpeak/commit/a4e3d10fb0c97ea07c4b08d16c0947945d8a7576))
|
||||
* **cms:** redirect legal links to external URL when configured ([c5bba50](https://github.com/the-luap/picpeak/commit/c5bba505ac92b5257f6bb1c069b8bc23ea6a5a1b))
|
||||
* configurable upload batch size for reverse proxy compatibility ([9b7495e](https://github.com/the-luap/picpeak/commit/9b7495e0054975e66c9b5006c24a9fae63969de4))
|
||||
* configurable upload batch size for reverse proxy compatibility ([4243363](https://github.com/the-luap/picpeak/commit/424336340bef8e1629490ade154f0ceebb2a71e1))
|
||||
* customisable 404 + gallery-not-found pages via CMS ([#324](https://github.com/the-luap/picpeak/issues/324)) ([4f77905](https://github.com/the-luap/picpeak/commit/4f77905b87bea474b3d2496350996deaad041230))
|
||||
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
|
||||
* draft mode, admin branding, and workflow improvements ([dc98206](https://github.com/the-luap/picpeak/commit/dc98206737d1ebe43637319ce8c5b6da2e44c05d))
|
||||
* draft mode, admin branding, and workflow improvements ([40332a7](https://github.com/the-luap/picpeak/commit/40332a71db6534097940d3f9362b0fe651dba6c7))
|
||||
* dynamic website title from branding settings ([d29aab7](https://github.com/the-luap/picpeak/commit/d29aab7c70c5777451666fb7d5c7a9729dab684a))
|
||||
* **email:** expand email palette to 8 tokens + Sync from Branding button ([47b6b39](https://github.com/the-luap/picpeak/commit/47b6b39f3a942aee93b970031d95a952cb769d09))
|
||||
* **events:** add Photos column to admin events list ([#384](https://github.com/the-luap/picpeak/issues/384)) ([d561db8](https://github.com/the-luap/picpeak/commit/d561db802b04db8fbb38819a22e840532e775ef0))
|
||||
* **events:** add Photos column to admin events list ([#384](https://github.com/the-luap/picpeak/issues/384)) ([ffb4318](https://github.com/the-luap/picpeak/commit/ffb4318a1f667e273cd59805673b125d2f17699b))
|
||||
* **events:** bulk delete with password confirmation ([#384](https://github.com/the-luap/picpeak/issues/384)) ([647aea2](https://github.com/the-luap/picpeak/commit/647aea21ae42fe0d089bf45568702617a25b98e4))
|
||||
* **events:** bulk delete with password confirmation ([#384](https://github.com/the-luap/picpeak/issues/384)) ([48d538f](https://github.com/the-luap/picpeak/commit/48d538f94fd39d9b85ec57c57301a8490b7d4f6d))
|
||||
* **events:** prefill admin email + admin picker on event creation ([3fe8e61](https://github.com/the-luap/picpeak/commit/3fe8e61bd1175e35dcb61604447e5d9c2e902ec5))
|
||||
* **events:** prefill admin email + admin picker on event creation ([ee56b67](https://github.com/the-luap/picpeak/commit/ee56b6762f5b2eb9ea42f4abe4dde4356e2e54e6))
|
||||
* **events:** Sync from Branding button in gallery theme customizer + clarified default inheritance ([bdbe7b8](https://github.com/the-luap/picpeak/commit/bdbe7b80a13b8b215ac544ba9105100e792eeda2))
|
||||
* **events:** tree view for external media folder picker ([cdd40ac](https://github.com/the-luap/picpeak/commit/cdd40acb4591d4eb8f80a79c69201556eab1bfd0))
|
||||
* **events:** tree view for external media folder picker ([f927b09](https://github.com/the-luap/picpeak/commit/f927b09c70f3b6a5c81c3726609a29680b96b6fc))
|
||||
* **frontend:** dedupe /public/settings via shared usePublicSettings hook ([#325](https://github.com/the-luap/picpeak/issues/325)) ([3d4ae4d](https://github.com/the-luap/picpeak/commit/3d4ae4d7e9f9995d93563e8092e05215362afb3b))
|
||||
* gallery layouts, bulk category editing, and hero header improvements ([7037106](https://github.com/the-luap/picpeak/commit/7037106bff62593bba600d898a781f79f07b459d))
|
||||
* gallery layouts, hero customization, bulk categories & event types ([d9e00dc](https://github.com/the-luap/picpeak/commit/d9e00dc0dbd7cef0ddb4665e5306c98aac3573e3))
|
||||
* gallery layouts, hero customization, event types, and UX improvements ([#146](https://github.com/the-luap/picpeak/issues/146), [#155](https://github.com/the-luap/picpeak/issues/155)-163, [#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([4280444](https://github.com/the-luap/picpeak/commit/4280444d70e73db09e67e18ce25bac75cf499b75))
|
||||
* **gallery:** decouple header style from layout, add banner option ([1f1a856](https://github.com/the-luap/picpeak/commit/1f1a856083b1966ed4b32a23a14442f1727cecef))
|
||||
* **gallery:** decouple header style from layout, add banner option ([24d7277](https://github.com/the-luap/picpeak/commit/24d727752c442263a6469e0aefa666454a4c652f))
|
||||
* **gallery:** decouple header style from layout, add banner option ([aff29c9](https://github.com/the-luap/picpeak/commit/aff29c91bbb250debe74e2a512047ee40e157a34))
|
||||
* **gallery:** icon-only menu, accent Download CTA ([#386](https://github.com/the-luap/picpeak/issues/386)) ([876b35b](https://github.com/the-luap/picpeak/commit/876b35b4a512f70cfc19561e35ce9d915a599547))
|
||||
* **gallery:** icon-only menu, accent Download CTA, logo aligned ([#386](https://github.com/the-luap/picpeak/issues/386)) ([de8ad5f](https://github.com/the-luap/picpeak/commit/de8ad5fdd5ce1b9552ca8ca6e405d15c7372a4c8))
|
||||
* guest selections with per-person identity ([#292](https://github.com/the-luap/picpeak/issues/292)) ([3856ba2](https://github.com/the-luap/picpeak/commit/3856ba25bbce971b07bba4dad19e7bdceca98cab))
|
||||
* guest selections with per-person identity ([#292](https://github.com/the-luap/picpeak/issues/292)) ([ad4e5a7](https://github.com/the-luap/picpeak/commit/ad4e5a7506bc9217d1223101da0bc112047532a8))
|
||||
* **i18n:** add Brazilian Portuguese (pt-BR) locale ([375f512](https://github.com/the-luap/picpeak/commit/375f51285b5db9c0dfcc04761d24927282e57796))
|
||||
* **i18n:** improve pt locale with pt-BR phrasings, remove duplicate pt-BR file ([f25559c](https://github.com/the-luap/picpeak/commit/f25559c0e76776f7cfe8d187e1fea05e751bbafe))
|
||||
* improve gallery layouts with aspect-ratio-aware masonry and mosaic modes ([#146](https://github.com/the-luap/picpeak/issues/146)) ([aacfcd5](https://github.com/the-luap/picpeak/commit/aacfcd517ea5739e834cf84627b55b3449740a5c))
|
||||
* improve hero image UX and live preview ([#163](https://github.com/the-luap/picpeak/issues/163), [#158](https://github.com/the-luap/picpeak/issues/158)) ([d63f67a](https://github.com/the-luap/picpeak/commit/d63f67a2afba1b92610382aa1012428ccacb86bd))
|
||||
* multilingual email templates with translations table ([8c5996e](https://github.com/the-luap/picpeak/commit/8c5996e4ec43b2817d84cc040cfe52878ffb61d5))
|
||||
* multilingual email templates with translations table ([f50d7c0](https://github.com/the-luap/picpeak/commit/f50d7c0c51aa84a2182e450cd4b6a00777a8f9c0))
|
||||
* native multi-arch Docker images (Apple Silicon, ARM64 Linux) ([df30618](https://github.com/the-luap/picpeak/commit/df3061893d154152b75b8ab0d07e0b1e0078431d))
|
||||
* native S3 storage backend ([#328](https://github.com/the-luap/picpeak/issues/328)) + presigned download follow-up ([1b717ce](https://github.com/the-luap/picpeak/commit/1b717ce5ededa343d2fbb7e1c3493b4434743565))
|
||||
* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120))
|
||||
* optional customer phone field gated by global toggle ([#322](https://github.com/the-luap/picpeak/issues/322)) ([be6cb28](https://github.com/the-luap/picpeak/commit/be6cb28c8097d2277c1af2a32cf8bc88ebbc7136))
|
||||
* original filename in admin UI, update dialog, and security hardening ([3ea9d5b](https://github.com/the-luap/picpeak/commit/3ea9d5b1219980032cbee7a2564c0004948923f5))
|
||||
* original filename in admin UI, update dialog, security hardening, and bug fixes ([bcf2745](https://github.com/the-luap/picpeak/commit/bcf2745ab64acb968ae4bd0710b28e78c14f340c))
|
||||
* outbound webhooks for event/photo lifecycle ([#327](https://github.com/the-luap/picpeak/issues/327)) ([c488f48](https://github.com/the-luap/picpeak/commit/c488f481caacf0d63dafc47f509e8de2708bc30f))
|
||||
* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f))
|
||||
* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([4a93e4e](https://github.com/the-luap/picpeak/commit/4a93e4e8cbe1b7a23a8be706291a270ccdf5bb55))
|
||||
* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([e1b6e43](https://github.com/the-luap/picpeak/commit/e1b6e43e524211c913d3d29ade5fc029df12920f))
|
||||
* pre-generate watermarks for instant lightbox loading ([1be974a](https://github.com/the-luap/picpeak/commit/1be974afbb0b7a1bdbdd140327771907a5d3c2ae)), closes [#112](https://github.com/the-luap/picpeak/issues/112)
|
||||
* pre-generated watermarks and mobile upload button improvements ([c6fdd38](https://github.com/the-luap/picpeak/commit/c6fdd38e842e1a8c0aa9cbab9fc791e6669e402d))
|
||||
* pre-zip download all and photo replacement by name ([#312](https://github.com/the-luap/picpeak/issues/312), [#313](https://github.com/the-luap/picpeak/issues/313)) ([d3f1206](https://github.com/the-luap/picpeak/commit/d3f12068164a6bfe6c4a3817ad2fc2e8ed7abf4f))
|
||||
* pre-zip download all and photo replacement by name ([#312](https://github.com/the-luap/picpeak/issues/312), [#313](https://github.com/the-luap/picpeak/issues/313)) ([e18afd3](https://github.com/the-luap/picpeak/commit/e18afd3e6b0b5a4cdb4873fb227d1b1d2bf35f21))
|
||||
* presigned download UI + S3 prefix walker auto-importer (follow-ups) ([446d80a](https://github.com/the-luap/picpeak/commit/446d80a4cc5eb0389994e29585b2a98dad373db2))
|
||||
* public v1 API + token management + OpenAPI docs ([#322](https://github.com/the-luap/picpeak/issues/322)) ([808b15b](https://github.com/the-luap/picpeak/commit/808b15bafbcdab6ea55aff7f0e507153f513a70a))
|
||||
* register Russian locale and add to language selector ([6f95b8c](https://github.com/the-luap/picpeak/commit/6f95b8c26cd794525e15e45d478f9ead0ec22555))
|
||||
* S3 storage + webhooks + settings dedupe + backup fixes ([06d54be](https://github.com/the-luap/picpeak/commit/06d54bec4d0afc4a1b9ba6f2449ed7d79f1d3e8f))
|
||||
* show original filename in admin UI ([#184](https://github.com/the-luap/picpeak/issues/184)) ([0891be1](https://github.com/the-luap/picpeak/commit/0891be197fdb7d92ade5a293b8db0bed26fa6e3a))
|
||||
* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([8805fa5](https://github.com/the-luap/picpeak/commit/8805fa53e61c6b3672a8f6dad14d2fd17998a451))
|
||||
* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([633d4a0](https://github.com/the-luap/picpeak/commit/633d4a0f301e355ee9f057347f2f8dee8c5b4163))
|
||||
* support Apple Silicon natively via multi-arch images ([c282a72](https://github.com/the-luap/picpeak/commit/c282a72bd35db062cec25770a42cf9c803388e44))
|
||||
* **theme:** expand color settings to 8-token CI palette + alt button ([114aab5](https://github.com/the-luap/picpeak/commit/114aab57771a4bba03a9e5c616c75a37c9b25969))
|
||||
* **upload:** async photo processing — backend (PR-B part 1) ([851744c](https://github.com/the-luap/picpeak/commit/851744c3c4df7deba8d946b6592fdb5042c52a26))
|
||||
* **upload:** async photo processing — frontend (PR-B part 2) ([3b827b8](https://github.com/the-luap/picpeak/commit/3b827b80d51269e1a7b9c693396f3cb7a9a48ffc))
|
||||
* **upload:** async photo processing + fix(auth): /auth/session symmetry (loop fix) ([907bcf1](https://github.com/the-luap/picpeak/commit/907bcf1eb2d44ded149a1caf39ee1cfe63fec994))
|
||||
* **upload:** two-state UI + temp dir cleanup (PR-A of async processing) ([86dfcc4](https://github.com/the-luap/picpeak/commit/86dfcc4f116a394e7e092ab3e01f3f1d030bb367))
|
||||
* visual WYSIWYG email template editor ([703c03f](https://github.com/the-luap/picpeak/commit/703c03fbee754a5291b57b885c5e82fbdd3e69e9))
|
||||
* warn about low thumbnail resolution when selecting beta themes ([ee3f6ae](https://github.com/the-luap/picpeak/commit/ee3f6ae13bf9c9fb3295286e84150e04bf9fbce4))
|
||||
* warn about low thumbnail resolution with beta themes ([aef9b4e](https://github.com/the-luap/picpeak/commit/aef9b4ed7fc443cbec8890c580759077e05e77b4))
|
||||
* **webhooks:** enrich event.* payloads with customer contact + share_token ([#341](https://github.com/the-luap/picpeak/issues/341)) ([7ea4801](https://github.com/the-luap/picpeak/commit/7ea4801544fd5cd8bca1907a71b5c4e96ee77649))
|
||||
* **webhooks:** enrich event.* payloads with customer contact + share_token ([#341](https://github.com/the-luap/picpeak/issues/341)) ([1e69d5f](https://github.com/the-luap/picpeak/commit/1e69d5ff71ac2d1d133b0e40637b437d7cc8bc4f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** default COOKIE_SECURE to 'auto' in production + first-install UX ([#427](https://github.com/the-luap/picpeak/issues/427)) ([e1c9382](https://github.com/the-luap/picpeak/commit/e1c93823c4a3095dd2afa618f393a061806f18a3))
|
||||
|
||||
## [3.42.6-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.5-beta.0...v3.42.6-beta.0) (2026-05-09)
|
||||
* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669))
|
||||
* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5))
|
||||
* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff))
|
||||
* address beta feedback - gallery layout fixes, Russian locale, email logo ([#249](https://github.com/the-luap/picpeak/issues/249)) ([486239a](https://github.com/the-luap/picpeak/commit/486239aeb9b5f56551d5aa90f0bad3008eedc3bb))
|
||||
* address bugs and feature requests from discussion [#317](https://github.com/the-luap/picpeak/issues/317) ([6cfff6f](https://github.com/the-luap/picpeak/commit/6cfff6f6a6dbdc5bc1e9fe4fbce5795cdb1855c6))
|
||||
* address Shannon security assessment findings (37 vulnerabilities) ([#254](https://github.com/the-luap/picpeak/issues/254)) ([23cd9cb](https://github.com/the-luap/picpeak/commit/23cd9cb680eb77b94a97266c3353dfc835f0cc69))
|
||||
* admin photo feedback filters have no effect ([#293](https://github.com/the-luap/picpeak/issues/293)) ([9ed8a2b](https://github.com/the-luap/picpeak/commit/9ed8a2b1994d139efd100c8fb97e6368655e5530))
|
||||
* **admin:** tab underlines use accent (not accent-dark) for proper highlight color ([565ae45](https://github.com/the-luap/picpeak/commit/565ae45ca71e46166c8bbfc0eb0b6da92d74f120))
|
||||
* apply password change fix to regular modal + longer toast delay ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c63bc47](https://github.com/the-luap/picpeak/commit/c63bc47089b4b32c570bdeeb1f82bf722569875f))
|
||||
* apply password change redirect fix to regular modal too ([#263](https://github.com/the-luap/picpeak/issues/263)) ([147dc28](https://github.com/the-luap/picpeak/commit/147dc28440ca69ed970677fa221dfac00c8e2560))
|
||||
* apply sort direction in gallery and respect show_feedback_to_guests ([#302](https://github.com/the-luap/picpeak/issues/302), [#303](https://github.com/the-luap/picpeak/issues/303)) ([3716ff5](https://github.com/the-luap/picpeak/commit/3716ff50854766bde588fbd6b9027f8647e59150))
|
||||
* apply sort direction in gallery view and respect show_feedback_to_guests ([#302](https://github.com/the-luap/picpeak/issues/302), [#303](https://github.com/the-luap/picpeak/issues/303)) ([dffe057](https://github.com/the-luap/picpeak/commit/dffe057772c922ab6a213e25f171157e0c2badf8))
|
||||
* **auth:** /auth/session must enforce session timeout symmetrically ([#350](https://github.com/the-luap/picpeak/issues/350) recurrence) ([c8e09c2](https://github.com/the-luap/picpeak/commit/c8e09c2a2a7d0920901560317eecd773b83251c0))
|
||||
* **auth:** /auth/session must enforce session timeout symmetrically ([#350](https://github.com/the-luap/picpeak/issues/350) recurrence) ([b106da1](https://github.com/the-luap/picpeak/commit/b106da1ededa27fc8727f2c0e74a9182e6e9c895))
|
||||
* **auth:** /auth/session must reject tokens that adminAuth/galleryAuth would reject ([f905f7e](https://github.com/the-luap/picpeak/commit/f905f7e7336c756e73a8c650c9239b171697164a))
|
||||
* **auth:** /auth/session must verify issuer claim like adminAuth ([#350](https://github.com/the-luap/picpeak/issues/350)) ([83dedbc](https://github.com/the-luap/picpeak/commit/83dedbcd45e34a924594dd83f6e3561f776576fb))
|
||||
* **auth:** make /auth/session verify the issuer claim like adminAuth ([#350](https://github.com/the-luap/picpeak/issues/350)) ([88a6c6a](https://github.com/the-luap/picpeak/commit/88a6c6a7fba7e1419a021f4870518f0b76ac6494))
|
||||
* **backup:** cron schedule mapping + manifest format detection + bigint coerce ([ab4095f](https://github.com/the-luap/picpeak/commit/ab4095f5928b1476009cddfd3444d6f5b58b034d))
|
||||
* **backup:** incremental backups against S3 + jsonb stats parsing ([e232f9f](https://github.com/the-luap/picpeak/commit/e232f9f2cf54aeba1e16d769397428206a0f1801))
|
||||
* **branding:** admin sidebar uses accent-dark, primary buttons follow CI token ([fc2bce3](https://github.com/the-luap/picpeak/commit/fc2bce3a01f02b2d131ca4ce1c8e81fc9dc62755))
|
||||
* **branding:** comprehensive sweep — replace remaining primary-* legacy colors with accent tokens ([578a174](https://github.com/the-luap/picpeak/commit/578a1745b8d010eeeb261d3452fd192b1ec7bcf8))
|
||||
* **branding:** selected-state accent colors, force-mode actually flips galleries, compact color picker layout ([5b410ed](https://github.com/the-luap/picpeak/commit/5b410ed9f87daad8e96345a86897f2a9e9419802))
|
||||
* **branding:** working tooltips, high-contrast selected states, gallery chrome follows accent ([b19bb0c](https://github.com/the-luap/picpeak/commit/b19bb0c6208744f329cb3e99f4e26a83f191710a))
|
||||
* checkbox and toggle settings not persisting after page refresh ([808ed1d](https://github.com/the-luap/picpeak/commit/808ed1d2f1164d9fd1114586c68a1f925bf73ddf)), closes [#117](https://github.com/the-luap/picpeak/issues/117)
|
||||
* **cms:** apply dark mode to CMS editor, public CMS, and admin modals ([d2a10f6](https://github.com/the-luap/picpeak/commit/d2a10f6523655488267d6f68835d7adb46dcf962))
|
||||
* **cms:** nl/pt/ru i18n + gate external_url in public response ([08d0462](https://github.com/the-luap/picpeak/commit/08d046276bf259e8511b01415141f51b8484f967))
|
||||
* **cms:** nl/pt/ru i18n + gate external_url in public response ([bce5c1f](https://github.com/the-luap/picpeak/commit/bce5c1f725043965c2499515f18e93e9578bd204))
|
||||
* correct invitation activation validation and add missing translations ([991aa98](https://github.com/the-luap/picpeak/commit/991aa98f98cffd1d7785c272726615325e2c0208)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
|
||||
* correct invitation email link URL path ([86fa104](https://github.com/the-luap/picpeak/commit/86fa1046d5439cb451feb164175c919c49ca219a)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
|
||||
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([0e3674b](https://github.com/the-luap/picpeak/commit/0e3674b2b0325bbcee5aa2c9ff7781da92f612d1))
|
||||
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([3ccb815](https://github.com/the-luap/picpeak/commit/3ccb8154eb40a432aa467fb06b3f216fd0d2c6b4))
|
||||
* database migration restart bug, lightbox loading spinner, and watermark cache invalidation ([7c58749](https://github.com/the-luap/picpeak/commit/7c5874980640ae8c3d1050ce24daeb0a2aeab7a3))
|
||||
* dedupe parallel admin 401 redirects to /admin/login ([038e84c](https://github.com/the-luap/picpeak/commit/038e84cae7f56a0a1af8c71b85881ca5d320c6e3))
|
||||
* discussion [#317](https://github.com/the-luap/picpeak/issues/317) issues and [#318](https://github.com/the-luap/picpeak/issues/318) archive crash ([2f2f405](https://github.com/the-luap/picpeak/commit/2f2f405d9bc2831b3bbe2ca7fbf726d61382dc38))
|
||||
* display welcome message in gallery and fix guest thumbnail URLs ([#306](https://github.com/the-luap/picpeak/issues/306), [#307](https://github.com/the-luap/picpeak/issues/307)) ([b05c36a](https://github.com/the-luap/picpeak/commit/b05c36ac810a557a2ac088ab7bec39bb76f9a2ae))
|
||||
* display welcome message in gallery and fix guest thumbnail URLs ([#306](https://github.com/the-luap/picpeak/issues/306), [#307](https://github.com/the-luap/picpeak/issues/307)) ([9323bef](https://github.com/the-luap/picpeak/commit/9323befdd99d64b85cca89af24ac1b7034d72eee))
|
||||
* docker compose v2 syntax and add missing ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([0817443](https://github.com/the-luap/picpeak/commit/0817443e793e37c770c6a1968ecae4b9464107b0))
|
||||
* **docker:** install system ffmpeg on Alpine, drop broken bundled binary ([3ab8a64](https://github.com/the-luap/picpeak/commit/3ab8a64a24f1600e674f77d39139e33857b4dfc8))
|
||||
* **docker:** install system ffmpeg on Alpine, drop broken bundled binary ([96818c7](https://github.com/the-luap/picpeak/commit/96818c7ae8de0d8fd478cd901ea25a3272eee85d))
|
||||
* dynamic website title from branding settings ([4701edc](https://github.com/the-luap/picpeak/commit/4701edc12ecfab27cb2d1cfb0b4ed4fd53f56cc6))
|
||||
* **email:** render conditionals, localise password placeholders, fix caller/template variable drift ([0767203](https://github.com/the-luap/picpeak/commit/07672038d4ac31fc601adfb2338104223856ba71))
|
||||
* **email:** render conditionals, localise password placeholders, fix caller/template variable drift ([e8052ad](https://github.com/the-luap/picpeak/commit/e8052adf1d2f1717652ac5d6b8cd8bcc01787189))
|
||||
* event-specific custom CSS settings not being saved ([dadef81](https://github.com/the-luap/picpeak/commit/dadef81158972d28aa32812203500f77ed08a999)), closes [#136](https://github.com/the-luap/picpeak/issues/136)
|
||||
* events search/counters ([#346](https://github.com/the-luap/picpeak/issues/346)), lazy gallery skeleton ([#321](https://github.com/the-luap/picpeak/issues/321)), smooth lightbox swipe ([#348](https://github.com/the-luap/picpeak/issues/348)) ([6229b38](https://github.com/the-luap/picpeak/commit/6229b38bac90cc0c538a72688efae3be77a3bb08))
|
||||
* events without expiration date incorrectly shown as expired ([c4f16eb](https://github.com/the-luap/picpeak/commit/c4f16eb76c909158abdb63aa4cc22f817f274dc5))
|
||||
* **events:** admin-set password on reset, full-URL gallery_link in all emails ([0d1f82d](https://github.com/the-luap/picpeak/commit/0d1f82d31a2f9e30bf193496ac203eaf8dfd856b))
|
||||
* **events:** admin-set password on reset, full-URL gallery_link in all emails ([ff50c74](https://github.com/the-luap/picpeak/commit/ff50c74e1912ccba60f7ccdbead92b76de91388b))
|
||||
* **events:** coerce expires_in_days to Number before addDays ([e5712d8](https://github.com/the-luap/picpeak/commit/e5712d8ffe2f0ed980e1df5e1263876af7202b76))
|
||||
* **events:** coerce expires_in_days to Number before addDays ([db29d0e](https://github.com/the-luap/picpeak/commit/db29d0e2788f63cc9eb0a43ec58313387acb0c0d))
|
||||
* **events:** match scrollbar to theme in external folder tree picker ([bd42ee1](https://github.com/the-luap/picpeak/commit/bd42ee1ce03b8f6e7b011b53f2c71453be931cc6))
|
||||
* **events:** server-side search/pagination to remove first-100 cap ([#346](https://github.com/the-luap/picpeak/issues/346)) ([a5b20ca](https://github.com/the-luap/picpeak/commit/a5b20ca3fe77df665d4a9744413d7ee4054858f0))
|
||||
* **events:** show customer phone in event details view ([#331](https://github.com/the-luap/picpeak/issues/331)) ([4c73d22](https://github.com/the-luap/picpeak/commit/4c73d228ed98b8ec05bec2824aee7ce066a184e1))
|
||||
* **events:** stop mapping branding_logo_position onto hero_logo_position ([af2b062](https://github.com/the-luap/picpeak/commit/af2b0628cb4f79a147366665d35c098012071216))
|
||||
* **events:** stop mapping branding_logo_position onto hero_logo_position ([ef1c875](https://github.com/the-luap/picpeak/commit/ef1c875f6ec1e02657006cb09cd0b1d868ec2fc0))
|
||||
* external media dimensions, theme race condition, email color customization ([dfae2c2](https://github.com/the-luap/picpeak/commit/dfae2c2bc6d86378c553cd847b439f7cb53a4f2a))
|
||||
* floor password_changed_at when comparing against JWT iat ([793e410](https://github.com/the-luap/picpeak/commit/793e410554b461522fbe24014dfd3baa915da2bb))
|
||||
* **fonts:** drop immutable Cache-Control to allow font replacement rollout ([5703fcb](https://github.com/the-luap/picpeak/commit/5703fcb80680155e3b637dd5fc15c430de963c40))
|
||||
* **gallery:** default controls to inline for every layout ([045e9ea](https://github.com/the-luap/picpeak/commit/045e9ea4861f33e3e82e31f978ac297c7f7824f6))
|
||||
* **gallery:** lazy-render skeleton grid for fast loads ([#321](https://github.com/the-luap/picpeak/issues/321) follow-up) ([d9d8137](https://github.com/the-luap/picpeak/commit/d9d81372b80f7d44dca54b7993f52c36574048c9))
|
||||
* **gallery:** preserve sidebar controlsStyle on banner migration ([05dadff](https://github.com/the-luap/picpeak/commit/05dadff4934ad9b055de8875846c2a7175e16f86))
|
||||
* **gallery:** single-finger swipe nav in mobile lightbox ([#332](https://github.com/the-luap/picpeak/issues/332)) ([4c8eba0](https://github.com/the-luap/picpeak/commit/4c8eba0cb43635d92a53d90c58b19007136c1c12))
|
||||
* **gallery:** use ref for swipe-start to avoid stale-closure miss ([#332](https://github.com/the-luap/picpeak/issues/332)) ([fcddfe0](https://github.com/the-luap/picpeak/commit/fcddfe094b2a01963f7b420afa886e7d5dae4390))
|
||||
* **gallery:** WCAG-safe Download button text + extract HeaderDownloadButton ([#401](https://github.com/the-luap/picpeak/issues/401) follow-ups) ([04e928d](https://github.com/the-luap/picpeak/commit/04e928d7621743d9d99797f0996f8c7aa50e7b2d))
|
||||
* **gallery:** WCAG-safe Download button text + extract HeaderDownloadButton ([#401](https://github.com/the-luap/picpeak/issues/401) follow-ups) ([0c80abd](https://github.com/the-luap/picpeak/commit/0c80abd57b806b9df01429a093c30c12c80c0601))
|
||||
* guest feedback flow bugs in Masonry grid and PhotoLightbox ([#292](https://github.com/the-luap/picpeak/issues/292)) ([54badef](https://github.com/the-luap/picpeak/commit/54badefc51b834d55530722f87c81a6ade33e35b))
|
||||
* guest feedback flow bugs in Masonry grid and PhotoLightbox ([#292](https://github.com/the-luap/picpeak/issues/292)) ([77f07e9](https://github.com/the-luap/picpeak/commit/77f07e9329e47f6ac5040f2e85d2710ebbea3ced))
|
||||
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
|
||||
* hero header state and preview in admin theme editor ([#158](https://github.com/the-luap/picpeak/issues/158)) ([f554f46](https://github.com/the-luap/picpeak/commit/f554f463b3492346dba067c0980b52ef42dd5e70))
|
||||
* improve ghost button visibility in admin dark mode ([4912e2b](https://github.com/the-luap/picpeak/commit/4912e2bccf282134d5598a8ac80942ed46d0523c))
|
||||
* improve password validation errors and event list UX ([#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([171abb3](https://github.com/the-luap/picpeak/commit/171abb31615484d77cf95a99cb5634afa0160adc))
|
||||
* improve photo serving, category filters, and upload chunking ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156), [#161](https://github.com/the-luap/picpeak/issues/161)) ([fa4c838](https://github.com/the-luap/picpeak/commit/fa4c83812d87cfa63394e51186e320a072929d37))
|
||||
* increase upload limit to 1GB and fix category filters ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156)) ([397d33a](https://github.com/the-luap/picpeak/commit/397d33a95a09e0b0986c3f6cf5965c544992a764))
|
||||
* issue [#203](https://github.com/the-luap/picpeak/issues/203) file type validation + security CVE fixes ([8017171](https://github.com/the-luap/picpeak/commit/80171713e0ffedda56f7cffb403b25a8d55634d1))
|
||||
* **lightbox:** mobile toolbar clipping + iOS safe-area + viewport-fit ([#336](https://github.com/the-luap/picpeak/issues/336)) ([42a7ae4](https://github.com/the-luap/picpeak/commit/42a7ae4be8fe7b12104ae036465c9c4117606378))
|
||||
* **lightbox:** smooth carousel swipe + drop instructional hint ([#348](https://github.com/the-luap/picpeak/issues/348)) ([743086d](https://github.com/the-luap/picpeak/commit/743086d3cb9100fb163bc9d04d968e5b611a1f99))
|
||||
* mobile lightbox + share previews + customer phone bug triage ([1e40677](https://github.com/the-luap/picpeak/commit/1e4067713ce9a808a7b49319bc262e5c9a6599c6))
|
||||
* mobile upload button not visible in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([cacaffa](https://github.com/the-luap/picpeak/commit/cacaffa5c39f67105c4cfb092ea62157121fb72e))
|
||||
* mobile upload button visibility in gallery ([2a2c23d](https://github.com/the-luap/picpeak/commit/2a2c23d11610e6c81684163eb4ea934a6d6104fb)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* mobile upload button visibility in gallery ([df7dbff](https://github.com/the-luap/picpeak/commit/df7dbffbffb180e62af0d2b58326f9de0f515439)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([05a5307](https://github.com/the-luap/picpeak/commit/05a5307e22dc45be4b75b2996ff9fac65dec399d))
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c))
|
||||
* **nginx:** proxy /fonts requests to backend ([e6c03e4](https://github.com/the-luap/picpeak/commit/e6c03e4b6e4ee2ccc3e3cd8b7a54c18f9685c2ba))
|
||||
* pin npm to v10 in backend Dockerfile ([ddefd3a](https://github.com/the-luap/picpeak/commit/ddefd3a95e5047d4a22aa4b6fef57dfb1c880967))
|
||||
* pin npm upgrade to v10 in backend Dockerfile ([978e447](https://github.com/the-luap/picpeak/commit/978e4473b5227ee61ad7d17487063eb3284bea36))
|
||||
* prevent backend crash on archive when admin_email is null ([#318](https://github.com/the-luap/picpeak/issues/318)) ([e4b0f96](https://github.com/the-luap/picpeak/commit/e4b0f961b75952b6907cc2291fa256215c09c80c))
|
||||
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
|
||||
* remove non-functional watermark toggle from Feature Toggles ([d4a15db](https://github.com/the-luap/picpeak/commit/d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c))
|
||||
* render minimal/none header styles, cap hero height, switch category hero images ([#158](https://github.com/the-luap/picpeak/issues/158), [#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([bc6c48b](https://github.com/the-luap/picpeak/commit/bc6c48bb2429505c2de3641693a8ff4f623a4951))
|
||||
* resend gallery email fails for events without password ([6b3ead7](https://github.com/the-luap/picpeak/commit/6b3ead747b1395d8ea2b3d135a5ac24db05e2eb8)), closes [#137](https://github.com/the-luap/picpeak/issues/137)
|
||||
* resolve admin invitation flow issues and improve STORAGE_PATH documentation ([41bf6ff](https://github.com/the-luap/picpeak/commit/41bf6ff884d5ef3181f95f3aa4a528434c23947a))
|
||||
* resolve code quality issues and add missing i18n keys ([#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([329d224](https://github.com/the-luap/picpeak/commit/329d224846d3f4eefa31e42337f34047c267d578))
|
||||
* resolve code scanning security alerts (multer, tar, Node 22) ([85a07fc](https://github.com/the-luap/picpeak/commit/85a07fcca7ad935f4c0c300f5ffe2f3af8da1e5f))
|
||||
* resolve external media dimensions, gallery theme race condition, and add email color customization ([bbeedd1](https://github.com/the-luap/picpeak/commit/bbeedd1888561b6c57586b5f42bbfee3ffc69fd7))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33af088](https://github.com/the-luap/picpeak/commit/33af0885607799e0071e2e74a582c7eb396c9b83))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([5ea4ef3](https://github.com/the-luap/picpeak/commit/5ea4ef3cf36b06f9e6c9108f80bfe2e9a6470898))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33483cf](https://github.com/the-luap/picpeak/commit/33483cf32dfae57f8da51c0765353792239135f9))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([cd00bc1](https://github.com/the-luap/picpeak/commit/cd00bc13d4e02a86a0f1742ed1f11f064614b8da))
|
||||
* resolve JWT iat timing issue in password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c031b1e](https://github.com/the-luap/picpeak/commit/c031b1e86333d90e8e0e0aa723572efa110f7fd1))
|
||||
* resolve mixed light/dark mode styling in admin UI ([#175](https://github.com/the-luap/picpeak/issues/175)) ([f8c8abd](https://github.com/the-luap/picpeak/commit/f8c8abd70bbae35d6cd519894624ade33b5115a8))
|
||||
* resolve password change redirect loop ([#263](https://github.com/the-luap/picpeak/issues/263)) and file watcher crash ([#269](https://github.com/the-luap/picpeak/issues/269)) ([b23c51b](https://github.com/the-luap/picpeak/commit/b23c51b386270dee4d911902b728dfacb1ff1bf9))
|
||||
* resolve password change redirect loop and file watcher crash ([835bdf5](https://github.com/the-luap/picpeak/commit/835bdf5abb40c7b143c5cdafb507c317a7c349bf)), closes [#269](https://github.com/the-luap/picpeak/issues/269)
|
||||
* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([07fc5e6](https://github.com/the-luap/picpeak/commit/07fc5e6519cd84f2214479d5f31bc35a495bfe4b))
|
||||
* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([3c8d344](https://github.com/the-luap/picpeak/commit/3c8d344ddd23974c9cf0f5f63edd6cd07817fee9))
|
||||
* respect allowed_file_types setting for upload validation ([#203](https://github.com/the-luap/picpeak/issues/203)) ([fe07a14](https://github.com/the-luap/picpeak/commit/fe07a148f1d998c0be00377c1f8b4eca3908305c))
|
||||
* respect optional email settings in event creation ([831ea6a](https://github.com/the-luap/picpeak/commit/831ea6a3bccfae4ec00ce1f619967b91b85150ce))
|
||||
* respect optional email settings in event creation ([#217](https://github.com/the-luap/picpeak/issues/217)) ([9c44a0e](https://github.com/the-luap/picpeak/commit/9c44a0ebfa527fa133512eb7f2f03335a2377aaa))
|
||||
* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([3974ba5](https://github.com/the-luap/picpeak/commit/3974ba5de5a6605ad906608d3e4d61620a215059))
|
||||
* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([5cef7fd](https://github.com/the-luap/picpeak/commit/5cef7fdd188389512bc4b55ae61536c8b1219eb8))
|
||||
* revert /api prefix in adminPhotos.js to avoid double-prefix ([094276d](https://github.com/the-luap/picpeak/commit/094276d3cc7117eee30e4bcbce487e54f0eacb29))
|
||||
* revert /api prefix in adminPhotos.js to avoid double-prefix ([#307](https://github.com/the-luap/picpeak/issues/307)) ([ceb2a09](https://github.com/the-luap/picpeak/commit/ceb2a09f483b4754fda232c5c1f7acb8971aac10))
|
||||
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([85a60a2](https://github.com/the-luap/picpeak/commit/85a60a2dc7526aa6b673e2a04e9fdfba7de7117f))
|
||||
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([f362239](https://github.com/the-luap/picpeak/commit/f3622396e77ce5d0b0741e439fc554a1dccaca50))
|
||||
* **security:** resolve all npm audit vulnerabilities ([4272618](https://github.com/the-luap/picpeak/commit/4272618b3f7fcb06aaca14fb724a6a7733251f24))
|
||||
* **security:** resolve Docker image CVEs for code scanning alerts ([cbecb93](https://github.com/the-luap/picpeak/commit/cbecb9323cf4b80c800326de14f6df73f60147c1))
|
||||
* **security:** token invalidation on password change, session timeout enforcement ([0a3a537](https://github.com/the-luap/picpeak/commit/0a3a53763c9f3caef9fdceccf9fdbfdefe9bd8bf))
|
||||
* **security:** token invalidation on password change, session timeout enforcement ([7ca9631](https://github.com/the-luap/picpeak/commit/7ca96315e254eef58d8ecc505f95a5186d2fa2da))
|
||||
* set JWT iat after password_changed_at to prevent token rejection ([#263](https://github.com/the-luap/picpeak/issues/263)) ([b1d1667](https://github.com/the-luap/picpeak/commit/b1d16670d56e19f7b35e7f2f12f3611fdb3fab58))
|
||||
* **share:** OG/Twitter-card metadata for gallery share URLs ([#333](https://github.com/the-luap/picpeak/issues/333)) ([5275621](https://github.com/the-luap/picpeak/commit/5275621fcd38f1ec09b54595163ecd5e63614b1a))
|
||||
* shorten Save button label on email template editor ([7250c42](https://github.com/the-luap/picpeak/commit/7250c427b905ffa3e8696dff607450f5a0b801b8))
|
||||
* show upload button in mobile topbar instead of sidebar ([ae181cf](https://github.com/the-luap/picpeak/commit/ae181cf92fc9c1e85cad7a7b843a4d83cec636ac)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* sync backend package-lock.json for security deps ([bb81fa5](https://github.com/the-luap/picpeak/commit/bb81fa5f4b5f1bd927a02470ce80a13c4f53443f))
|
||||
* sync backend package-lock.json with security dep updates ([03e1989](https://github.com/the-luap/picpeak/commit/03e19893b3532a27aa59e7b834d53c6a2b52b7cd))
|
||||
* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([2288309](https://github.com/the-luap/picpeak/commit/228830939553fd32c250704bb89a8ce233324d25))
|
||||
* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([a19e7c4](https://github.com/the-luap/picpeak/commit/a19e7c40a200ff822c947a83349ed07ccf4e1b01))
|
||||
* theme picker buttons no longer submit the parent form ([#326](https://github.com/the-luap/picpeak/issues/326)) ([2eead52](https://github.com/the-luap/picpeak/commit/2eead523193ccb7f23eb767097ad9698e8312833))
|
||||
* theme save without Live Preview, Branding default on new events, gallery loading flicker ([#323](https://github.com/the-luap/picpeak/issues/323), [#321](https://github.com/the-luap/picpeak/issues/321)) ([822be9a](https://github.com/the-luap/picpeak/commit/822be9a9b2716f1832a4cb6fccd53602e3cbab51))
|
||||
* theme-preset match loop ignores extra fields like logoUrl ([#323](https://github.com/the-luap/picpeak/issues/323)) ([b63a877](https://github.com/the-luap/picpeak/commit/b63a8774c4b44733b903736b2ca5a472a884055e))
|
||||
* **theme:** centralise force-mode enforcement inside ThemeContext so every gallery flips ([21188f4](https://github.com/the-luap/picpeak/commit/21188f48d76dd29bc1251bcc6faf9d6d96c805b5))
|
||||
* **theme:** kill initial white frame + theme-aware skeleton tiles ([#358](https://github.com/the-luap/picpeak/issues/358) follow-up) ([f529c9e](https://github.com/the-luap/picpeak/commit/f529c9e3d72f0e3496951dfa5d160afda9a1ac51))
|
||||
* **theme:** kill initial white frame + theme-aware skeleton tiles ([#358](https://github.com/the-luap/picpeak/issues/358) follow-up) ([1a530ae](https://github.com/the-luap/picpeak/commit/1a530aeaa2d61b34d9721a555b71631c7101c58e))
|
||||
* **theme:** pre-React bootstrap to kill white-flash on dark galleries ([#358](https://github.com/the-luap/picpeak/issues/358)) ([07b41e6](https://github.com/the-luap/picpeak/commit/07b41e691d2e8a71f775c667d805a2f9adc10590))
|
||||
* **theme:** pre-React bootstrap to kill white-flash on dark galleries ([#358](https://github.com/the-luap/picpeak/issues/358)) ([f81a872](https://github.com/the-luap/picpeak/commit/f81a8728e67b313ac43f55c94fb635abf9beca05))
|
||||
* update dependencies to resolve code scanning security alerts ([1f524f2](https://github.com/the-luap/picpeak/commit/1f524f23580d2e2a21dbba28cb46aed76e85c475))
|
||||
* update docker-compose to docker compose and add ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([a4c6248](https://github.com/the-luap/picpeak/commit/a4c624802b2926a16adcf0472a3041562f9b2f48))
|
||||
* update packages to fix security vulnerabilities ([8097a0c](https://github.com/the-luap/picpeak/commit/8097a0cb530bd8003597cde81606231efadb0bf5))
|
||||
* update security policy with private reporting channels ([308e086](https://github.com/the-luap/picpeak/commit/308e08626383bab213ce3eb5563608dff6168ef4))
|
||||
* update security policy with private reporting channels ([7f77362](https://github.com/the-luap/picpeak/commit/7f7736282f534adf4b9d5331d841a1f0bff7341c))
|
||||
* update security policy with proper contact email and private reporting ([67b0f32](https://github.com/the-luap/picpeak/commit/67b0f32456d0216e4c685a104c680fa5a5fd578f)), closes [#223](https://github.com/the-luap/picpeak/issues/223)
|
||||
* use actual photo aspect ratios in masonry columns mode ([#146](https://github.com/the-luap/picpeak/issues/146)) ([8711f96](https://github.com/the-luap/picpeak/commit/8711f967a15f5d57f6ad01bfdbd8d33f9ee96abc))
|
||||
* use CSS Columns for gap-free mosaic layout ([#146](https://github.com/the-luap/picpeak/issues/146)) ([821d329](https://github.com/the-luap/picpeak/commit/821d3296ea4b6bde499e5497d258f15ab8dd1dbc))
|
||||
* use photo dimensions for mosaic aspect ratios ([#146](https://github.com/the-luap/picpeak/issues/146)) ([27ff51e](https://github.com/the-luap/picpeak/commit/27ff51e7a1217848859b47940bc88caa6f1fb20f))
|
||||
* video upload media type, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([fc75bcd](https://github.com/the-luap/picpeak/commit/fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c))
|
||||
* video upload, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([a0bb080](https://github.com/the-luap/picpeak/commit/a0bb0805868e742f323b64312c3c5ef8ec408f68))
|
||||
* wire admin photo feedback filters into grid query ([#293](https://github.com/the-luap/picpeak/issues/293)) ([d4b4dc6](https://github.com/the-luap/picpeak/commit/d4b4dc628f28a303ff1c80ba6d8e5e768217ba51))
|
||||
* wrap email preview with full styled header/footer template ([9a6d2e8](https://github.com/the-luap/picpeak/commit/9a6d2e8e3a3fab8d7969a8a42e94934c38d88392))
|
||||
* wrap email preview with full styled header/footer template ([fc0911a](https://github.com/the-luap/picpeak/commit/fc0911acf8b7c8a18d71bb4267f1086acd1e0ca1)), closes [#229](https://github.com/the-luap/picpeak/issues/229)
|
||||
* wrap test email with standard email template ([#252](https://github.com/the-luap/picpeak/issues/252)) ([954a011](https://github.com/the-luap/picpeak/commit/954a0118bae5770c74f1e811e03b8fc702c70db2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
### Reverts
|
||||
|
||||
* **external-media:** pre-generate thumbnails so reference-mode galleries load fast ([#423](https://github.com/the-luap/picpeak/issues/423)) ([e2ffd9f](https://github.com/the-luap/picpeak/commit/e2ffd9f93d228f9e16408fe424c65b26db67ac8e))
|
||||
* **external-media:** pre-generate thumbnails so reference-mode galleries load fast ([#423](https://github.com/the-luap/picpeak/issues/423)) ([f3d0f16](https://github.com/the-luap/picpeak/commit/f3d0f161c9e554a5149e6b4eafdb0ac42bebf277))
|
||||
|
||||
## [3.42.5-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.4-beta.0...v3.42.5-beta.0) (2026-05-08)
|
||||
* **branding:** per-option font preview (defer to follow-up) ([f410207](https://github.com/the-luap/picpeak/commit/f410207b2d7ddf1c9525603c7dcbb7cdfee1729b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
### Documentation
|
||||
|
||||
* **admin:** test email always sends, regardless of update availability ([#418](https://github.com/the-luap/picpeak/issues/418)) ([9326a42](https://github.com/the-luap/picpeak/commit/9326a427b32458dfdaa01530bac66cda84ed7b72))
|
||||
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
|
||||
* add Buy Me a Coffee badge + Support section ([46bc894](https://github.com/the-luap/picpeak/commit/46bc894d917bd55dbd9bafaa64fd38db21488b81))
|
||||
* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([2e1c71c](https://github.com/the-luap/picpeak/commit/2e1c71c1ab073e488ac93e35337a2d3955dfef3d))
|
||||
* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([f6ca713](https://github.com/the-luap/picpeak/commit/f6ca713a6edc8ba371db790daba05ecb85ea4872))
|
||||
* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([5295516](https://github.com/the-luap/picpeak/commit/5295516b67a1d9f035564c5f9a724f25f8d21c78))
|
||||
* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([ee0baaf](https://github.com/the-luap/picpeak/commit/ee0baafc59f3588a26172aa8835c12dcaec35d10))
|
||||
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
|
||||
* **fonts:** cache rollout, stale-list note, meta.json ([bd0e052](https://github.com/the-luap/picpeak/commit/bd0e052b1a1847718151a16117dacc6c42a2178e))
|
||||
* move documentation to docs.picpeak.app, drop in-repo copies ([02ed5d4](https://github.com/the-luap/picpeak/commit/02ed5d400736f966283a138dedde2455448067ff))
|
||||
* move documentation to docs.picpeak.app, drop in-repo copies ([0faf9b3](https://github.com/the-luap/picpeak/commit/0faf9b32816f5f94aa584d2336cdb1e0b7082239))
|
||||
* **readme:** add Contributors section with @Luca-Timo and @Rekoo-PS ([c60ab74](https://github.com/the-luap/picpeak/commit/c60ab74ae2daabc4b11fea1f1b2df728294b03c8))
|
||||
* **readme:** add Contributors section with @Luca-Timo and @Rekoo-PS ([dbe0a30](https://github.com/the-luap/picpeak/commit/dbe0a3055bd2c71981cb7d9cf43c2b22b9e3276c))
|
||||
* rewrite README — shorter, cleaner ([62643f2](https://github.com/the-luap/picpeak/commit/62643f241b51dc1620e30a8c8767f52428c0314c))
|
||||
* rewrite README — shorter, cleaner, less AI-sounding ([64f6061](https://github.com/the-luap/picpeak/commit/64f606152fde2db9034fa9ffa08cc58623edf646))
|
||||
|
||||
## [3.42.4-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.3-beta.0...v3.42.4-beta.0) (2026-05-08)
|
||||
## [3.42.1](https://github.com/the-luap/picpeak/compare/v2.6.5...v3.42.1) (2026-05-07)
|
||||
|
||||
Stable release promoting the entire `beta` channel to `main`. Brings ~300 commits of features, fixes, and infrastructure improvements that have been baked on the beta channel since v2.6.5. Highlights below; full per-version notes follow in the beta history.
|
||||
|
||||
### Bug Fixes
|
||||
### Major themes since v2.6.5
|
||||
|
||||
* **events:** typed-DELETE confirmation for bulk delete ([#417](https://github.com/the-luap/picpeak/issues/417)) ([e165ee5](https://github.com/the-luap/picpeak/commit/e165ee5d9fa805c704a64f91c9514bf0ab75b5b8))
|
||||
* **events:** typed-DELETE confirmation for bulk delete ([#417](https://github.com/the-luap/picpeak/issues/417)) ([99e420b](https://github.com/the-luap/picpeak/commit/99e420b1b9783a1d6b4eb892c09d0af3340bf314))
|
||||
* **Multi-administrator support with RBAC** — super admin / admin / editor roles, fine-grained permissions
|
||||
* **Async upload pipeline** — bytes-on-wire returns 202; sharp/ffmpeg/EXIF/watermark/webhooks happen in a background worker pool
|
||||
* **Self-hosted webfonts** — filesystem-driven scanner, GDPR-compliant, replaces Google Fonts CDN
|
||||
* **8-token CI palette + force color mode** — full theme customization across admin and public site
|
||||
* **Native multi-arch Docker images** — Apple Silicon and ARM64 Linux supported natively
|
||||
* **Native S3 storage backend** — S3 + S3-compatible providers
|
||||
* **Comprehensive video support** — upload, stream, and play MP4/WebM/MOV alongside photos
|
||||
* **Outbound webhooks** — event/photo lifecycle push API with HMAC signatures
|
||||
* **Gallery layout system** — decoupled header style from layout, banner option, theme-aware skeletons, and lazy-loaded folder picker
|
||||
* **Multilingual email templates** — translations table for EN/DE/NL/PT/RU
|
||||
* **Bulk operations** — bulk delete with password confirmation, bulk archive
|
||||
* **Photo dimensions backfill** — true masonry layout with portrait/landscape sizing
|
||||
* **Customer client access** — separate review/visibility area before the gallery is shared with guests
|
||||
* **Image security** — devtools detection, watermarking, right-click prevention, secure thumbnails
|
||||
|
||||
## [3.42.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.2-beta.0...v3.42.3-beta.0) (2026-05-07)
|
||||
### Bug fixes (highlights from beta)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **create-event:** re-apply Branding theme on stale→fresh settings ([#323](https://github.com/the-luap/picpeak/issues/323)-B) ([401abf7](https://github.com/the-luap/picpeak/commit/401abf7a27cb73dd7fb8399f4c05644c95767093))
|
||||
* **security:** scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage ([7abfeb9](https://github.com/the-luap/picpeak/commit/7abfeb91cc7bbb9b6853146dbfe16b8d9835bcb3))
|
||||
* **security:** scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage ([6b6191a](https://github.com/the-luap/picpeak/commit/6b6191a4260650e21c45f6153cac1b142bf8483a))
|
||||
|
||||
## [3.42.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.1-beta.0...v3.42.2-beta.0) (2026-05-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** patch 18 dependency CVEs (axios + transitives + nodemailer + i18next-http-backend) ([b7d6ca0](https://github.com/the-luap/picpeak/commit/b7d6ca0b65e652b50d957380f93ed16e151e94a8))
|
||||
* **security:** patch 18 dependency CVEs (axios + transitives) ([523f499](https://github.com/the-luap/picpeak/commit/523f49916bea44697d40f61e0b6e44b83decc4b9))
|
||||
* `/auth/session` symmetry fixes for the admin-login redirect-loop family (#350, #355, #363, #398)
|
||||
* Email template renderer: handle `{{#if}}` conditionals, fix CSS leak in plain-text fallback, gate publish-from-draft password placeholder, gate `external_url` in public response
|
||||
* Customer email caller/template variable drift across gallery_created, expiration_warning, archive_complete, gallery_expired
|
||||
* Full-URL `gallery_link` in all email types (was path-only in 3 sites)
|
||||
* ffmpeg/ffprobe installed via apk for Alpine compatibility (was glibc-bundled binary)
|
||||
* Theme-aware skeleton tiles, dark theme white-flash on first paint
|
||||
* Admin events search and counters not bounded to first 100 records (#346)
|
||||
* Login redirect loop with stale admin cookies (#350) — three rounds of asymmetry fixes
|
||||
|
||||
## [3.42.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.0-beta.0...v3.42.1-beta.0) (2026-05-07)
|
||||
|
||||
@@ -1892,3 +2119,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [1.1.15] - Previous Release
|
||||
|
||||
Initial stable release with core functionality.
|
||||
|
||||
---
|
||||
|
||||
# Pre-3.x history (main 2.x channel)
|
||||
|
||||
## [2.6.5](https://github.com/the-luap/picpeak/compare/v2.6.4...v2.6.5) (2026-04-08)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* rewrite README — shorter, cleaner ([62643f2](https://github.com/the-luap/picpeak/commit/62643f241b51dc1620e30a8c8767f52428c0314c))
|
||||
* rewrite README — shorter, cleaner, less AI-sounding ([64f6061](https://github.com/the-luap/picpeak/commit/64f606152fde2db9034fa9ffa08cc58623edf646))
|
||||
|
||||
## [2.6.4](https://github.com/the-luap/picpeak/compare/v2.6.3...v2.6.4) (2026-04-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* sync backend package-lock.json for security deps ([bb81fa5](https://github.com/the-luap/picpeak/commit/bb81fa5f4b5f1bd927a02470ce80a13c4f53443f))
|
||||
* sync backend package-lock.json with security dep updates ([03e1989](https://github.com/the-luap/picpeak/commit/03e19893b3532a27aa59e7b834d53c6a2b52b7cd))
|
||||
|
||||
## [2.6.3](https://github.com/the-luap/picpeak/compare/v2.6.2...v2.6.3) (2026-04-07)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([2e1c71c](https://github.com/the-luap/picpeak/commit/2e1c71c1ab073e488ac93e35337a2d3955dfef3d))
|
||||
* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([f6ca713](https://github.com/the-luap/picpeak/commit/f6ca713a6edc8ba371db790daba05ecb85ea4872))
|
||||
|
||||
## [2.6.2](https://github.com/the-luap/picpeak/compare/v2.6.1...v2.6.2) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([85a60a2](https://github.com/the-luap/picpeak/commit/85a60a2dc7526aa6b673e2a04e9fdfba7de7117f))
|
||||
* **security:** token invalidation on password change, session timeout enforcement ([0a3a537](https://github.com/the-luap/picpeak/commit/0a3a53763c9f3caef9fdceccf9fdbfdefe9bd8bf))
|
||||
|
||||
## [2.6.1](https://github.com/the-luap/picpeak/compare/v2.6.0...v2.6.1) (2026-03-11)
|
||||
|
||||
@@ -0,0 +1,888 @@
|
||||
# 🚀 PicPeak Deployment Guide
|
||||
|
||||
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Configuration](#-configuration)
|
||||
- [Deployment](#-deployment)
|
||||
- [First Login](#-first-login)
|
||||
- [Release Channels](#-release-channels)
|
||||
- [Reverse Proxy Setup](#-reverse-proxy-setup)
|
||||
- [External Media Library](#-external-media-library)
|
||||
- [Maintenance](#-maintenance)
|
||||
- [Troubleshooting](#-troubleshooting)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Option 1: Automated Setup Script (Easiest)
|
||||
|
||||
For the simplest installation, use our unified setup script:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
|
||||
|
||||
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
|
||||
|
||||
### Option 2: Docker with Pre-built Images (Recommended)
|
||||
|
||||
```bash
|
||||
# Clone repository for configuration files
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
# Create required directories
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
|
||||
# Deploy using pre-built images
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
```
|
||||
|
||||
**Available image tags:**
|
||||
| Channel | Tags | Description |
|
||||
|---------|------|-------------|
|
||||
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
|
||||
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
|
||||
| Branch | `main`, `beta` | Latest from each branch |
|
||||
|
||||
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
|
||||
|
||||
### Option 3: Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Domain name (for production)
|
||||
- SMTP server credentials for emails
|
||||
- At least 2GB RAM and 20GB storage
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Essential Environment Variables
|
||||
|
||||
Generate secure values:
|
||||
```bash
|
||||
# JWT Secret
|
||||
openssl rand -base64 64
|
||||
|
||||
# Database Password (avoid $ character - see warning below)
|
||||
openssl rand -base64 32 | tr -d '$'
|
||||
|
||||
# Redis Password (avoid $ character - see warning below)
|
||||
openssl rand -base64 32 | tr -d '$'
|
||||
```
|
||||
|
||||
⚠️ **PASSWORD WARNING**: Docker Compose interprets `$` as variable substitution. Either:
|
||||
- Avoid `$` in passwords (recommended - use the commands above)
|
||||
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
|
||||
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
|
||||
|
||||
### Public Landing Page
|
||||
|
||||
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
|
||||
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
|
||||
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
|
||||
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
|
||||
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
|
||||
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
|
||||
|
||||
### Backend Configuration (.env)
|
||||
Update `.env` with:
|
||||
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
|
||||
- `DB_PASSWORD` - PostgreSQL password
|
||||
- `REDIS_PASSWORD` - Redis password
|
||||
- `SMTP_*` - Email configuration
|
||||
- **URL Configuration** (for backend CORS):
|
||||
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
|
||||
Notes:
|
||||
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
|
||||
- Always include the scheme (`http://` or `https://`).
|
||||
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
|
||||
|
||||
#### Authentication Security
|
||||
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
|
||||
|
||||
#### External Database Example
|
||||
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
|
||||
|
||||
```env
|
||||
DB_HOST=db.example.com
|
||||
DB_PORT=5432
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=change_me
|
||||
DB_NAME=picpeak_prod
|
||||
```
|
||||
|
||||
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you don’t set `DB_HOST` it will use the bundled `postgres` container.
|
||||
|
||||
### Frontend Configuration (frontend/.env)
|
||||
Create `frontend/.env` from `frontend/.env.example`:
|
||||
```bash
|
||||
cp frontend/.env.example frontend/.env
|
||||
```
|
||||
|
||||
Update `frontend/.env` with:
|
||||
- `VITE_API_URL` - Backend API URL
|
||||
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
|
||||
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
|
||||
|
||||
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
|
||||
|
||||
⚠️ **IMPORTANT PORT CONFIGURATION**:
|
||||
- The frontend runs on port **3000** in Docker (exposed via nginx)
|
||||
- The backend API runs on port **3001**
|
||||
- The frontend `.env` file MUST point to the correct backend port (3001)
|
||||
- Default `.env.example` is configured for Docker deployment
|
||||
|
||||
### Email Configuration Examples
|
||||
|
||||
#### Gmail
|
||||
```env
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-specific-password
|
||||
```
|
||||
|
||||
#### SendGrid
|
||||
```env
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
```
|
||||
|
||||
## 📦 Deployment
|
||||
|
||||
### Using Pre-built Images (Fastest)
|
||||
|
||||
```bash
|
||||
# Pull latest images from GitHub Container Registry
|
||||
docker pull ghcr.io/the-luap/picpeak/backend:latest
|
||||
docker pull ghcr.io/the-luap/picpeak/frontend:latest
|
||||
|
||||
# Start services using production compose file
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# View running containers
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Building from Source (For Customization)
|
||||
|
||||
```bash
|
||||
# Build images locally
|
||||
docker compose build
|
||||
|
||||
# Or build with no cache for clean build
|
||||
docker compose build --no-cache
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
# View running containers
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Access Points
|
||||
|
||||
By default, services are exposed on:
|
||||
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
|
||||
- Backend/API: http://localhost:3001 (API only; no UI routes)
|
||||
- PostgreSQL: localhost:5432 (if needed)
|
||||
- Redis: localhost:6379 (if needed)
|
||||
|
||||
### Initial Admin Setup
|
||||
|
||||
When deploying for the first time, an admin account is automatically created with a secure, randomly generated password. This password is displayed in the Docker logs during initialization and **must be changed** on first login.
|
||||
|
||||
#### Finding the Auto-Generated Admin Password
|
||||
|
||||
The admin password is automatically generated during the first startup and displayed in the backend container logs. Here's how to find it:
|
||||
|
||||
**Option 1: Search Docker logs for admin password** (recommended)
|
||||
```bash
|
||||
# Find the auto-generated admin password in logs
|
||||
docker compose logs backend | grep "Admin password"
|
||||
```
|
||||
|
||||
You should see output like:
|
||||
```
|
||||
✅ Admin password generated: BraveTiger6231!
|
||||
```
|
||||
|
||||
**Option 2: View the complete initialization logs**
|
||||
```bash
|
||||
# View the complete admin setup logs
|
||||
docker compose logs backend | grep -A 10 "Admin user created"
|
||||
```
|
||||
|
||||
**Option 3: Check the saved credentials file**
|
||||
```bash
|
||||
# The password is also saved in the backend container
|
||||
docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
|
||||
```
|
||||
|
||||
**Option 4: Use the helper script**
|
||||
```bash
|
||||
# Show current admin username and email (password is hidden)
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js
|
||||
|
||||
# Reset the admin password to a new random password (displays new password in console)
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
```
|
||||
|
||||
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
|
||||
|
||||
#### Important Security Notes
|
||||
|
||||
- **Login requires the email address**, not username
|
||||
- When resetting password, the new password is displayed once in the console - save it immediately
|
||||
- **Password change is MANDATORY** on first login - the system will force you to change it
|
||||
- If you lose the password before first login, use the `--reset` option to generate a new one
|
||||
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
|
||||
|
||||
## 🔐 First Login
|
||||
|
||||
After deployment, you must complete the first login process which includes mandatory password change for security.
|
||||
|
||||
### Step 1: Locate Your Admin Password
|
||||
|
||||
1. **Find the auto-generated password** from the credentials file:
|
||||
```bash
|
||||
# Docker deployment
|
||||
docker compose exec backend cat /app/data/ADMIN_CREDENTIALS.txt
|
||||
|
||||
# Or directly from the host (if you have access)
|
||||
cat data/ADMIN_CREDENTIALS.txt
|
||||
```
|
||||
|
||||
2. **Note the admin email** (default: `admin@example.com` unless customized)
|
||||
|
||||
### Step 2: Access Admin Panel
|
||||
|
||||
1. Navigate to your frontend domain and open the admin section:
|
||||
- `http://your-domain.com/admin` (behind reverse proxy)
|
||||
- `http://localhost:3000/admin` (Docker local)
|
||||
|
||||
The backend at `:3001` serves API only and does not serve the admin UI.
|
||||
2. Login using:
|
||||
- **Email**: `admin@example.com` (or your custom admin email)
|
||||
- **Password**: The auto-generated password from the logs
|
||||
|
||||
### Step 3: Mandatory Password Change
|
||||
|
||||
Upon first login, the system will **automatically redirect** you to change your password:
|
||||
|
||||
1. **You cannot skip this step** - it's enforced for security
|
||||
2. Enter the current auto-generated password
|
||||
3. Create a new secure password meeting these requirements:
|
||||
- Minimum 12 characters
|
||||
- At least one uppercase letter
|
||||
- At least one lowercase letter
|
||||
- At least one number
|
||||
- At least one special character (!@#$%^&*)
|
||||
|
||||
### Security Best Practices for New Password
|
||||
|
||||
- **Use a unique password** not used elsewhere
|
||||
- **Consider a password manager** for generation and storage
|
||||
- **Include mixed characters**: `MySecureP@ssw0rd2024!`
|
||||
- **Avoid personal information** (names, dates, etc.)
|
||||
- **Save securely** - you cannot recover this password easily
|
||||
|
||||
### If You Lose Access
|
||||
|
||||
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
||||
|
||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
|
||||
|
||||
```bash
|
||||
# Native reinstall example
|
||||
sudo ./picpeak-setup.sh --native --force-admin-password-reset
|
||||
|
||||
# Docker reinstall example
|
||||
sudo ./picpeak-setup.sh --docker --force-admin-password-reset
|
||||
```
|
||||
|
||||
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
|
||||
|
||||
#### Configuring Admin Email
|
||||
|
||||
By default, the admin email is `admin@example.com`. To use a different email address, set it in your `.env` file before first deployment:
|
||||
|
||||
```env
|
||||
# .env
|
||||
ADMIN_EMAIL=your-email@yourdomain.com
|
||||
```
|
||||
|
||||
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
|
||||
|
||||
## 🔄 Release Channels
|
||||
|
||||
PicPeak offers two release channels for different needs:
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
- Thoroughly tested before release
|
||||
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
|
||||
|
||||
### Beta Channel
|
||||
- Early access to new features
|
||||
- May contain bugs or incomplete functionality
|
||||
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
|
||||
|
||||
### Configuring Your Channel
|
||||
|
||||
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
|
||||
|
||||
```bash
|
||||
# For stable releases (default)
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# For beta releases
|
||||
PICPEAK_CHANNEL=beta
|
||||
|
||||
# For a specific version
|
||||
PICPEAK_CHANNEL=v2.3.0
|
||||
```
|
||||
|
||||
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
|
||||
```yaml
|
||||
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
|
||||
```
|
||||
|
||||
### Switching Channels
|
||||
|
||||
To switch between channels:
|
||||
|
||||
```bash
|
||||
# Edit your .env file
|
||||
nano .env
|
||||
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
|
||||
|
||||
# Pull the new images and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
|
||||
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
|
||||
- Checks GitHub releases hourly (cached to avoid rate limits)
|
||||
- Shows updates relevant to your current channel (stable or beta)
|
||||
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
|
||||
|
||||
## 🔒 Reverse Proxy Setup
|
||||
|
||||
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
|
||||
|
||||
### Routing Schema
|
||||
|
||||
PicPeak consists of two services that need to be routed correctly:
|
||||
|
||||
| Path | Service | Port | Description |
|
||||
|------|---------|------|-------------|
|
||||
| `/api/*` | Backend | 3001 | All API endpoints |
|
||||
| `/photos/*` | Backend | 3001 | Protected photo files |
|
||||
| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files |
|
||||
| `/uploads/*` | Backend | 3001 | Upload files |
|
||||
| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) |
|
||||
|
||||
> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests.
|
||||
|
||||
### Option 1: Nginx
|
||||
|
||||
Install nginx and create `/etc/nginx/sites-available/picpeak`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# Backend: API endpoints
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Backend: Protected media files
|
||||
location ~ ^/(photos|thumbnails|uploads)/ {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Frontend: Everything else (React SPA)
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the site:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/picpeak /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Option 2: Traefik
|
||||
|
||||
Add labels to `docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak.rule=Host(`your-domain.com`)"
|
||||
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
||||
|
||||
backend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# API endpoints
|
||||
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
|
||||
# Protected media files
|
||||
- "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))"
|
||||
- "traefik.http.routers.picpeak-media.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-media.loadbalancer.server.port=3001"
|
||||
```
|
||||
|
||||
### Option 3: Caddy
|
||||
|
||||
Create a `Caddyfile`:
|
||||
|
||||
```caddyfile
|
||||
your-domain.com {
|
||||
# Backend: API endpoints
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Backend: Protected media files
|
||||
handle /photos/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /thumbnails/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /uploads/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
|
||||
handle {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SSL Certificates
|
||||
|
||||
For any reverse proxy, you can use Let's Encrypt:
|
||||
|
||||
```bash
|
||||
# With Certbot
|
||||
sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com
|
||||
|
||||
# Or use your reverse proxy's built-in ACME support
|
||||
```
|
||||
|
||||
## 📂 External Media Library
|
||||
|
||||
The External Media Library allows events to reference photos stored directly on your host filesystem instead of uploading them through the admin UI. This is useful for photographers who already have organized photo libraries and want to share them without re-uploading.
|
||||
|
||||
### How It Works
|
||||
|
||||
- **Managed mode** (default): Photos are uploaded through the admin UI and stored inside PicPeak's storage directory.
|
||||
- **Reference mode**: Photos remain on your host filesystem. PicPeak reads them directly and generates thumbnails on demand.
|
||||
|
||||
Each event can use either mode. Reference mode events point to a folder under the configured external media root.
|
||||
|
||||
### Configuration
|
||||
|
||||
Add the following to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Path where your photo library is stored on the host
|
||||
EXTERNAL_MEDIA_ROOT=/path/to/your/photos
|
||||
```
|
||||
|
||||
Then mount this path into the backend container in your `docker-compose.yml` or `docker-compose.production.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
- EXTERNAL_MEDIA_ROOT=/external-media
|
||||
volumes:
|
||||
- /path/to/your/photos:/external-media:ro # read-only is recommended
|
||||
```
|
||||
|
||||
> **Permissions**: Ensure the container user (`PUID`/`PGID`) has read access to the mounted directory. If thumbnails fail to generate, this is usually a permissions issue.
|
||||
|
||||
### Folder Structure
|
||||
|
||||
Organize your photos with subdirectories for each event. Within each event folder, use `individual/` and `collages/` subdirectories to classify photos:
|
||||
|
||||
```
|
||||
/path/to/your/photos/
|
||||
├── wedding-smith-2026/
|
||||
│ ├── individual/
|
||||
│ │ ├── IMG_0001.jpg
|
||||
│ │ ├── IMG_0002.jpg
|
||||
│ │ └── ...
|
||||
│ └── collages/
|
||||
│ ├── group-photo.jpg
|
||||
│ └── ...
|
||||
├── corporate-event/
|
||||
│ ├── individual/
|
||||
│ │ └── ...
|
||||
│ └── collages/
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
Supported file formats: `.jpg`, `.jpeg`, `.png`, `.webp`
|
||||
|
||||
### Usage
|
||||
|
||||
1. **Create an event** in the admin panel as usual (name, date, email, etc.).
|
||||
|
||||
2. **Switch source mode** to "Reference external folder" in the event details under Source Mode.
|
||||
|
||||
3. **Browse and select** the external folder using the folder picker that appears. Navigate to the event's directory.
|
||||
|
||||
4. **Import photos** by clicking "Import from External Folder" in the Photos tab. PicPeak will:
|
||||
- Recursively scan the selected folder
|
||||
- Classify photos by subfolder name (`individual/` or `collages/`)
|
||||
- Deduplicate by filename (keeps the largest file if duplicates exist)
|
||||
- Extract image dimensions for gallery layout
|
||||
- Register the photos in the database
|
||||
|
||||
5. **Thumbnails** are generated on demand when a guest first views the gallery. There is no upfront processing delay.
|
||||
|
||||
### Limitations
|
||||
|
||||
- **Images only** — video files are not supported for external media.
|
||||
- **Read-only** — PicPeak does not modify or delete files in the external media directory.
|
||||
- **No automatic sync** — If you add new photos to the external folder, you need to re-import from the admin UI.
|
||||
- **Backup caveat** — External media originals are excluded from PicPeak's built-in backup system. Only thumbnails and database records are backed up. You are responsible for backing up the source files separately.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Folder picker shows empty directory | Check that the volume is mounted correctly and `EXTERNAL_MEDIA_ROOT` matches the container path |
|
||||
| "Permission denied" errors | Ensure `PUID`/`PGID` in `.env` match the owner of the external media files on the host |
|
||||
| Thumbnails not generating | Verify the backend container can read the files: `docker exec picpeak-backend ls /external-media/your-folder/` |
|
||||
| Import finds 0 photos | Only `.jpg`, `.jpeg`, `.png`, `.webp` files are supported. Check file extensions. |
|
||||
|
||||
## 🔧 Maintenance
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
```
|
||||
|
||||
### Backup
|
||||
|
||||
#### Manual Backup
|
||||
```bash
|
||||
# Database backup
|
||||
docker exec picpeak-postgres pg_dump -U picpeak picpeak_prod > backup/db_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# Files backup
|
||||
tar -czf backup/photos_$(date +%Y%m%d_%H%M%S).tar.gz events/
|
||||
```
|
||||
|
||||
#### Automated Backup
|
||||
The application includes a built-in backup service. Configure it in the admin panel:
|
||||
1. Login to admin panel
|
||||
2. Go to Settings → Backup
|
||||
3. Configure destination and schedule
|
||||
4. Enable backup service
|
||||
|
||||
### Updates
|
||||
|
||||
#### Method 1: Using Pre-built Images (Recommended)
|
||||
|
||||
```bash
|
||||
# Pull latest changes (for configuration updates)
|
||||
git pull
|
||||
|
||||
# Pull latest images from GitHub Container Registry
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
|
||||
# Restart with new images
|
||||
docker compose -f docker-compose.production.yml down
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Verify services are healthy
|
||||
docker compose -f docker-compose.production.yml ps
|
||||
```
|
||||
|
||||
#### Method 2: Building from Source
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Verify services are healthy
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
#### Specific Version or Channel Updates
|
||||
|
||||
To use a specific version or switch channels, update your `.env` file:
|
||||
|
||||
```bash
|
||||
# Edit .env to change the channel or pin to a specific version
|
||||
nano .env
|
||||
|
||||
# Options for PICPEAK_CHANNEL:
|
||||
# - stable (recommended, production-ready)
|
||||
# - beta (early access to new features)
|
||||
# - v2.3.0 (pin to specific stable version)
|
||||
# - v2.3.0-beta.1 (pin to specific beta version)
|
||||
|
||||
# Then pull and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
The admin dashboard will notify you when updates are available for your configured channel.
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Migrations run automatically on startup, but you can run them manually:
|
||||
|
||||
```bash
|
||||
docker exec picpeak-backend npm run migrate
|
||||
```
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 502 Bad Gateway / Login Failures
|
||||
**This is the most common deployment issue!** Usually caused by misconfigured URLs or network problems:
|
||||
|
||||
1. **CORS Configuration Errors**:
|
||||
```bash
|
||||
# WRONG - Missing port will cause CORS errors
|
||||
FRONTEND_URL=http://10.0.252.12
|
||||
|
||||
# CORRECT - Include the port you're accessing from
|
||||
FRONTEND_URL=http://10.0.252.12:3000
|
||||
```
|
||||
|
||||
The backend validates Origin headers against `FRONTEND_URL` for CORS. If they don't match exactly, you'll get 500 errors on login.
|
||||
|
||||
2. **After Container Restarts**:
|
||||
- Nginx may have cached old container IPs
|
||||
- Solution: `docker restart picpeak-frontend`
|
||||
- Always wait 30-60 seconds for health checks
|
||||
|
||||
3. **Backend Not Starting After Migrations**:
|
||||
- The logs may only show migrations completed
|
||||
- Check if server is actually running: `docker exec picpeak-backend ps aux | grep node`
|
||||
- Should see `node server.js` process
|
||||
|
||||
4. **Login After Fresh Install**:
|
||||
- Check backend logs for auto-generated admin password: `docker compose logs backend | grep "Admin password"`
|
||||
- Email: `admin@example.com` (or your custom admin email from .env)
|
||||
- Password: Auto-generated and shown in logs (e.g., `BraveTiger6231!`)
|
||||
- Remember: Password MUST be changed on first login
|
||||
|
||||
5. **Complete Fix Sequence**:
|
||||
```bash
|
||||
# 1. Fix your .env file URLs
|
||||
# 2. Full restart
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Wait for healthy status
|
||||
sleep 60
|
||||
docker ps # All should show (healthy)
|
||||
|
||||
# 4. Test backend directly
|
||||
curl http://localhost:3001/health
|
||||
|
||||
# 5. Test through frontend
|
||||
curl http://localhost:3000/api/public/settings
|
||||
```
|
||||
|
||||
#### Port Already in Use
|
||||
```bash
|
||||
# Check what's using the port
|
||||
sudo lsof -i :3000
|
||||
sudo lsof -i :3001
|
||||
|
||||
# Change ports in .env
|
||||
FRONTEND_PORT=3002
|
||||
BACKEND_PORT=3003
|
||||
```
|
||||
|
||||
#### Docker Compose Variable Substitution Errors
|
||||
If you see warnings like:
|
||||
```
|
||||
WARN[0000] The "fgbf" variable is not set. Defaulting to a blank string.
|
||||
```
|
||||
|
||||
This means your password contains `$` which Docker Compose interprets as a variable. Solutions:
|
||||
1. **Best**: Generate passwords without `$`: `openssl rand -base64 32 | tr -d '$'`
|
||||
2. **Alternative**: Escape `$` as `$$` in your .env file
|
||||
3. **Example**: `DB_PASSWORD=Pass@#$$fgbf` instead of `DB_PASSWORD=Pass@#$fgbf`
|
||||
|
||||
#### Permission Errors
|
||||
```bash
|
||||
# Fix ownership
|
||||
sudo chown -R 1000:1000 events data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
#### Database Connection Issues
|
||||
```bash
|
||||
# Check if database is running
|
||||
docker compose ps
|
||||
docker compose logs postgres
|
||||
|
||||
# Test connection
|
||||
docker exec picpeak-postgres pg_isready
|
||||
```
|
||||
|
||||
#### Email Not Sending
|
||||
- Verify SMTP settings in .env
|
||||
- Check email queue: `docker exec picpeak-backend psql -U picpeak -d picpeak_prod -c "SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;"`
|
||||
- For Gmail, use app-specific password
|
||||
- Check logs: `docker compose logs backend | grep email`
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Backend health
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Frontend health
|
||||
curl http://localhost:3000
|
||||
|
||||
# Database health
|
||||
docker exec picpeak-postgres pg_isready
|
||||
```
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# Enter backend container
|
||||
docker exec -it picpeak-backend sh
|
||||
|
||||
# Enter database
|
||||
docker exec -it picpeak-postgres psql -U picpeak picpeak_prod
|
||||
|
||||
# Reset admin password
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
|
||||
# Check disk usage
|
||||
df -h
|
||||
du -sh events/ storage/ backup/
|
||||
|
||||
# View running processes
|
||||
docker compose top
|
||||
```
|
||||
|
||||
## Security Recommendations
|
||||
|
||||
1. **Use HTTPS**: Always use a reverse proxy with SSL in production
|
||||
2. **Firewall**: Only expose necessary ports (80, 443)
|
||||
3. **Secure passwords**: Use strong, unique passwords for all services
|
||||
4. **Regular updates**: Keep Docker images and system packages updated
|
||||
5. **Backup strategy**: Set up automated backups and test restoration
|
||||
6. **Monitor logs**: Regularly check logs for suspicious activity
|
||||
7. **Rate limiting**: The app includes built-in rate limiting, configure as needed
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
- Check logs first: `docker compose logs`
|
||||
- Review documentation in the repository
|
||||
- Check existing issues on GitHub
|
||||
- Create a new issue with:
|
||||
- Error messages
|
||||
- Log output
|
||||
- Environment details (without secrets)
|
||||
- Steps to reproduce
|
||||
@@ -1,482 +1,152 @@
|
||||
# 📸 PicPeak - Open Source Photo Sharing for Events
|
||||
# PicPeak
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
|
||||
|
||||
|
||||
**Self-hosted photo sharing for event photographers.**
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://buymeacoffee.com/theluap)
|
||||
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support the project ☕](https://buymeacoffee.com/theluap)
|
||||
[Live Demo](https://demo.picpeak.app) · [Deployment Guide](DEPLOYMENT_GUIDE.md) · [Homepage](https://www.picpeak.app)
|
||||
</div>
|
||||
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
||||
---
|
||||
|
||||
PicPeak lets you create password-protected, time-limited photo galleries for your clients — hosted on your own server. No subscriptions, no storage limits, no third-party access to your photos.
|
||||
|
||||

|
||||
|
||||
## 🎮 Live Demo
|
||||
## Demo
|
||||
|
||||
Try PicPeak without installing anything:
|
||||
Try it out at [demo.picpeak.app](https://demo.picpeak.app).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
|
||||
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
|
||||
| **Email** | `demo@picpeak.app` |
|
||||
| **Password** | `Demo2026!` |
|
||||
Admin panel: [demo.picpeak.app/admin](https://demo.picpeak.app/admin) — login with `demo@picpeak.app` / `Demo2026!`
|
||||
|
||||
> The demo resets periodically. Uploaded content may be removed without notice.
|
||||
> The demo resets periodically.
|
||||
|
||||
## 🌟 Why Choose PicPeak?
|
||||
## Features
|
||||
|
||||
Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
**Gallery Management** — Create galleries, upload photos via drag & drop, set passwords and expiration dates. Galleries auto-archive when they expire. Events start as drafts so you can upload and prepare before notifying the client.
|
||||
|
||||
- **💰 No Monthly Fees** - One-time setup, unlimited galleries
|
||||
- **🔒 Complete Data Control** - Your photos stay on your server
|
||||
- **🎨 White-Label Ready** - Full branding customization
|
||||
- **📱 Mobile-First Design** - Beautiful on all devices
|
||||
- **🚀 Lightning Fast** - Optimized performance and caching
|
||||
- **🌍 Multi-Language** - Built-in i18n support (EN, DE)
|
||||
**Client Experience** — Responsive galleries that look great on any device. Guests can browse, download individual photos or everything at once. Optional guest uploads and feedback (likes, comments, ratings).
|
||||
|
||||
## ✨ Key Features
|
||||
**Themes & Branding** — 11 built-in theme presets, custom CSS templates, configurable colors/fonts/layouts. White-label your admin panel and login page with your own logo and company name.
|
||||
|
||||
### For Photographers
|
||||
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
|
||||
- 🔗 **External Media (Reference Mode)** - Browse and import from a read‑only external folder library without copying originals
|
||||
- ⏰ **Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
|
||||
- 🔐 **Password Protection** - Secure client galleries
|
||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
|
||||
**Email Notifications** — Automated gallery creation, expiration warning, and archive emails. Multilingual templates (EN, DE, NL, PT, RU) editable from the admin UI.
|
||||
|
||||
### For Clients
|
||||
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
||||
- 📱 **Mobile Optimized** - Swipe through photos on any device
|
||||
- ⬇️ **Bulk Downloads** - Download all photos with one click
|
||||
- 🔍 **Smart Search** - Find photos quickly
|
||||
- 📤 **Guest Uploads** - Optional client photo uploads
|
||||
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
|
||||
**Photo Protection** — Watermarking, right-click prevention, canvas rendering, DevTools detection. Configurable per gallery.
|
||||
|
||||
### Technical Excellence
|
||||
- 🐳 **Docker Ready** - Deploy in minutes
|
||||
- 🔄 **Auto-Processing** - Automatic thumbnail generation
|
||||
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
|
||||
- 💾 **Smart Storage** - Automatic archiving of expired galleries
|
||||
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
||||
- 📈 **Scalable** - From small studios to large agencies
|
||||
**External Media** — Reference photos from a mounted folder instead of uploading. PicPeak reads originals in place and generates thumbnails on demand.
|
||||
|
||||
## 🚀 Quick Start
|
||||
**Multi-Language** — Full UI translations for English, German, Dutch, Portuguese, and Russian. Email templates support all languages independently.
|
||||
|
||||
Get PicPeak running in under 5 minutes:
|
||||
**Analytics** — Built-in view/download tracking plus optional Umami integration for privacy-focused analytics.
|
||||
|
||||
**Video Support** — Upload and stream MP4, WebM, MOV alongside photos. FFmpeg bundled via npm.
|
||||
|
||||
**Multiple Admins** — Role-based access control with super admin, admin, and editor roles.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit configuration (required: JWT_SECRET)
|
||||
nano .env
|
||||
|
||||
# Start with Docker Compose
|
||||
# Edit .env — set at least JWT_SECRET and passwords
|
||||
docker compose up -d
|
||||
|
||||
# Access at http://localhost:3000
|
||||
```
|
||||
|
||||
Note on Docker file permissions (PUID/PGID)
|
||||
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a non‑root user by default.
|
||||
- Set `PUID` and `PGID` in your `.env` to match your host user’s UID/GID (run `id -u` and `id -g` on the host). Compose maps the container user to these values.
|
||||
- Example in `.env`:
|
||||
- `PUID=1000`
|
||||
- `PGID=1000`
|
||||
- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied".
|
||||
Open `http://localhost:3000` and log in with the credentials from your `.env`.
|
||||
|
||||
**ARM64 (aarch64) systems:** Pre-built images include native `linux/arm64`, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see [docker-compose.amd64.override.yml](docker-compose.amd64.override.yml) for a transitional fallback.
|
||||
> **Permissions:** Set `PUID` and `PGID` in `.env` to match your host user (`id -u` / `id -g`) so Docker volumes are writable.
|
||||
|
||||
## 🔄 Release Channels
|
||||
See the [Deployment Guide](DEPLOYMENT_GUIDE.md) for reverse proxy setup, SSL, external media, and production configuration.
|
||||
|
||||
PicPeak offers two release channels for different needs:
|
||||
## Screenshots
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
- Thoroughly tested before release
|
||||
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
|
||||
<details>
|
||||
<summary>Admin Dashboard</summary>
|
||||
|
||||
### Beta Channel
|
||||
- Early access to new features
|
||||
- May contain bugs or incomplete functionality
|
||||
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
|
||||
<img src="docs/screenshot-dashboard.png" alt="Admin Dashboard" width="800" />
|
||||
</details>
|
||||
|
||||
### Switching Channels
|
||||
<details>
|
||||
<summary>Event Management</summary>
|
||||
|
||||
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
|
||||
<img src="docs/screenshots-events.png" alt="Event Management" width="800" />
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Analytics</summary>
|
||||
|
||||
<img src="docs/screenshot-analytics.png" alt="Analytics" width="800" />
|
||||
</details>
|
||||
|
||||
## Comparison
|
||||
|
||||
| | PicPeak | PicDrop | Scrapbook.de |
|
||||
|---|---|---|---|
|
||||
| Self-hosted | Yes | No | No |
|
||||
| Monthly cost | $0 | $29-199 | 19-99 EUR |
|
||||
| Storage | Unlimited | 50-500 GB | 100-1000 GB |
|
||||
| Custom branding | Full | Limited | Limited |
|
||||
| Open source | Yes | No | No |
|
||||
| API | Yes | Paid | No |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend:** Node.js, Express, PostgreSQL (or SQLite)
|
||||
- **Frontend:** React, TypeScript, Tailwind CSS
|
||||
- **Infrastructure:** Docker, Nginx, Redis
|
||||
- **Processing:** Sharp (images), FFmpeg (video)
|
||||
|
||||
## Release Channels
|
||||
|
||||
**Stable** (`stable` / `latest`) — Production-ready. Use this for real deployments.
|
||||
|
||||
**Beta** (`beta`) — Early access to new features. May have rough edges.
|
||||
|
||||
```bash
|
||||
# For stable releases (default)
|
||||
PICPEAK_CHANNEL=stable
|
||||
# Set in .env
|
||||
PICPEAK_CHANNEL=stable # or beta
|
||||
|
||||
# For beta releases
|
||||
PICPEAK_CHANNEL=beta
|
||||
|
||||
# For a specific version
|
||||
PICPEAK_CHANNEL=v2.3.0
|
||||
```
|
||||
|
||||
Then update your containers:
|
||||
|
||||
```bash
|
||||
# Update
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
The admin dashboard notifies you when updates are available.
|
||||
|
||||
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
UPDATE_CHECK_ENABLED=false
|
||||
```
|
||||
We welcome contributions — bug fixes, features, translations, documentation. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions.
|
||||
|
||||
## 📖 Documentation
|
||||
## Documentation
|
||||
|
||||
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings reference, API docs, webhooks, archive lifecycle, branding, and everything else. Some quick links:
|
||||
- [Deployment Guide](DEPLOYMENT_GUIDE.md) — Installation, configuration, reverse proxy, external media
|
||||
- [Admin API (OpenAPI)](docs/picpeak-admin-api.openapi.yaml) — Machine-readable API spec
|
||||
- [Admin API Quickstart](docs/admin-api-quickstart.md) — Authentication and testing guide
|
||||
- [Security Policy](SECURITY.md)
|
||||
|
||||
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
|
||||
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
|
||||
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
|
||||
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Local, S3, rsync destinations
|
||||
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
|
||||
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
|
||||
## Contributors
|
||||
|
||||
Project meta:
|
||||
Thanks to the people whose code, reports, and feedback have shaped PicPeak:
|
||||
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
||||
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, lazy-loaded folder tree picker, admin-email picker, self-hosted webfont system, gallery header/banner decoupling, and several typed-API refactors.
|
||||
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, gallery-loading skeleton work, mobile-lightbox overhaul, admin-events search-counter fix, photo-count column, and bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter.
|
||||
|
||||
## 🌐 Public Landing Page
|
||||
If you've contributed and aren't listed here, please open a PR.
|
||||
|
||||
Spotlight your studio with a customizable marketing page at `/`:
|
||||
|
||||
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
|
||||
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
|
||||
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
|
||||
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
|
||||
- Use **Reset to default** anytime to restore the bundled template.
|
||||
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
|
||||
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
|
||||
## License
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
Perfect for:
|
||||
- 💒 **Wedding Photographers** - Share ceremony photos securely
|
||||
- 🎂 **Event Photography** - Birthday parties, corporate events
|
||||
- 📸 **Portrait Studios** - Client galleries with download limits
|
||||
- 🏢 **Corporate Events** - Internal photo sharing with branding
|
||||
- 🎓 **School Photography** - Secure parent access with expiration
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
|
||||
- **Backend**: Node.js, Express, SQLite/PostgreSQL
|
||||
- **Frontend**: React, Tailwind CSS, Framer Motion
|
||||
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](#storage-backends)
|
||||
- **Email**: SMTP with customizable templates
|
||||
- **Analytics**: Privacy-focused with Umami integration
|
||||
|
||||
## 💾 Storage Backends
|
||||
|
||||
PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.
|
||||
|
||||
| Capability | `STORAGE_BACKEND=local` (default) | `STORAGE_BACKEND=s3` |
|
||||
|---|---|---|
|
||||
| Photo / thumbnail / hero storage | Local filesystem under `STORAGE_PATH` | Bucket on any S3-compatible service |
|
||||
| Admin UI upload | ✅ | ✅ |
|
||||
| Filesystem auto-import (chokidar watcher) | ✅ | ❌ — disabled (use the upload API) |
|
||||
| Watermarks, fingerprinting, fragmentation | ✅ | ✅ (materialized to a tmp file just-in-time) |
|
||||
| Bulk download zips (cached + on-the-fly) | ✅ | ✅ |
|
||||
| Backups | ✅ | ✅ |
|
||||
| External media reference mode (`EXTERNAL_MEDIA_ROOT`) | ✅ (always local) | ✅ (still local — not migrated) |
|
||||
|
||||
### Switching to an S3-compatible backend
|
||||
|
||||
1. Provision a bucket and credentials. The minimum IAM policy is documented in `.env.example`.
|
||||
2. Set `STORAGE_BACKEND=s3` plus `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_ACCESS_KEY`, `STORAGE_S3_SECRET_KEY`. For non-AWS providers (MinIO, R2, B2, …) also set `STORAGE_S3_ENDPOINT`.
|
||||
3. If you have existing local content, copy it first: `node backend/scripts/migrate-storage.js --dry-run` then `node backend/scripts/migrate-storage.js`. The script is idempotent and writes a failures CSV.
|
||||
4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.
|
||||
|
||||
Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally **not** in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.
|
||||
|
||||
## 🔔 Webhooks
|
||||
|
||||
PicPeak POSTs event/photo lifecycle notifications to URLs you configure under **Settings → Webhooks**. Each delivery is signed `HMAC-SHA256` with a per-webhook secret in the `X-PicPeak-Signature` header so receivers can verify the request really came from your PicPeak instance.
|
||||
|
||||
### Event types
|
||||
|
||||
| Event | Fires when |
|
||||
|---|---|
|
||||
| `event.created` | Gallery created (admin or API) |
|
||||
| `event.published` | Draft becomes live (`is_draft: true → false`) — also fires when an event is created with `is_draft=false` |
|
||||
| `event.archived` | Bulk-archive, manual archive, or auto-archive on expiry |
|
||||
| `event.expired` | Expiration checker marks the gallery inactive (fires before `event.archived` in the cascade) |
|
||||
| `photo.uploaded` | Admin upload, API upload, guest upload, or auto-import |
|
||||
| `photo.deleted` | Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from `event.archived` to avoid flooding) |
|
||||
|
||||
### Payload shape
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "delivery-uuid",
|
||||
"type": "event.published",
|
||||
"created_at": "2026-04-28T05:25:00.000Z",
|
||||
"data": {
|
||||
"event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also sent on every request:
|
||||
- `X-PicPeak-Signature` — `HMAC-SHA256(secret, raw_body)` as hex
|
||||
- `X-PicPeak-Event` — the event type (handy for routing without parsing the body)
|
||||
- `X-PicPeak-Delivery` — UUID for idempotency on the receiver side
|
||||
- `User-Agent: PicPeak-Webhooks/1.0`
|
||||
|
||||
### Verifying signatures
|
||||
|
||||
**Node.js**
|
||||
```js
|
||||
const crypto = require('crypto');
|
||||
function verify(secret, rawBody, signature) {
|
||||
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
const a = Buffer.from(expected, 'hex');
|
||||
const b = Buffer.from(signature, 'hex');
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
```
|
||||
|
||||
**Python**
|
||||
```python
|
||||
import hmac, hashlib
|
||||
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
|
||||
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
```
|
||||
|
||||
**curl + openssl** (one-liner for a quick replay)
|
||||
```sh
|
||||
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
|
||||
[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH
|
||||
```
|
||||
|
||||
### Retries + observability
|
||||
|
||||
- `2xx` → success, recorded with latency
|
||||
- Non-`2xx` or network error → exponential backoff: `1m → 5m → 30m → 2h → 12h`, max 5 attempts
|
||||
- After max attempts: status `failed`, surfaces in **Settings → Webhooks → Deliveries** with a "Replay" button
|
||||
- Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via `WEBHOOK_DELIVERY_CONCURRENCY`)
|
||||
- Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log
|
||||
|
||||
The deliveries page (`/admin/webhooks/:id/deliveries`) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.
|
||||
|
||||
### SSRF protection
|
||||
|
||||
Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, `.local`/`.internal` hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).
|
||||
|
||||
For local development with a receiver on the same machine or docker network, set `WEBHOOK_ALLOW_PRIVATE_URLS=true`. Production deployments must leave this OFF.
|
||||
|
||||
## 💻 System Requirements
|
||||
|
||||
### Minimum Requirements
|
||||
- **CPU**: 2 CPU cores
|
||||
- **RAM**: 2GB minimum
|
||||
- **Storage**: 20GB minimum (plus photo storage needs)
|
||||
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
|
||||
- **Node.js**: v18.0.0 or higher
|
||||
- **Database**: SQLite (included) or PostgreSQL 12+
|
||||
|
||||
### Docker Requirements (Recommended)
|
||||
- **Docker**: v20.10.0+
|
||||
- **Docker Compose**: v2.0.0+
|
||||
|
||||
### Video Support Requirements
|
||||
When enabling video uploads, consider these additional resources:
|
||||
|
||||
| Resource | Recommendation | Notes |
|
||||
|----------|----------------|-------|
|
||||
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
|
||||
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
|
||||
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
|
||||
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
|
||||
|
||||
**Technical Notes:**
|
||||
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
|
||||
- Maximum upload size: **10GB per video file**
|
||||
- Chunked upload support for files >100MB (resumable uploads)
|
||||
- Supported formats: MP4, WebM, MOV, AVI
|
||||
- Video thumbnails are automatically generated from the first few seconds
|
||||
|
||||
**For Nginx/Reverse Proxy:**
|
||||
If using Nginx, increase the client max body size:
|
||||
```nginx
|
||||
client_max_body_size 10G;
|
||||
proxy_read_timeout 3600;
|
||||
proxy_send_timeout 3600;
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
||||
|
||||
See our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
## 📊 Comparison with Alternatives
|
||||
|
||||
| Feature | PicPeak | PicDrop | Scrapbook.de |
|
||||
|---------|---------|---------|--------------|
|
||||
| Self-Hosted | ✅ | ❌ | ❌ |
|
||||
| Custom Branding | ✅ Full | Limited | Limited |
|
||||
| Monthly Cost | $0 | $29-199 | €19-99 |
|
||||
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
|
||||
| Client Uploads | ✅ | ✅ | ✅ |
|
||||
| API Access | ✅ | Paid | ❌ |
|
||||
| Open Source | ✅ | ❌ | ❌ |
|
||||
|
||||
*Limited only by your server storage
|
||||
|
||||
## 🛡️ Security
|
||||
|
||||
PicPeak takes security seriously:
|
||||
- 🔐 Password hashing with bcrypt
|
||||
- 🎫 JWT-based authentication
|
||||
- 🚦 Rate limiting on all endpoints
|
||||
- 🛡️ CORS protection
|
||||
- 📝 Activity logging
|
||||
- 🔒 Secure file access
|
||||
|
||||
Found a security issue? Please open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
### 🎛️ **Admin Dashboard**
|
||||
Get a complete overview of your photo galleries, analytics, and system status.
|
||||
|
||||
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
|
||||
|
||||
### 📊 **Analytics & Insights**
|
||||
Track gallery performance, view statistics, and monitor user engagement.
|
||||
|
||||
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
|
||||
|
||||
### 📁 **Event Management**
|
||||
Organize and manage your photo galleries with intuitive event management tools.
|
||||
|
||||
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
|
||||
|
||||
### ✨ **Key Interface Highlights**
|
||||
|
||||
<details>
|
||||
<summary>👆 Click to see more interface details</summary>
|
||||
|
||||
#### What makes PicPeak's interface special:
|
||||
|
||||
- **🎨 Clean Design**: Modern, photographer-friendly interface
|
||||
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
|
||||
- **⚡ Fast Loading**: Optimized for quick photo browsing
|
||||
- **🔒 Secure Access**: Password-protected galleries with expiration
|
||||
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
|
||||
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
|
||||
|
||||
</details>
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
|
||||
|
||||
### 🚧 Beta Features (Use at your own risk)
|
||||
|
||||
These features are currently in beta testing and may have limited functionality or stability:
|
||||
|
||||
| Feature | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
|
||||
|
||||
### 📋 Future Enhancements
|
||||
|
||||
| Feature | Description | Priority | Status |
|
||||
|---------|-------------|----------|---------|
|
||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||
| **External Media Library (Reference Mode)** | Use an external folder library as a read‑only source with import and on‑demand thumbnail generation | High | ✅ Implemented |
|
||||
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
|
||||
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
|
||||
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
|
||||
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
|
||||
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
|
||||
|
||||
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
||||
|
||||
## ☕ Support the Project
|
||||
|
||||
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running.
|
||||
|
||||
<p align="left">
|
||||
<a href="https://buymeacoffee.com/theluap" target="_blank">
|
||||
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
|
||||
|
||||
### 👥 Contributors
|
||||
|
||||
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
|
||||
|
||||
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, and several typed-API refactors. Consistently raises the bar with thoughtful PRs.
|
||||
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
|
||||
|
||||
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
|
||||
|
||||
### 🤖 AI-Assisted Development
|
||||
|
||||
This project was generated with the assistance of AI technology, but has been:
|
||||
- ✅ **Fully tested end-to-end** by human developers
|
||||
- 🔒 **Security audited** with comprehensive security checks
|
||||
- 👨💻 **Human-reviewed** for code quality and best practices
|
||||
- 🧪 **Production-tested** in real-world scenarios
|
||||
|
||||
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
|
||||
|
||||
## 📄 License
|
||||
|
||||
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
|
||||
|
||||
## 🚀 Ready to Get Started?
|
||||
|
||||
1. ⭐ **Star this repository** to show your support
|
||||
2. 📖 Read the [docs at docs.picpeak.app](https://docs.picpeak.app)
|
||||
3. 🐛 Report issues or request features
|
||||
4. 🤝 Join our community and contribute!
|
||||
MIT — use it for personal or commercial projects.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ by photographers, for photographers
|
||||
<br>
|
||||
<a href="https://www.picpeak.app">Homepage</a> •
|
||||
<a href="https://demo.picpeak.app">Live Demo</a> •
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="https://docs.picpeak.app">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
<a href="https://www.picpeak.app">Homepage</a> · <a href="https://demo.picpeak.app">Live Demo</a> · <a href="DEPLOYMENT_GUIDE.md">Docs</a> · <a href="https://github.com/the-luap/picpeak/issues">Issues</a>
|
||||
</p>
|
||||
|
||||
+9
-4
@@ -35,12 +35,15 @@ RUN apk upgrade --no-cache
|
||||
RUN npm install -g npm@10
|
||||
|
||||
# Install dumb-init for proper signal handling, postgresql-client for database
|
||||
# checks, and ffmpeg for video upload support. Alpine's ffmpeg package ships
|
||||
# both `ffmpeg` and `ffprobe` built natively against musl libc — the npm
|
||||
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
|
||||
# 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
|
||||
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
|
||||
# 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
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
@@ -56,7 +59,9 @@ 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 && \
|
||||
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
|
||||
|
||||
|
||||
@@ -137,6 +137,41 @@ describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
|
||||
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 () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
|
||||
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: 'admin@example.com',
|
||||
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: 'second@example.com',
|
||||
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: 'fresh@example.com',
|
||||
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: 'invitee@example.com',
|
||||
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.
@@ -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 {
|
||||
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_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) {
|
||||
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 {
|
||||
await db.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
|
||||
ON backup_runs(created_at DESC)
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
|
||||
ON backup_runs(started_at DESC)
|
||||
WHERE status = 'completed' AND backup_mode = 'full';
|
||||
`);
|
||||
await db.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
|
||||
ON backup_runs(parent_backup_id, created_at)
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
|
||||
ON backup_runs(parent_backup_id, started_at)
|
||||
WHERE backup_mode = 'incremental';
|
||||
`);
|
||||
} 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 {
|
||||
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_started_mode');
|
||||
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_created_mode');
|
||||
} catch (error) {
|
||||
// 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,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
|
||||
* ~200–500 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.
|
||||
};
|
||||
@@ -38,7 +38,48 @@ async function markMigrationAsApplied(filename) {
|
||||
// Detect existing schema and mark migrations as applied
|
||||
async function detectExistingSchema() {
|
||||
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/008–028 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 = [
|
||||
{ table: 'events', migration: '001_init.js' },
|
||||
{ table: 'photos', migration: '001_init.js' },
|
||||
@@ -49,7 +90,7 @@ async function detectExistingSchema() {
|
||||
{ table: 'backup_runs', migration: '029_add_backup_service_tables.js' },
|
||||
{ table: 'gallery_feedback', migration: '033_add_gallery_feedback.js' },
|
||||
];
|
||||
|
||||
|
||||
for (const check of tableChecks) {
|
||||
const exists = await db.schema.hasTable(check.table);
|
||||
if (exists) {
|
||||
@@ -126,18 +167,26 @@ async function runMigrations() {
|
||||
const hasActivityLogsTable = await db.schema.hasTable('activity_logs');
|
||||
|
||||
// Get applied migrations
|
||||
const appliedMigrations = await db('migrations').select('filename');
|
||||
const appliedFilenames = appliedMigrations.map(m => m.filename);
|
||||
|
||||
let appliedMigrations = await db('migrations').select('filename');
|
||||
let appliedFilenames = appliedMigrations.map(m => m.filename);
|
||||
|
||||
// Check if this is a new deployment
|
||||
// It's new if no essential tables exist OR no migrations have been applied
|
||||
const hasEssentialTables = hasEventsTable && hasPhotosTable && hasAdminTable && hasActivityLogsTable;
|
||||
const isDatabaseEmpty = !hasEventsTable && !hasPhotosTable && !hasAdminTable && !hasActivityLogsTable;
|
||||
const isNewDeployment = isDatabaseEmpty || (appliedFilenames.length === 0 && !hasEssentialTables);
|
||||
|
||||
|
||||
// Only detect existing schema for truly existing deployments
|
||||
if (!isNewDeployment) {
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.49.1-beta.0",
|
||||
"version": "3.44.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
+10
-1
@@ -161,7 +161,12 @@ const corsOptions = {
|
||||
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
|
||||
@@ -182,6 +187,10 @@ function composeInlineStyles(payload) {
|
||||
--brand-accent: ${branding.colors.accent};
|
||||
--brand-background: ${branding.colors.background};
|
||||
--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) {
|
||||
|
||||
@@ -35,6 +35,7 @@ const { getStorage } = require('../services/storage');
|
||||
const {
|
||||
buildOgMetadata,
|
||||
handleGalleryOgCover,
|
||||
isSocialCrawler,
|
||||
} = require('../services/galleryOgService');
|
||||
|
||||
// The service hits two tables in sequence:
|
||||
@@ -237,3 +238,52 @@ describe('handleGalleryOgCover — 404 unless explicitly opted in', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,4 +159,33 @@ describe('publicSiteService', () => {
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -165,7 +165,7 @@ const DEFAULT_PUBLIC_SITE_CSS = `
|
||||
body {
|
||||
margin: 0;
|
||||
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);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@@ -184,16 +184,16 @@ img {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
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 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
background: var(--brand-surface, rgba(255, 255, 255, 0.92));
|
||||
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 {
|
||||
@@ -238,14 +238,14 @@ img {
|
||||
.brand-tagline {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
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 {
|
||||
@@ -334,7 +334,7 @@ img {
|
||||
.hero__lead {
|
||||
margin: 0;
|
||||
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;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -360,7 +360,7 @@ img {
|
||||
|
||||
.hero__stats dd {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -372,9 +372,9 @@ img {
|
||||
.deck {
|
||||
border-radius: 20px;
|
||||
padding: 1.75rem;
|
||||
background: #fff;
|
||||
background: var(--brand-surface, #fff);
|
||||
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;
|
||||
gap: 1.35rem;
|
||||
}
|
||||
@@ -384,7 +384,7 @@ img {
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -411,14 +411,14 @@ img {
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
color: rgba(15, 23, 42, 0.68);
|
||||
color: var(--brand-muted-text, rgba(15, 23, 42, 0.68));
|
||||
}
|
||||
|
||||
.deck__quote {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(15, 23, 42, 0.78);
|
||||
color: var(--brand-text, rgba(15, 23, 42, 0.78));
|
||||
}
|
||||
|
||||
.deck__author {
|
||||
@@ -458,7 +458,7 @@ img {
|
||||
|
||||
.section-head p {
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
@@ -469,10 +469,10 @@ img {
|
||||
}
|
||||
|
||||
.feature-grid article {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: var(--brand-surface, rgba(255, 255, 255, 0.9));
|
||||
border-radius: 16px;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -504,17 +504,17 @@ img {
|
||||
|
||||
.workflow__steps p {
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
|
||||
}
|
||||
|
||||
.workflow__browser {
|
||||
margin: 0;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
background: var(--brand-elevated, rgba(15, 23, 42, 0.05));
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -526,9 +526,9 @@ img {
|
||||
}
|
||||
|
||||
.collection-showcase article {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
background: var(--brand-surface, rgba(255, 255, 255, 0.92));
|
||||
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;
|
||||
box-shadow: 0 18px 45px -32px rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
@@ -543,9 +543,9 @@ img {
|
||||
.story-grid figure {
|
||||
margin: 0;
|
||||
padding: 1.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
background: var(--brand-surface, rgba(255, 255, 255, 0.95));
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -553,12 +553,12 @@ img {
|
||||
margin: 0 0 1.2rem;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(15, 23, 42, 0.8);
|
||||
color: var(--brand-text, rgba(15, 23, 42, 0.8));
|
||||
}
|
||||
|
||||
.story-grid figcaption {
|
||||
font-weight: 600;
|
||||
color: rgba(15, 23, 42, 0.7);
|
||||
color: var(--brand-muted-text, rgba(15, 23, 42, 0.7));
|
||||
}
|
||||
|
||||
.cta {
|
||||
@@ -625,8 +625,8 @@ img {
|
||||
|
||||
.site-footer {
|
||||
padding: 3rem 1.5rem;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
background: var(--brand-elevated, rgba(15, 23, 42, 0.05));
|
||||
border-top: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
@@ -644,7 +644,7 @@ img {
|
||||
|
||||
.footer-inner p {
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,16 @@ async function initializeDatabase() {
|
||||
table.boolean('disable_right_click').defaultTo(false);
|
||||
table.boolean('watermark_downloads').defaultTo(false);
|
||||
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);
|
||||
});
|
||||
} else {
|
||||
@@ -213,6 +222,25 @@ async function initializeDatabase() {
|
||||
table.integer('view_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
|
||||
|
||||
@@ -3,6 +3,7 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { slugify } = require('../utils/slug');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const archiver = require('archiver');
|
||||
@@ -217,7 +218,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
const insertResult = await db('photo_categories').insert({
|
||||
event_id: archive.id,
|
||||
name: categoryName,
|
||||
slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'),
|
||||
slug: slugify(categoryName),
|
||||
created_at: new Date()
|
||||
}).returning('id');
|
||||
|
||||
|
||||
@@ -56,7 +56,9 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
const { name, slug, is_global = true, event_id = null } = req.body;
|
||||
|
||||
// Generate slug if not provided
|
||||
const categorySlug = slug || name.toLowerCase()
|
||||
const categorySlug = slug || name
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
@@ -128,7 +130,9 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
|
||||
const updateData = {
|
||||
name,
|
||||
slug: name.toLowerCase()
|
||||
slug: name
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { slugify } = require('../utils/slug');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const router = express.Router();
|
||||
@@ -439,7 +440,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
allow_presigned_download = false,
|
||||
require_password: requirePasswordInput,
|
||||
// Feedback settings
|
||||
feedback_enabled = false,
|
||||
feedback_enabled: feedbackEnabledInput,
|
||||
allow_ratings = true,
|
||||
allow_likes = true,
|
||||
allow_comments = true,
|
||||
@@ -507,6 +508,17 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
}
|
||||
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
|
||||
logger.debug('Download control values', {
|
||||
allow_downloads,
|
||||
@@ -538,12 +550,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
const processedEventName = event_name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
|
||||
.replace(/-+/g, '-') // Replace multiple dashes with single dash
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
|
||||
// Generate unique slug. Uses the shared util so accented names
|
||||
// (Família, Decoração, etc.) get transliterated instead of dropped
|
||||
// — see backend/src/utils/slug.js for the why (#525).
|
||||
const processedEventName = slugify(event_name);
|
||||
|
||||
// Use event_date in slug if provided, otherwise use random suffix
|
||||
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
|
||||
|
||||
@@ -7,7 +7,11 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { ensureThumbnail } = require('../services/imageProcessor');
|
||||
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 { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
|
||||
@@ -222,15 +226,28 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
let photoType = 'individual'; // default
|
||||
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)) {
|
||||
const category = await db('photo_categories').where({ id: parsedCategoryId }).first();
|
||||
if (category) {
|
||||
categoryName = category.slug || category.name.toLowerCase().replace(/\s+/g, '_');
|
||||
// Use category slug for type determination
|
||||
if (category.slug === 'collage' || category.slug === 'collages') {
|
||||
photoType = 'collage';
|
||||
}
|
||||
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}`
|
||||
});
|
||||
}
|
||||
categoryName = category.slug || category.name.toLowerCase().replace(/\s+/g, '_');
|
||||
// Use category slug for type determination
|
||||
if (category.slug === 'collage' || category.slug === 'collages') {
|
||||
photoType = 'collage';
|
||||
}
|
||||
} else if (category_id === 'collage') {
|
||||
// For backwards compatibility, accept string values
|
||||
@@ -649,6 +666,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
if (photo.hero_path) {
|
||||
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
|
||||
if (photo.watermark_path) {
|
||||
@@ -783,6 +806,10 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
|
||||
if (photo.hero_path) {
|
||||
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) {
|
||||
await watermarkGeneratorService.deleteForPhoto(photo.id);
|
||||
}
|
||||
@@ -901,6 +928,11 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
||||
const storage = getStorage();
|
||||
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) {
|
||||
const stat = await storage.stat(storageKey);
|
||||
if (!stat) {
|
||||
@@ -909,7 +941,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'application/octet-stream',
|
||||
'Content-Length': stat.size,
|
||||
'Content-Disposition': `attachment; filename="${photo.filename}"`,
|
||||
'Content-Disposition': contentDisposition,
|
||||
});
|
||||
const stream = await storage.get(storageKey);
|
||||
stream.pipe(res);
|
||||
@@ -923,7 +955,11 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
||||
} catch (error) {
|
||||
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) {
|
||||
console.error('Error downloading photo:', error);
|
||||
res.status(500).json({ error: 'Failed to download photo' });
|
||||
|
||||
@@ -322,7 +322,8 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
twitter_url,
|
||||
youtube_url,
|
||||
promo_markdown,
|
||||
promo_position
|
||||
promo_position,
|
||||
promo_alignment
|
||||
} = req.body;
|
||||
|
||||
// Normalize force_color_mode: only 'dark' | 'light' | null are valid.
|
||||
@@ -340,6 +341,15 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
? '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'];
|
||||
@@ -381,7 +391,8 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
...(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_position !== undefined && { promo_position: normalizedPromoPosition }),
|
||||
...(promo_alignment !== undefined && { promo_alignment: normalizedPromoAlignment })
|
||||
};
|
||||
|
||||
// Handle favicon deletion if empty string or null is provided
|
||||
@@ -788,6 +799,19 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
|
||||
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
|
||||
await db('activity_logs').insert({
|
||||
|
||||
@@ -3,7 +3,7 @@ const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { generateThumbnail } = require('../services/imageProcessor');
|
||||
const { generateThumbnail, ensurePreviewImage } = require('../services/imageProcessor');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
@@ -25,7 +25,9 @@ router.get('/settings', adminAuth, requirePermission('photos.view'), async (req,
|
||||
'thumbnail_height',
|
||||
'thumbnail_fit',
|
||||
'thumbnail_quality',
|
||||
'thumbnail_format'
|
||||
'thumbnail_format',
|
||||
// Lightbox preview tier (#492). Boolean, default false.
|
||||
'lightbox_preview_enabled'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
@@ -51,7 +53,7 @@ router.get('/settings', adminAuth, requirePermission('photos.view'), async (req,
|
||||
// Update thumbnail settings
|
||||
router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
const { width, height, fit, quality, format } = req.body;
|
||||
const { width, height, fit, quality, format, lightbox_preview_enabled } = req.body;
|
||||
|
||||
// Validate inputs
|
||||
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 (quality) updates.push({ setting_key: 'thumbnail_quality', setting_value: quality });
|
||||
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) {
|
||||
await db('app_settings')
|
||||
const updated = await db('app_settings')
|
||||
.where('setting_key', update.setting_key)
|
||||
.update({
|
||||
setting_value: update.setting_value,
|
||||
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({
|
||||
@@ -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
|
||||
router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,37 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
|
||||
const userManagementService = require('../services/userManagementService');
|
||||
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)
|
||||
*/
|
||||
@@ -20,10 +51,10 @@ function transformUser(user) {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isActive: user.is_active,
|
||||
lastLogin: user.last_login,
|
||||
lastLogin: toIso(user.last_login),
|
||||
lastLoginIp: user.last_login_ip,
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at,
|
||||
createdAt: toIso(user.created_at),
|
||||
updatedAt: toIso(user.updated_at),
|
||||
roleId: user.role_id,
|
||||
roleName: user.role_name,
|
||||
roleDisplayName: user.role_display_name,
|
||||
@@ -52,8 +83,8 @@ function transformInvitation(invitation) {
|
||||
return {
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
expiresAt: invitation.expires_at,
|
||||
createdAt: invitation.created_at,
|
||||
expiresAt: toIso(invitation.expires_at),
|
||||
createdAt: toIso(invitation.created_at),
|
||||
roleName: invitation.role_name,
|
||||
invitedBy: invitation.invited_by
|
||||
};
|
||||
@@ -212,4 +243,7 @@ router.post('/:id/reset-password', [
|
||||
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.__test = { toIso, transformUser, transformInvitation };
|
||||
|
||||
@@ -4,6 +4,7 @@ const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { slugify } = require('../utils/slug');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const fs = require('fs').promises;
|
||||
@@ -125,8 +126,8 @@ router.post('/', adminAuth, [
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
||||
// Generate unique slug — slugify() handles accents (see #525).
|
||||
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
|
||||
@@ -13,8 +13,14 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
const { ensureThumbnail, ensureHeroImage, withLocalCopy } = require('../services/imageProcessor');
|
||||
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
|
||||
const downloadZipService = require('../services/downloadZipService');
|
||||
const {
|
||||
getUseOriginalFilenames,
|
||||
pickRawDownloadName,
|
||||
getZipEntryNames,
|
||||
} = require('../services/downloadFilenameService');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const { getStorage } = require('../services/storage');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -399,6 +405,38 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
fragmentation_level: req.event.fragmentation_level || 3,
|
||||
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({
|
||||
@@ -427,6 +465,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
hero_image_anchor: req.event.hero_image_anchor || 'center',
|
||||
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),
|
||||
// Mirror of the admin-side toggle so the lightbox can decide
|
||||
// whether to surface original camera filenames (#508).
|
||||
use_original_filenames: useOriginalFilenames,
|
||||
...protectionSettings
|
||||
},
|
||||
categories: categories,
|
||||
@@ -441,10 +482,24 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
return {
|
||||
id: photo.id,
|
||||
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,
|
||||
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_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}}`,
|
||||
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
|
||||
type: photo.type,
|
||||
@@ -596,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 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) {
|
||||
// Apply watermark and send
|
||||
// Use event watermark text if available, otherwise fall back to global settings
|
||||
@@ -608,14 +670,21 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Disposition': `attachment; filename="${photo.filename}"`,
|
||||
'Content-Disposition': contentDisposition,
|
||||
'Content-Length': watermarkedBuffer.length
|
||||
});
|
||||
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send original file
|
||||
res.download(filePath, photo.filename, (downloadError) => {
|
||||
// res.download() builds Content-Disposition itself but doesn't emit the
|
||||
// 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) {
|
||||
logger.error('Error streaming gallery download', {
|
||||
slug: req.params.slug,
|
||||
@@ -737,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.
|
||||
const { resolvePhotoStorageKey } = require('../services/photoResolver');
|
||||
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 entryName = bulkEntryNames[i];
|
||||
let archiveName;
|
||||
if (hasMultipleTypes) {
|
||||
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
||||
archiveName = path.join(folderName, photo.filename);
|
||||
archiveName = path.join(folderName, entryName);
|
||||
} else {
|
||||
archiveName = photo.filename;
|
||||
archiveName = entryName;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -865,8 +940,12 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
||||
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../services/photoResolver');
|
||||
const { withLocalCopy: withSelectedLocalCopy } = require('../services/imageProcessor');
|
||||
const selectedStorage = getStorage();
|
||||
for (const photo of photos) {
|
||||
const name = photo.filename || `photo-${photo.id}.jpg`;
|
||||
// #493: same display-name resolution as bulk download, with dedup.
|
||||
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);
|
||||
try {
|
||||
if (shouldApplyWatermark && effectiveSettings) {
|
||||
@@ -1337,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
|
||||
router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,7 @@ router.get('/', async (req, res) => {
|
||||
.orWhereIn('setting_key', [
|
||||
'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai',
|
||||
'event_default_require_password',
|
||||
'event_default_feedback_enabled',
|
||||
'gallery_show_filter_bar',
|
||||
'event_phone_field_enabled'
|
||||
]);
|
||||
@@ -77,6 +78,12 @@ router.get('/', async (req, res) => {
|
||||
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
|
||||
// is hidden and the value overrides per-theme/system preference.
|
||||
// Allowed values: 'dark' | 'light' | null (null = no force).
|
||||
@@ -114,6 +121,10 @@ router.get('/', async (req, res) => {
|
||||
event_require_expiration: settingsObject.event_require_expiration !== false,
|
||||
// Default value for "Require password" toggle in event creation form
|
||||
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).
|
||||
event_phone_field_enabled: settingsObject.event_phone_field_enabled === true,
|
||||
// Whether to show the search/sort filter bar in public galleries (default: true)
|
||||
|
||||
@@ -8,6 +8,11 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
|
||||
const { withLocalCopy } = require('../services/imageProcessor');
|
||||
const { getStorage } = require('../services/storage');
|
||||
const {
|
||||
getUseOriginalFilenames,
|
||||
pickRawDownloadName,
|
||||
} = require('../services/downloadFilenameService');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -339,9 +344,16 @@ router.get('/:slug/secure-download/:photoId/:token',
|
||||
'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({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Disposition': `attachment; filename="${photo.filename}"`,
|
||||
'Content-Disposition': buildContentDisposition(downloadName),
|
||||
'Content-Length': fileBuffer.length,
|
||||
'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);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,9 @@ const { apiTokenAuth, requireApiScope } = require('../../middleware/apiTokenAuth
|
||||
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { generateThumbnail } = require('../../services/imageProcessor');
|
||||
const logger = require('../../utils/logger');
|
||||
const { slugify } = require('../../utils/slug');
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -51,8 +54,8 @@ const photoUpload = multer({
|
||||
}
|
||||
});
|
||||
|
||||
const slugify = (s) =>
|
||||
String(s).toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
// slugify now imported from ../../utils/slug — shared with adminEvents
|
||||
// and events.js so the diacritic fix from #502 lands here too (#525).
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// POST /events — create event
|
||||
@@ -86,6 +89,8 @@ const slugify = (s) =>
|
||||
* require_password: { type: boolean, default: true }
|
||||
* password: { type: string, nullable: true, description: "Required when require_password is 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:
|
||||
* 201:
|
||||
* description: Event created
|
||||
@@ -116,7 +121,9 @@ router.post(
|
||||
body('admin_email').optional({ nullable: true, checkFalsy: true }).isEmail(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
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) => {
|
||||
try {
|
||||
@@ -126,9 +133,28 @@ router.post(
|
||||
event_name, event_type, event_date,
|
||||
customer_name = null, customer_email = null, customer_phone = null,
|
||||
admin_email = null, require_password = true, password,
|
||||
expires_at = null
|
||||
expires_at = null,
|
||||
color_theme = null,
|
||||
feedback_enabled: feedbackEnabledInput
|
||||
} = 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)) {
|
||||
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_by: req.admin.id,
|
||||
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_email ? { customer_email } : {}),
|
||||
...(persistPhone ? { customer_phone: persistPhone } : {})
|
||||
}).returning('id');
|
||||
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, {
|
||||
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]
|
||||
* properties:
|
||||
* 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:
|
||||
* 201:
|
||||
* description: Photo uploaded
|
||||
@@ -351,6 +408,7 @@ router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res
|
||||
* path: { type: string }
|
||||
* thumbnail_path: { type: string, nullable: true }
|
||||
* size_bytes: { type: integer }
|
||||
* category_id: { type: integer, nullable: true }
|
||||
* 400: { description: No file or invalid type }
|
||||
* 404: { description: Event not found }
|
||||
*/
|
||||
@@ -368,6 +426,38 @@ router.post(
|
||||
const event = await db('events').where({ id: req.params.id }).first();
|
||||
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 finalName = `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`;
|
||||
// photo.path is stored relative to events/active so resolvePhotoStorageKey
|
||||
@@ -408,7 +498,8 @@ router.post(
|
||||
original_filename: req.file.originalname,
|
||||
path: relPath,
|
||||
thumbnail_path: thumbRel,
|
||||
type: 'individual',
|
||||
type: photoType,
|
||||
category_id: categoryId,
|
||||
size_bytes: stat.size,
|
||||
width,
|
||||
height,
|
||||
@@ -432,7 +523,14 @@ router.post(
|
||||
});
|
||||
} 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) {
|
||||
logger.error('v1 POST /events/:id/photos failed', { error: error.message });
|
||||
if (tempPath) await fs.unlink(tempPath).catch(() => {});
|
||||
|
||||
@@ -9,6 +9,12 @@ const { queueEmail, getSupportEmail } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const feedbackService = require('./feedbackService');
|
||||
const { getStorage } = require('./storage');
|
||||
const { resolvePhotoStorageKey } = require('./photoResolver');
|
||||
const { getUseOriginalFilenames } = require('./downloadFilenameService');
|
||||
const {
|
||||
sanitizeForZipEntry,
|
||||
uniquifyZipNames,
|
||||
} = require('../utils/filenameSanitizer');
|
||||
|
||||
async function archiveEvent(event) {
|
||||
const storage = getStorage();
|
||||
@@ -54,6 +60,40 @@ async function archiveEvent(event) {
|
||||
// the zip directly from the storage backend.
|
||||
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;
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(tmpArchive);
|
||||
@@ -67,10 +107,9 @@ async function archiveEvent(event) {
|
||||
archive.pipe(output);
|
||||
|
||||
const append = async () => {
|
||||
for (const entry of photoEntries) {
|
||||
const nameInZip = entry.key.startsWith(`${eventPrefix}/`)
|
||||
? entry.key.slice(eventPrefix.length + 1)
|
||||
: entry.key;
|
||||
for (let i = 0; i < photoEntries.length; i += 1) {
|
||||
const entry = photoEntries[i];
|
||||
const nameInZip = dedupedNames[i];
|
||||
const stream = await storage.get(entry.key);
|
||||
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);
|
||||
for (const photo of photos) {
|
||||
if (photo.thumbnail_path) {
|
||||
@@ -138,6 +180,11 @@ async function archiveEvent(event) {
|
||||
if (photo.hero_path) {
|
||||
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.
|
||||
if (photo.watermark_path) {
|
||||
await storage.delete(photo.watermark_path).catch(() => {});
|
||||
|
||||
@@ -361,6 +361,16 @@ async function getFilesToBackupInternal(includeArchived = true) {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return files;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -23,6 +23,7 @@ const { db } = require('../database/db');
|
||||
const watermarkService = require('./watermarkService');
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||
const { getStorage } = require('./storage');
|
||||
const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const DEBOUNCE_MS = 5000;
|
||||
@@ -134,6 +135,11 @@ class DownloadZipService {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-zipbuild-'));
|
||||
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
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(tmpPath);
|
||||
@@ -147,19 +153,21 @@ class DownloadZipService {
|
||||
const hasMultipleTypes = uniqueTypes > 1;
|
||||
|
||||
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
|
||||
if (this.versions.get(eventId) !== version) {
|
||||
archive.abort();
|
||||
return reject(new Error('Build invalidated'));
|
||||
}
|
||||
|
||||
const entryName = entryNames[i];
|
||||
let archiveName;
|
||||
if (hasMultipleTypes) {
|
||||
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
||||
archiveName = path.join(folderName, photo.filename);
|
||||
archiveName = path.join(folderName, entryName);
|
||||
} else {
|
||||
archiveName = photo.filename;
|
||||
archiveName = entryName;
|
||||
}
|
||||
|
||||
// External-mode photos still live on local disk; managed photos go
|
||||
|
||||
@@ -116,7 +116,9 @@ async function getRecipientLanguage(email, eventId = null) {
|
||||
.where('setting_key', 'general_default_language')
|
||||
.first();
|
||||
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) {
|
||||
logger.error('Error fetching app settings language:', error);
|
||||
@@ -140,6 +142,7 @@ async function getRecipientLanguage(email, eventId = null) {
|
||||
{ domains: ['.nl', '.be'], language: 'nl' },
|
||||
{ domains: ['.br', '.pt'], language: 'pt' },
|
||||
{ domains: ['.ru', '.su'], language: 'ru' },
|
||||
{ domains: ['.es'], language: 'es' },
|
||||
];
|
||||
for (const { domains, language: lang } of domainLanguageMap) {
|
||||
if (domains.some(d => domain.endsWith(d))) {
|
||||
@@ -497,6 +500,7 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
nl: '(Om veiligheidsredenen niet weergegeven)',
|
||||
pt: '(Não exibido por motivos de segurança)',
|
||||
ru: '(Не показано в целях безопасности)',
|
||||
es: '(No se muestra por razones de seguridad)',
|
||||
};
|
||||
const noPasswordI18n = {
|
||||
en: 'No password required',
|
||||
@@ -504,6 +508,7 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
nl: 'Geen wachtwoord vereist',
|
||||
pt: 'Nenhuma senha necessária',
|
||||
ru: 'Пароль не требуется',
|
||||
es: 'No se requiere contraseña',
|
||||
};
|
||||
// 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
|
||||
@@ -515,6 +520,7 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
nl: 'Het wachtwoord dat u bij het aanmaken van de galerij hebt ingesteld',
|
||||
pt: 'A senha definida ao criar a galeria',
|
||||
ru: 'Пароль, заданный при создании галереи',
|
||||
es: 'La contraseña que estableciste al crear la galería',
|
||||
};
|
||||
|
||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||
|
||||
@@ -7,7 +7,12 @@ const SOCIAL_CRAWLER_PATTERNS = [
|
||||
/facebookexternalhit/i,
|
||||
/facebot/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,
|
||||
/WhatsAppBot/i,
|
||||
/wa-bot/i,
|
||||
/Slackbot/i,
|
||||
/TelegramBot/i,
|
||||
/SkypeUriPreview/i,
|
||||
@@ -24,7 +29,12 @@ const SOCIAL_CRAWLER_PATTERNS = [
|
||||
/Mastodon/i,
|
||||
/Bluesky/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) {
|
||||
|
||||
@@ -30,6 +30,15 @@ const DEFAULT_HERO_WIDTH = 1920;
|
||||
const DEFAULT_HERO_HEIGHT = 1080;
|
||||
const DEFAULT_HERO_QUALITY = 85;
|
||||
|
||||
// Preview tier (#492). Aspect-preserved downscale for the lightbox so
|
||||
// guests don't pay the full 5–12 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
|
||||
// (~200–500 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)
|
||||
function parseSettingValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -476,6 +485,132 @@ async function ensureHeroImage(photo) {
|
||||
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
|
||||
*/
|
||||
@@ -525,6 +660,9 @@ module.exports = {
|
||||
generateHeroImage,
|
||||
isHeroValid,
|
||||
ensureHeroImage,
|
||||
generatePreviewImage,
|
||||
isPreviewValid,
|
||||
ensurePreviewImage,
|
||||
extractCaptureDate,
|
||||
withLocalCopy,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Sanitize a string to be used as a filename component
|
||||
* @param {string} str - The string to sanitize
|
||||
@@ -51,7 +53,125 @@ function generatePhotoFilename(eventName, categoryName, counter, 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 (0x01–0x1F, 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 = {
|
||||
sanitizeFilename,
|
||||
generatePhotoFilename
|
||||
generatePhotoFilename,
|
||||
sanitizeForContentDisposition,
|
||||
buildContentDisposition,
|
||||
sanitizeForZipEntry,
|
||||
uniquifyZipNames,
|
||||
};
|
||||
@@ -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+0300–U+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 };
|
||||
@@ -3,6 +3,43 @@
|
||||
|
||||
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}"
|
||||
port="${DB_PORT:-5432}"
|
||||
user="${DB_USER:-picpeak}"
|
||||
|
||||
@@ -15,7 +15,13 @@ services:
|
||||
- picpeak-network
|
||||
restart: unless-stopped
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -64,8 +70,13 @@ services:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
# Backend exposes /health on internal port 3000
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
# Backend exposes /health on internal port 3000.
|
||||
# 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
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -77,6 +88,16 @@ services:
|
||||
container_name: picpeak-frontend
|
||||
# Note: Pre-built frontend uses Nginx to proxy /api to backend:3001.
|
||||
# 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:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
networks:
|
||||
|
||||
+9
-5
@@ -31,11 +31,11 @@ services:
|
||||
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
|
||||
- TZ=${TZ:-UTC}
|
||||
- STORAGE_PATH=/app/storage
|
||||
# Optional: run container as matching host user to avoid bind mount permission issues
|
||||
- PUID=${PUID:-1001}
|
||||
- PGID=${PGID:-1001}
|
||||
# Use host-matching user ID/GID so bind-mounted folders are writable
|
||||
user: "${PUID:-1001}:${PGID:-1001}"
|
||||
# No `user:` directive — as of #484, the container starts as root,
|
||||
# chowns the bind mounts to nodejs (UID 1001), then drops privileges
|
||||
# via su-exec. PUID/PGID env vars are no longer read; if you need
|
||||
# a different runtime UID, pre-chown the host dirs and pin
|
||||
# `user: "<uid>:<gid>"` here.
|
||||
volumes:
|
||||
- ./events:/app/events
|
||||
- ./data:/app/data
|
||||
@@ -121,6 +121,10 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
# Static social-preview brand (#521) — substituted into
|
||||
# index.html at container start; see frontend/docker-entrypoint.sh.
|
||||
- BRAND_TITLE=${BRAND_TITLE:-PicPeak}
|
||||
- BRAND_DESCRIPTION=${BRAND_DESCRIPTION:-Photo gallery shared with PicPeak.}
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
depends_on:
|
||||
|
||||
+19
-3
@@ -33,8 +33,11 @@ FROM nginx:1.28-alpine
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache curl
|
||||
# Install runtime dependencies. `gettext` provides envsubst, used by
|
||||
# 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
|
||||
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 --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)
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||
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
|
||||
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;"]
|
||||
|
||||
Executable
+40
@@ -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 "$@"
|
||||
+33
-1
@@ -4,7 +4,39 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>PicPeak - Photo Sharing Platform</title>
|
||||
<!--
|
||||
Static fallback title + Open Graph defaults (#521).
|
||||
|
||||
The runtime SPA updates these once React loads, and social-crawler
|
||||
User-Agents hitting /gallery/<slug> get a per-event rich preview
|
||||
served by backend's galleryOgService instead of this static shell.
|
||||
|
||||
But the third path — link previews fetched by WhatsApp Business
|
||||
API, Twilio, LinkPreview, or any service that caches metadata
|
||||
with a non-crawler UA — gets *this* HTML as-is. Defaulting the
|
||||
title to "PicPeak - Photo Sharing Platform" left every such
|
||||
preview looking unbranded for self-hosted installs.
|
||||
|
||||
${BRAND_TITLE} / ${BRAND_DESCRIPTION} are replaced at *container
|
||||
start* by the frontend image's docker-entrypoint.sh (envsubst on
|
||||
an index.html.tpl snapshot taken at image build). That keeps the
|
||||
tokens working even for self-hosters running the pre-built GHCR
|
||||
image — set BRAND_TITLE in compose env and the next container
|
||||
restart picks it up. No frontend rebuild needed.
|
||||
|
||||
Vite dev server doesn't run the entrypoint, so dev mode shows the
|
||||
literal tokens in the tab title — acceptable since dev sessions
|
||||
don't care about social previews.
|
||||
-->
|
||||
<title>${BRAND_TITLE}</title>
|
||||
<meta name="description" content="${BRAND_DESCRIPTION}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="${BRAND_TITLE}" />
|
||||
<meta property="og:title" content="${BRAND_TITLE}" />
|
||||
<meta property="og:description" content="${BRAND_DESCRIPTION}" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="${BRAND_TITLE}" />
|
||||
<meta name="twitter:description" content="${BRAND_DESCRIPTION}" />
|
||||
|
||||
<!-- Pre-React theme bootstrap (#358).
|
||||
The browser may paint the very first frame before our inline
|
||||
|
||||
+24
-9
@@ -1,3 +1,14 @@
|
||||
# Honour the outer reverse proxy's X-Forwarded-Proto when present (e.g. NPM,
|
||||
# Traefik, Caddy in front of PicPeak). Falls back to nginx's own $scheme when
|
||||
# the header is absent (direct access / no outer proxy). Without this the
|
||||
# inner nginx was always forwarding "http" to the backend because the outer
|
||||
# proxy → inner nginx hop is plain HTTP, breaking Secure cookies and HTTPS
|
||||
# URL generation in the backend. See issue #547.
|
||||
map $http_x_forwarded_proto $real_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
"" $scheme;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
@@ -80,7 +91,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
|
||||
@@ -97,7 +108,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
|
||||
# Cache photos
|
||||
proxy_cache_valid 200 302 1d;
|
||||
@@ -112,7 +123,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
|
||||
# Cache thumbnails
|
||||
proxy_cache_valid 200 302 7d;
|
||||
@@ -128,7 +139,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
|
||||
# Cache uploads
|
||||
proxy_cache_valid 200 302 7d;
|
||||
@@ -146,7 +157,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
|
||||
# Fonts rarely change; cache aggressively (matches backend Cache-Control).
|
||||
proxy_cache_valid 200 302 7d;
|
||||
@@ -161,7 +172,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
}
|
||||
|
||||
# Delegate root requests to backend for public landing page handling
|
||||
@@ -175,7 +186,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
@@ -184,7 +195,11 @@ server {
|
||||
# meta tags never reach them. Route those UAs to backend's /og handler
|
||||
# via internal rewrite; humans fall through to the SPA via try_files.
|
||||
location ~ ^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ {
|
||||
if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|Slackbot|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph)") {
|
||||
# Keep this list in sync with SOCIAL_CRAWLER_PATTERNS in
|
||||
# backend/src/services/galleryOgService.js. WhatsAppBot / wa-bot
|
||||
# and LinkPreview / Slack-ImgProxy added in #521 to catch
|
||||
# business-API preview fetchers that aren't the main WhatsApp app.
|
||||
if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph|LinkPreview)") {
|
||||
rewrite ^ /og/gallery/$gallery_slug last;
|
||||
}
|
||||
try_files $uri $uri/ /index.html;
|
||||
@@ -199,7 +214,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
# Honour outer reverse-proxy's X-Forwarded-Proto when present (see #547 /
|
||||
# frontend/nginx.conf for full rationale).
|
||||
map $http_x_forwarded_proto $real_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
"" $scheme;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
@@ -10,7 +17,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
}
|
||||
|
||||
# Photos proxy to backend
|
||||
@@ -41,7 +48,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.49.1-beta.0",
|
||||
"version": "3.44.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,7 +9,7 @@
|
||||
"build:check": "tsc -b && node ./scripts/build.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run src/components/admin/__tests__/ThemeCustomizerEnhanced.test.tsx",
|
||||
"test": "vitest run",
|
||||
"i18n:extract": "i18next-cli extract",
|
||||
"i18n:status": "i18next-cli status",
|
||||
"i18n:ci": "i18next-cli extract --ci --dry-run"
|
||||
|
||||
@@ -79,14 +79,16 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
);
|
||||
|
||||
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
// Shared filter + per-upload-limit pipeline used by both the file-input
|
||||
// change handler and the drop handler. #504 — without the drop handler
|
||||
// the dashed-border zone looked draggable but silently fell through to
|
||||
// the browser's default "open the file in a new tab" behaviour.
|
||||
const addFiles = (incoming: File[]) => {
|
||||
const imageFiles = incoming.filter((file) => allowedMimeTypes.includes(file.type));
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const imageFiles = files.filter(file =>
|
||||
allowedMimeTypes.includes(file.type)
|
||||
);
|
||||
|
||||
// Check total file count with existing files
|
||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||
if (totalFiles > maxFilesPerUpload) {
|
||||
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||
@@ -101,11 +103,44 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
||||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
|
||||
);
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||
setSelectedFiles((prev) => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles]);
|
||||
|
||||
setSelectedFiles((prev) => [...prev, ...imageFiles]);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
addFiles(Array.from(e.target.files || []));
|
||||
// Reset the input so picking the same files again still fires onChange.
|
||||
if (e.target.value) e.target.value = '';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// dropEffect must be set on every dragover for the cursor to render
|
||||
// the "copy" affordance in Chrome/Firefox.
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
if (!isDragOver) setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// dragleave fires for every child node the cursor passes — only flip
|
||||
// the highlight off when the cursor leaves the zone itself, otherwise
|
||||
// it strobes on/off as the user moves over the icon and text.
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
addFiles(files);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
@@ -128,9 +163,14 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
setUploadProgress(0);
|
||||
setUploadIds([]);
|
||||
|
||||
// For large uploads, chunk the files by both count AND size to prevent memory/network issues
|
||||
// For large uploads, chunk the files by both count AND size to prevent memory/network issues.
|
||||
// #509: the per-chunk byte cap MUST be tunable so users behind Cloudflare Tunnel and other
|
||||
// reverse proxies with request-size limits can drop it below their proxy's cap. Falls back
|
||||
// to 95MB (Cloudflare-safe headroom under 100MB) when the setting is unset — that matches
|
||||
// the value the migration seeds and is what worked in #208's resolution.
|
||||
const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk
|
||||
const MAX_BYTES_PER_CHUNK = 500 * 1024 * 1024; // Max 500MB per chunk (nginx limit is 1GB)
|
||||
const maxBatchSizeMb = Number(settings?.general_max_upload_batch_size_mb) || 95;
|
||||
const MAX_BYTES_PER_CHUNK = maxBatchSizeMb * 1024 * 1024;
|
||||
const chunks: File[][] = [];
|
||||
|
||||
let currentChunk: File[] = [];
|
||||
@@ -349,14 +389,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* File Input Area */}
|
||||
{/* File Input Area — accepts both click-to-pick and drag-and-drop (#504). */}
|
||||
<div
|
||||
className={clsx(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer",
|
||||
"hover:border-accent-dark hover:bg-accent-dark/15",
|
||||
selectedFiles.length > 0 ? "border-accent-dark bg-accent-dark/15" : "border-neutral-300 dark:border-neutral-600"
|
||||
isDragOver
|
||||
? "border-accent-dark bg-accent-dark/25"
|
||||
: selectedFiles.length > 0
|
||||
? "border-accent-dark bg-accent-dark/15"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="w-12 h-12 mx-auto text-neutral-400 dark:text-neutral-500 mb-4" />
|
||||
<p className="text-neutral-700 dark:text-neutral-300 font-medium mb-1">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import { ThemeCustomizerEnhanced } from '../ThemeCustomizerEnhanced';
|
||||
import type { ThemeConfig } from '../../../types/theme.types';
|
||||
@@ -15,6 +17,16 @@ vi.mock('react-i18next', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Component uses useQuery for admin-settings + fonts; tests don't exercise
|
||||
// those data paths, so just give them a client that won't retry on the
|
||||
// (intentionally absent) network.
|
||||
const renderWithQueryClient = (ui: ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } }
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
describe('ThemeCustomizerEnhanced', () => {
|
||||
const baseTheme: ThemeConfig = {
|
||||
primaryColor: '#000000',
|
||||
@@ -32,7 +44,7 @@ describe('ThemeCustomizerEnhanced', () => {
|
||||
const handleChange = vi.fn();
|
||||
const handleApply = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
renderWithQueryClient(
|
||||
<ThemeCustomizerEnhanced
|
||||
value={baseTheme}
|
||||
onChange={handleChange}
|
||||
@@ -55,7 +67,7 @@ describe('ThemeCustomizerEnhanced', () => {
|
||||
it('disables the Apply button while applying', () => {
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithQueryClient(
|
||||
<ThemeCustomizerEnhanced
|
||||
value={baseTheme}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -54,6 +54,14 @@ const FRFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) =>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ESFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#AA151B" d="M0 0h640v120H0z"/>
|
||||
<path fill="#F1BF00" d="M0 120h640v240H0z"/>
|
||||
<path fill="#AA151B" d="M0 360h640v120H0z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{ code: 'en', name: 'English', Flag: GBFlag },
|
||||
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
|
||||
@@ -61,6 +69,7 @@ export const SUPPORTED_LANGUAGES = [
|
||||
{ code: 'pt', name: 'Português', Flag: PTBRFlag },
|
||||
{ code: 'nl', name: 'Nederlands', Flag: NLFlag },
|
||||
{ code: 'fr', name: 'Français', Flag: FRFlag },
|
||||
{ code: 'es', name: 'Español', Flag: ESFlag },
|
||||
];
|
||||
|
||||
export const LanguageSelector: React.FC = () => {
|
||||
@@ -78,11 +87,17 @@ export const LanguageSelector: React.FC = () => {
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
className="flex items-center gap-2 px-2 sm:px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
// On <sm the language *name* is hidden — the Globe + flag pair
|
||||
// is enough recognition on its own and stops this control from
|
||||
// pushing into the company-name title on narrow mobile widths
|
||||
// (#523). Full name stays on sm+ where there's room.
|
||||
aria-label={currentLanguage.name}
|
||||
title={currentLanguage.name}
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
<currentLanguage.Flag className="w-5 h-5" />
|
||||
<span>{currentLanguage.name}</span>
|
||||
<span className="hidden sm:inline">{currentLanguage.name}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
|
||||
@@ -47,6 +47,9 @@ interface GalleryLayoutProps {
|
||||
youtube_url?: string;
|
||||
promo_markdown?: string;
|
||||
promo_position?: 'above_footer' | 'below_footer';
|
||||
// Horizontal alignment for the promo content (#482). Defaults
|
||||
// to 'center' so the banner aligns with the footer.
|
||||
promo_alignment?: 'left' | 'center' | 'right';
|
||||
};
|
||||
showLogout?: boolean;
|
||||
onLogout?: () => void;
|
||||
@@ -226,12 +229,36 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
})().trim();
|
||||
const promoPosition: 'above_footer' | 'below_footer' = brandingSettings?.promo_position === 'below_footer' ? 'below_footer' : 'above_footer';
|
||||
|
||||
// Promo alignment (#482). Defaults to 'center' to match the gallery
|
||||
// footer's `text-center px-4` so the banner reads as part of the
|
||||
// same composition as the footer beneath it. Admin can flip to
|
||||
// 'left' or 'right' from Settings → Branding.
|
||||
const promoAlignment: 'left' | 'center' | 'right' =
|
||||
brandingSettings?.promo_alignment === 'left' ? 'left'
|
||||
: brandingSettings?.promo_alignment === 'right' ? 'right'
|
||||
: 'center';
|
||||
const promoTextAlignClass =
|
||||
promoAlignment === 'left' ? 'text-left'
|
||||
: promoAlignment === 'right' ? 'text-right'
|
||||
: 'text-center';
|
||||
|
||||
const promoSlot = promoMarkdown ? (
|
||||
<div className="gallery-promo border-t border-surface bg-surface/50">
|
||||
<div className="container py-4 sm:py-6">
|
||||
<div className="max-w-3xl mx-auto text-sm text-theme">
|
||||
<MarkdownContent source={promoMarkdown} className="prose-sm prose-a:text-accent" />
|
||||
</div>
|
||||
{/*
|
||||
* Inner block uses .container (matches the footer's container
|
||||
* width) + the alignment class. We deliberately drop the
|
||||
* previous max-w-3xl wrapper — it created a narrower column
|
||||
* that read as visually offset from the full-width footer
|
||||
* (#482, reported by Rekoo-PS). The `prose` class is needed
|
||||
* for the prose-a:text-accent modifier to actually take effect
|
||||
* (modifiers without an outer .prose are no-ops in Tailwind
|
||||
* Typography).
|
||||
*/}
|
||||
<div className={`container py-4 sm:py-6 px-4 ${promoTextAlignClass}`}>
|
||||
<MarkdownContent
|
||||
source={promoMarkdown}
|
||||
className={`prose prose-sm max-w-none mx-auto text-sm text-theme prose-a:text-accent ${promoTextAlignClass}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||
import { api } from '../../config/api';
|
||||
import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
@@ -143,6 +144,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const disableRightClick = data?.event?.disable_right_click === true;
|
||||
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
|
||||
const useCanvasRendering = data?.event?.use_canvas_rendering === true;
|
||||
// #508 — surface original camera filenames in the lightbox when the
|
||||
// admin has flipped the same toggle that drives original-name downloads.
|
||||
const showOriginalFilename = data?.event?.use_original_filenames === true;
|
||||
|
||||
// DevTools protection - enabled by individual setting OR legacy protection level
|
||||
const devToolsEnabled = enableDevtoolsProtection || protectionLevel === 'enhanced' || protectionLevel === 'maximum';
|
||||
@@ -226,6 +230,46 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
}, [feedbackSettings]);
|
||||
|
||||
// In guest identity mode both the "Liked / Favorited / Rated /
|
||||
// Commented" filters AND the matching chip-count labels need to scope
|
||||
// to the *current guest's* interactions, not the global aggregates on
|
||||
// each photo row (#538 bug 1). Pull the current guest's feedback via
|
||||
// /my-feedback (already keyed by x-guest-token in the api interceptor)
|
||||
// and build per-type photo-id sets so both consumers below can do
|
||||
// O(1) lookups.
|
||||
//
|
||||
// Always-on in guest mode (not gated on the active filter) because
|
||||
// the chip counts render whether or not a feedback filter is selected
|
||||
// — gating on filterType would leave "Liked (0)" stale until the user
|
||||
// clicks the chip, which is the same UX cliff bug 1 was reporting.
|
||||
const isGuestIdentityMode = feedbackSettings?.identity_mode === 'guest';
|
||||
const { data: myFeedbackRows } = useQuery<Array<{
|
||||
photo_id: number;
|
||||
feedback_type: 'like' | 'favorite' | 'rating' | 'comment';
|
||||
}>>({
|
||||
queryKey: ['my-feedback', slug],
|
||||
queryFn: () => feedbackService.getMyFeedback(slug),
|
||||
enabled: isGuestIdentityMode && !!slug,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const myFeedbackPhotoIds = useMemo(() => {
|
||||
const sets = {
|
||||
liked: new Set<number>(),
|
||||
favorited: new Set<number>(),
|
||||
rated: new Set<number>(),
|
||||
commented: new Set<number>(),
|
||||
};
|
||||
if (!myFeedbackRows) return sets;
|
||||
for (const row of myFeedbackRows) {
|
||||
if (row.feedback_type === 'like') sets.liked.add(row.photo_id);
|
||||
else if (row.feedback_type === 'favorite') sets.favorited.add(row.photo_id);
|
||||
else if (row.feedback_type === 'rating') sets.rated.add(row.photo_id);
|
||||
else if (row.feedback_type === 'comment') sets.commented.add(row.photo_id);
|
||||
}
|
||||
return sets;
|
||||
}, [myFeedbackRows]);
|
||||
|
||||
// Apply branding settings
|
||||
useEffect(() => {
|
||||
if (settingsData) {
|
||||
@@ -253,6 +297,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
youtube_url: settingsData.branding_youtube_url || '',
|
||||
promo_markdown: settingsData.branding_promo_markdown || '',
|
||||
promo_position: settingsData.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer',
|
||||
// Promo alignment (#482). Defaults to 'center' to match the
|
||||
// gallery footer; see GalleryLayout.
|
||||
promo_alignment: ['left', 'center', 'right'].includes(settingsData.branding_promo_alignment)
|
||||
? settingsData.branding_promo_alignment
|
||||
: 'center',
|
||||
});
|
||||
}
|
||||
}, [settingsData]);
|
||||
@@ -436,19 +485,33 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Apply feedback filter
|
||||
// Apply feedback filter. In guest identity mode the filter has to
|
||||
// scope to the *current guest's* interactions (#538 bug 1) — the
|
||||
// aggregate counts on each photo row are global across all guests,
|
||||
// which gave an empty grid when the guest had liked photos that
|
||||
// nobody else had touched. Falls back to the aggregate-count check
|
||||
// in simple/non-guest mode where there's no per-person identity to
|
||||
// scope by.
|
||||
switch (filterType) {
|
||||
case 'liked':
|
||||
photos = photos.filter(photo => (photo.like_count || 0) > 0);
|
||||
photos = isGuestIdentityMode
|
||||
? photos.filter(photo => myFeedbackPhotoIds.liked.has(photo.id))
|
||||
: photos.filter(photo => (photo.like_count || 0) > 0);
|
||||
break;
|
||||
case 'favorited':
|
||||
photos = photos.filter(photo => (photo.favorite_count || 0) > 0);
|
||||
photos = isGuestIdentityMode
|
||||
? photos.filter(photo => myFeedbackPhotoIds.favorited.has(photo.id))
|
||||
: photos.filter(photo => (photo.favorite_count || 0) > 0);
|
||||
break;
|
||||
case 'rated':
|
||||
photos = photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
|
||||
photos = isGuestIdentityMode
|
||||
? photos.filter(photo => myFeedbackPhotoIds.rated.has(photo.id))
|
||||
: photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
|
||||
break;
|
||||
case 'commented':
|
||||
photos = photos.filter(photo => (photo.comment_count || 0) > 0);
|
||||
photos = isGuestIdentityMode
|
||||
? photos.filter(photo => myFeedbackPhotoIds.commented.has(photo.id))
|
||||
: photos.filter(photo => (photo.comment_count || 0) > 0);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -494,22 +557,29 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
|
||||
return photos;
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter]);
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds]);
|
||||
|
||||
const likeCount = useMemo(
|
||||
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
|
||||
[data?.photos]
|
||||
);
|
||||
// Counts shown in the filter chips ("Liked (N)", etc.). In guest
|
||||
// mode these need to mirror the per-guest filter behaviour above —
|
||||
// otherwise the chip says "Liked (5)" globally but clicking it
|
||||
// surfaces 3 (the guest's own subset), which is the same confusing
|
||||
// mismatch #538 reported for the filter itself. Fall back to the
|
||||
// global aggregate in simple mode where no per-person identity
|
||||
// exists.
|
||||
const likeCount = useMemo(() => {
|
||||
if (isGuestIdentityMode) return myFeedbackPhotoIds.liked.size;
|
||||
return data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0;
|
||||
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
|
||||
|
||||
const favoriteCount = useMemo(
|
||||
() => data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0,
|
||||
[data?.photos]
|
||||
);
|
||||
const favoriteCount = useMemo(() => {
|
||||
if (isGuestIdentityMode) return myFeedbackPhotoIds.favorited.size;
|
||||
return data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0;
|
||||
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
|
||||
|
||||
const ratedCount = useMemo(
|
||||
() => data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0,
|
||||
[data?.photos]
|
||||
);
|
||||
const ratedCount = useMemo(() => {
|
||||
if (isGuestIdentityMode) return myFeedbackPhotoIds.rated.size;
|
||||
return data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0;
|
||||
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
|
||||
|
||||
// Check if downloads are allowed (both event setting and not expired)
|
||||
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
|
||||
@@ -672,6 +742,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||
welcomeMessage={event.welcome_message}
|
||||
onLogout={logout}
|
||||
showOriginalFilename={showOriginalFilename}
|
||||
/>
|
||||
|
||||
{/* Upload Modal for full-page layouts */}
|
||||
@@ -913,6 +984,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
welcomeMessage={event.welcome_message}
|
||||
isClient={isClient}
|
||||
onToggleVisibility={isClient ? handleToggleVisibility : undefined}
|
||||
showOriginalFilename={showOriginalFilename}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ interface PhotoGridWithLayoutsProps {
|
||||
// Client visibility controls (#172)
|
||||
isClient?: boolean;
|
||||
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
|
||||
// Mirror of the admin original-filename toggle (#508). When true, the
|
||||
// lightbox bottom toolbar surfaces each photo's original camera name.
|
||||
showOriginalFilename?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
@@ -105,7 +108,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
welcomeMessage,
|
||||
onLogout,
|
||||
isClient = false,
|
||||
onToggleVisibility
|
||||
onToggleVisibility,
|
||||
showOriginalFilename = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
@@ -238,6 +242,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
onLogout,
|
||||
isClient,
|
||||
onToggleVisibility,
|
||||
showOriginalFilename,
|
||||
};
|
||||
|
||||
// Determine if we should show hero header (decoupled from layout)
|
||||
@@ -379,6 +384,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
enableDevtoolsProtection={enableDevtoolsProtection}
|
||||
initialShowFeedback={openFeedbackInitially}
|
||||
onFeedbackChange={onFeedbackChange}
|
||||
showOriginalFilename={showOriginalFilename}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
|
||||
import type { Photo } from '../../types';
|
||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||
import { useSavePhotoToDevice } from '../../hooks/useGallery';
|
||||
import { AuthenticatedImage } from '../common';
|
||||
import { PhotoFeedback } from './PhotoFeedback';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
@@ -24,6 +24,11 @@ interface PhotoLightboxProps {
|
||||
onFeedbackChange?: () => void;
|
||||
disableRightClick?: boolean;
|
||||
enableDevtoolsProtection?: boolean;
|
||||
// When true, surface each photo's original camera filename in the
|
||||
// bottom toolbar — useful for photographers matching guest selections
|
||||
// back to source files (#508). Tied to the admin-side toggle that
|
||||
// also drives original-filename downloads (#493).
|
||||
showOriginalFilename?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
@@ -40,6 +45,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
onFeedbackChange,
|
||||
disableRightClick = false,
|
||||
enableDevtoolsProtection = false,
|
||||
showOriginalFilename = false,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
@@ -71,6 +77,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
feedback_enabled?: boolean;
|
||||
allow_likes?: boolean;
|
||||
allow_ratings?: boolean;
|
||||
allow_comments?: boolean;
|
||||
show_feedback_to_guests?: boolean;
|
||||
require_name_email?: boolean;
|
||||
} | null>(null);
|
||||
const [myLiked, setMyLiked] = useState<boolean>(false);
|
||||
@@ -91,7 +99,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
}, []);
|
||||
|
||||
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
// Save-aware download. On mobile (where Web Share + files is supported)
|
||||
// this opens the OS share sheet so "Save to Photos" actually lands in
|
||||
// the Photos/Gallery app — matters for non-technical clients who
|
||||
// otherwise have to chain Files → unzip → save (#531). Desktop and
|
||||
// unsupported browsers fall through to a regular <a download>.
|
||||
const downloadPhotoMutation = useSavePhotoToDevice();
|
||||
const currentPhoto = photos[currentIndex];
|
||||
|
||||
// DevTools protection - enabled by individual setting OR legacy protection level
|
||||
@@ -398,6 +411,16 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
setDragX(0);
|
||||
}
|
||||
}
|
||||
} else if (e.touches.length === 1 && zoom > 1) {
|
||||
// Single-finger pan when zoomed in (#532). Mirrors the desktop
|
||||
// handleMouseDown path so mobile users can drag a zoomed image
|
||||
// around instead of being stuck looking at the centre crop.
|
||||
// Carousel swipe is disabled in this branch — when zoom > 1 the
|
||||
// gesture has to mean "pan", not "next photo", or zoomed nav
|
||||
// becomes unusable.
|
||||
const t = e.touches[0];
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: t.clientX - dragOffset.x, y: t.clientY - dragOffset.y });
|
||||
} else if (e.touches.length === 1 && zoom <= 1 && (phase === 'idle' || phase === 'dragging')) {
|
||||
const t = e.touches[0];
|
||||
swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() };
|
||||
@@ -419,6 +442,24 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
const newZoom = Math.max(1, Math.min(3, zoom * scale));
|
||||
setZoom(newZoom);
|
||||
setTouchDistance(newDistance);
|
||||
// Pinch-out back down to 1.0 has to re-centre the image — without
|
||||
// this the previous pan offset persists and the photo sits off-
|
||||
// centre at the natural zoom level (#532 follow-on).
|
||||
if (newZoom <= 1 && (dragOffset.x !== 0 || dragOffset.y !== 0)) {
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDragging && zoom > 1 && e.touches.length === 1) {
|
||||
// Single-finger pan when zoomed (#532). Touch counterpart to
|
||||
// handleMouseMove. Same dragOffset state so the transform on the
|
||||
// <img> stays consistent across input modalities.
|
||||
const t = e.touches[0];
|
||||
setDragOffset({
|
||||
x: t.clientX - dragStart.x,
|
||||
y: t.clientY - dragStart.y,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -446,6 +487,10 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
setTouchDistance(null);
|
||||
// Release single-finger pan state (#532). The pan offset itself
|
||||
// persists so the image stays where the user left it — only the
|
||||
// "actively dragging" flag clears.
|
||||
if (isDragging) setIsDragging(false);
|
||||
const start = swipeStartRef.current;
|
||||
if (phase === 'dragging' && start && e.changedTouches.length > 0) {
|
||||
const t = e.changedTouches[0];
|
||||
@@ -593,10 +638,23 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="text-white">
|
||||
<div className="text-white min-w-0">
|
||||
<p className="text-sm opacity-75">
|
||||
{currentIndex + 1} / {photos.length}
|
||||
</p>
|
||||
{/* #508 — original camera filename next to the counter when
|
||||
the admin has flipped the matching toggle. Falls back to
|
||||
the storage filename only if `original_filename` is null
|
||||
(pre-migration-062 uploads). truncate + max-w keep long
|
||||
names from pushing the action row to another line. */}
|
||||
{showOriginalFilename && (currentPhoto.original_filename || currentPhoto.filename) && (
|
||||
<p
|
||||
className="text-xs opacity-60 truncate max-w-[14rem] sm:max-w-md mt-0.5"
|
||||
title={currentPhoto.original_filename || currentPhoto.filename}
|
||||
>
|
||||
{currentPhoto.original_filename || currentPhoto.filename}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-end">
|
||||
@@ -641,9 +699,19 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
aria-label={myLiked ? 'Unlike photo' : 'Like photo'}
|
||||
title={myLiked ? 'Unlike' : 'Like'}
|
||||
>
|
||||
<Heart className={`w-5 h-5 ${myLiked ? 'text-white' : 'text-white'}`} />
|
||||
{/* fill-current on the liked state so the heart is
|
||||
actually visible against the red background — both
|
||||
branches were `text-white` only (#538 follow-on
|
||||
bug from @Tietge86). */}
|
||||
<Heart className={`w-5 h-5 text-white ${myLiked ? 'fill-current' : ''}`} />
|
||||
</button>
|
||||
<span className="text-white text-xs min-w-[1.5rem] text-center select-none">{likeCount}</span>
|
||||
{/* Aggregate like count is admin-only when the admin
|
||||
has hidden feedback from guests (#538 bug 3). Without
|
||||
this gate, a guest could see how many other guests
|
||||
liked a photo even with show_feedback_to_guests off. */}
|
||||
{feedbackSettings?.show_feedback_to_guests && (
|
||||
<span className="text-white text-xs min-w-[1.5rem] text-center select-none">{likeCount}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -665,8 +733,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback button with indicator */}
|
||||
{feedbackEnabled && (
|
||||
{/* Feedback button with indicator. Gated on allow_comments
|
||||
because likes/ratings already have their own dedicated
|
||||
toolbar buttons above — this MessageSquare button only
|
||||
opens the comments panel, so it has nothing to do when
|
||||
comments are off (#518). */}
|
||||
{feedbackEnabled && feedbackSettings?.allow_comments && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFeedback(!showFeedback);
|
||||
@@ -696,20 +768,38 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
{(() => {
|
||||
const isVideoCurrent = currentPhoto.media_type === 'video';
|
||||
|
||||
const renderSlide = (photo: Photo | null, isCurrent: boolean) => {
|
||||
// Stable per-slide keys so React's reconciler can MOVE existing
|
||||
// DOM nodes across slot positions on commit rather than
|
||||
// re-fetching the AuthenticatedImage at the new position (#505 —
|
||||
// that re-fetch is what caused the black blink during swipe).
|
||||
// Edge case: 2-photo galleries assign the same photo to both
|
||||
// `prev` and `next`; fall back to slot-prefixed keys to keep
|
||||
// siblings unique. >2-photo galleries (the common case) get
|
||||
// plain photo.id keys so a "next becomes current" commit
|
||||
// preserves the loaded image instance.
|
||||
const slideKey = (photo: Photo | null, slot: 'prev' | 'current' | 'next') => {
|
||||
if (!photo) return `empty-${slot}`;
|
||||
if (photos.length === 2) return `${slot}-${photo.id}`;
|
||||
return `photo-${photo.id}`;
|
||||
};
|
||||
|
||||
const renderSlide = (photo: Photo | null, isCurrent: boolean, slot: 'prev' | 'current' | 'next') => {
|
||||
// Reserve the slot even when there's no neighbour (single-photo
|
||||
// gallery) so the flex layout keeps slides aligned.
|
||||
if (!photo) {
|
||||
return <div className="h-full" style={{ flex: '0 0 33.3333%' }} aria-hidden="true" />;
|
||||
return <div key={slideKey(photo, slot)} className="h-full" style={{ flex: '0 0 33.3333%' }} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
// Neighbouring slides are plain thumbnails — they're only on
|
||||
// screen during the swipe animation, so we save the work of a
|
||||
// protected canvas pipeline for them. The current slide keeps
|
||||
// the full protection chain.
|
||||
// the full protection chain. Wrapper className matches the
|
||||
// current slide so object-contain sizing renders the same
|
||||
// visible height (#505 — earlier `px-2` made wide images
|
||||
// shorter on neighbours than on current).
|
||||
if (!isCurrent) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center px-2" style={{ flex: '0 0 33.3333%' }}>
|
||||
<div key={slideKey(photo, slot)} className="h-full flex items-center justify-center" style={{ flex: '0 0 33.3333%' }}>
|
||||
{photo.media_type === 'video' && photo.thumbnail_url ? (
|
||||
<img
|
||||
src={photo.thumbnail_url}
|
||||
@@ -719,7 +809,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
/>
|
||||
) : (
|
||||
<AuthenticatedImage
|
||||
src={photo.url}
|
||||
// Prefer the lightbox preview tier when the admin
|
||||
// opted in (#492). Falls back to `url` (the
|
||||
// original) when preview_url is null — happens
|
||||
// when the toggle is off, when the photo is a
|
||||
// video, or briefly while lazy generation runs.
|
||||
src={photo.preview_url || photo.url}
|
||||
alt={photo.filename}
|
||||
fallbackSrc={photo.thumbnail_url || undefined}
|
||||
className="max-w-full max-h-full object-contain select-none pointer-events-none"
|
||||
@@ -737,12 +832,15 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slideKey(photo, slot)}
|
||||
className="h-full flex items-center justify-center"
|
||||
style={{ flex: '0 0 33.3333%' }}
|
||||
onClick={handleImageClick}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photo.url}
|
||||
// Same preview-prefer-with-fallback logic as the
|
||||
// off-screen tile above (#492).
|
||||
src={photo.preview_url || photo.url}
|
||||
alt={photo.filename}
|
||||
fallbackSrc={photo.thumbnail_url || undefined}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
@@ -831,9 +929,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
}}
|
||||
onTransitionEnd={handleTrackTransitionEnd}
|
||||
>
|
||||
{renderSlide(prevPhoto, false)}
|
||||
{renderSlide(currentPhoto, true)}
|
||||
{renderSlide(nextPhoto, false)}
|
||||
{renderSlide(prevPhoto, false, 'prev')}
|
||||
{renderSlide(currentPhoto, true, 'current')}
|
||||
{renderSlide(nextPhoto, false, 'next')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -111,8 +111,12 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
onClick={handleLikeClick}
|
||||
disabled={isSubmitting}
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||
isLiked
|
||||
? 'bg-red-50 text-red-600 hover:bg-red-100'
|
||||
// Filled solid-red state matches the lightbox toolbar (#538 bug
|
||||
// 2). The previous `bg-red-50 text-red-600` was almost invisible
|
||||
// — particularly on dark themes and coloured gallery backgrounds
|
||||
// — so the user couldn't tell the like had registered.
|
||||
isLiked
|
||||
? 'bg-red-500/80 text-white hover:bg-red-500'
|
||||
: 'bg-surface text-muted-theme hover:bg-black/10'
|
||||
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
aria-label={isLiked ? t('feedback.unlike', 'Unlike') : t('feedback.like', 'Like')}
|
||||
|
||||
@@ -28,6 +28,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
// bytes-on-wire for that file, so the UI can show "Processing…"
|
||||
// instead of a static 100% bar while the backend works.
|
||||
const [processingFiles, setProcessingFiles] = useState<{ [key: string]: boolean }>({});
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
|
||||
@@ -41,11 +42,9 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
[publicSettings?.allowed_file_types]
|
||||
);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
|
||||
// Validate file types
|
||||
const validFiles = selectedFiles.filter(file => {
|
||||
// Shared filter pipeline for both <input> change and drag-and-drop (#504).
|
||||
const addFiles = (incoming: File[]) => {
|
||||
const validFiles = incoming.filter((file) => {
|
||||
if (!allowedMimeTypes.includes(file.type)) {
|
||||
toast.error(`Invalid file type: ${file.name}`);
|
||||
return false;
|
||||
@@ -57,8 +56,38 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (validFiles.length === 0) return;
|
||||
setFiles((prev) => [...prev, ...validFiles]);
|
||||
};
|
||||
|
||||
setFiles(prev => [...prev, ...validFiles]);
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
addFiles(Array.from(e.target.files || []));
|
||||
// Reset so re-selecting the same file fires onChange again.
|
||||
if (e.target.value) e.target.value = '';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
if (!isDragOver) setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// dragleave fires for every child node — only flip off when the cursor
|
||||
// leaves the zone itself.
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
if (uploading) return;
|
||||
addFiles(Array.from(e.dataTransfer.files || []));
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
@@ -154,10 +183,18 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 p-4 sm:p-6 overflow-y-auto min-h-0">
|
||||
{/* Upload Area */}
|
||||
{/* Upload Area — accepts both click-to-pick and drag-and-drop (#504). */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<label className="block">
|
||||
<div className="border-2 border-dashed border-surface rounded-lg p-6 sm:p-8 text-center hover:border-accent-dark transition-colors cursor-pointer">
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 sm:p-8 text-center hover:border-accent-dark transition-colors cursor-pointer ${
|
||||
isDragOver ? 'border-accent-dark bg-accent-dark/10' : 'border-surface'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="w-10 h-10 sm:w-12 sm:h-12 text-neutral-400 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-muted-theme mb-1">
|
||||
{t('upload.clickToUpload')}
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface BaseGalleryLayoutProps {
|
||||
// Client visibility controls (#172)
|
||||
isClient?: boolean;
|
||||
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
|
||||
// Mirror of the admin original-filename toggle (#508). Forwarded to the
|
||||
// lightbox by layouts that mount their own (story/premium).
|
||||
showOriginalFilename?: boolean;
|
||||
}
|
||||
|
||||
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
|
||||
|
||||
@@ -147,7 +147,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
<Download className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
{feedbackOptions?.allowLikes && (
|
||||
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -6,8 +6,10 @@ import Thumbnails from 'yet-another-react-lightbox/plugins/thumbnails';
|
||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom';
|
||||
import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen';
|
||||
import Download from 'yet-another-react-lightbox/plugins/download';
|
||||
import Captions from 'yet-another-react-lightbox/plugins/captions';
|
||||
import 'yet-another-react-lightbox/styles.css';
|
||||
import 'yet-another-react-lightbox/plugins/thumbnails.css';
|
||||
import 'yet-another-react-lightbox/plugins/captions.css';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Download as DownloadIcon, Heart, Check, Star, MessageSquare, Package, LogOut } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -42,6 +44,10 @@ interface PhotoCardProps {
|
||||
useEnhancedProtection?: boolean;
|
||||
useCanvasRendering?: boolean;
|
||||
feedbackEnabled?: boolean;
|
||||
// #506: track the per-event "allow likes" toggle so the per-photo
|
||||
// Like button respects it. `feedbackEnabled` alone isn't enough —
|
||||
// an event can have feedback on but likes specifically disabled.
|
||||
allowLikes?: boolean;
|
||||
index: number;
|
||||
}
|
||||
|
||||
@@ -61,6 +67,7 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
feedbackEnabled = false,
|
||||
allowLikes = false,
|
||||
index
|
||||
}) => {
|
||||
// Note: height is passed but not used as we maintain aspect ratio via width
|
||||
@@ -113,15 +120,18 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
|
||||
{isSelected && <Check className="w-3.5 h-3.5" strokeWidth={3} />}
|
||||
</button>
|
||||
|
||||
{/* Like Button */}
|
||||
<button
|
||||
onClick={onLike}
|
||||
className={`gallery-premium-like-btn ${isLiked ? 'liked' : ''}`}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${isLiked ? 'fill-current' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{/* Like Button — #506: only when feedback master is on AND the
|
||||
per-event "allow likes" sub-toggle is on. */}
|
||||
{feedbackEnabled && allowLikes && (
|
||||
<button
|
||||
onClick={onLike}
|
||||
className={`gallery-premium-like-btn ${isLiked ? 'liked' : ''}`}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${isLiked ? 'fill-current' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Selection Border */}
|
||||
{isSelected && (
|
||||
@@ -177,7 +187,8 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions,
|
||||
heroPhotoOverride,
|
||||
onLogout
|
||||
onLogout,
|
||||
showOriginalFilename = false,
|
||||
}) => {
|
||||
// These props are passed by parent but we use our own lightbox, so mark as intentionally unused
|
||||
void _onPhotoClick;
|
||||
@@ -225,16 +236,20 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
}));
|
||||
}, [filteredPhotos]);
|
||||
|
||||
// Lightbox slides
|
||||
// Lightbox slides. `title` powers the Captions plugin — only emitted
|
||||
// when the admin has flipped the original-filenames toggle (#508).
|
||||
const slides = useMemo(() => {
|
||||
return filteredPhotos.map(photo => ({
|
||||
src: photo.url,
|
||||
alt: photo.filename,
|
||||
width: photo.width || 1200,
|
||||
height: photo.height || 800,
|
||||
download: allowDownloads ? photo.url : undefined
|
||||
download: allowDownloads ? photo.url : undefined,
|
||||
title: showOriginalFilename
|
||||
? (photo.original_filename || photo.filename)
|
||||
: undefined,
|
||||
}));
|
||||
}, [filteredPhotos, allowDownloads]);
|
||||
}, [filteredPhotos, allowDownloads, showOriginalFilename]);
|
||||
|
||||
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -502,6 +517,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
allowLikes={!!feedbackOptions?.allowLikes}
|
||||
index={photoIndex}
|
||||
/>
|
||||
);
|
||||
@@ -528,7 +544,13 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
close={() => setLightboxIndex(-1)}
|
||||
index={lightboxIndex}
|
||||
slides={slides}
|
||||
plugins={allowDownloads ? [Thumbnails, Zoom, Fullscreen, Download] : [Thumbnails, Zoom, Fullscreen]}
|
||||
plugins={[
|
||||
Thumbnails,
|
||||
Zoom,
|
||||
Fullscreen,
|
||||
...(allowDownloads ? [Download] : []),
|
||||
...(showOriginalFilename ? [Captions] : []),
|
||||
]}
|
||||
animation={{ fade: 300, swipe: 250 }}
|
||||
styles={{
|
||||
container: { backgroundColor: 'rgba(0, 0, 0, 0.95)' },
|
||||
|
||||
@@ -58,7 +58,8 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
feedbackOptions,
|
||||
heroPhotoOverride,
|
||||
welcomeMessage,
|
||||
onLogout
|
||||
onLogout,
|
||||
showOriginalFilename = false,
|
||||
}) => {
|
||||
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
|
||||
void _onPhotoClick;
|
||||
@@ -379,6 +380,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering}
|
||||
onFeedbackChange={onFeedbackChange}
|
||||
showOriginalFilename={showOriginalFilename}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
)}
|
||||
{feedbackOptions?.allowLikes && (
|
||||
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||
<button
|
||||
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||
onClick={async (e) => {
|
||||
|
||||
@@ -144,7 +144,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
)}
|
||||
{feedbackOptions?.allowLikes && (
|
||||
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||
<button
|
||||
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||
onClick={async (e) => {
|
||||
|
||||
@@ -16,10 +16,13 @@ export interface GeneralSettings {
|
||||
max_file_size_mb: number;
|
||||
max_files_per_upload: number;
|
||||
allowed_file_types: string;
|
||||
// #509 — re-added after the main-into-beta merge dropped it.
|
||||
max_upload_batch_size_mb: number;
|
||||
enable_analytics: boolean;
|
||||
enable_registration: boolean;
|
||||
maintenance_mode: boolean;
|
||||
short_gallery_urls: boolean;
|
||||
use_original_filenames_for_downloads: boolean;
|
||||
default_language: string;
|
||||
date_format: { format: string; locale: string };
|
||||
}
|
||||
@@ -51,6 +54,7 @@ export interface EventSettings {
|
||||
event_require_event_date: boolean;
|
||||
event_require_expiration: boolean;
|
||||
event_default_require_password: boolean;
|
||||
event_default_feedback_enabled: boolean;
|
||||
gallery_show_filter_bar: boolean;
|
||||
event_phone_field_enabled: boolean;
|
||||
}
|
||||
@@ -69,7 +73,7 @@ export interface SeoSettings {
|
||||
|
||||
export function useSettingsState() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { updateUserProfile } = useAdminAuth();
|
||||
|
||||
// Fetch settings
|
||||
@@ -90,10 +94,12 @@ export function useSettingsState() {
|
||||
max_file_size_mb: 50,
|
||||
max_files_per_upload: 500,
|
||||
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
||||
max_upload_batch_size_mb: 95,
|
||||
enable_analytics: true,
|
||||
enable_registration: false,
|
||||
maintenance_mode: false,
|
||||
short_gallery_urls: false,
|
||||
use_original_filenames_for_downloads: false,
|
||||
default_language: 'en',
|
||||
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||
});
|
||||
@@ -128,6 +134,7 @@ export function useSettingsState() {
|
||||
event_require_event_date: true,
|
||||
event_require_expiration: true,
|
||||
event_default_require_password: true,
|
||||
event_default_feedback_enabled: false,
|
||||
gallery_show_filter_bar: true,
|
||||
event_phone_field_enabled: false
|
||||
});
|
||||
@@ -162,10 +169,6 @@ export function useSettingsState() {
|
||||
// Initialize settings from API
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
if (settings.general_default_language && settings.general_default_language !== i18n.language) {
|
||||
i18n.changeLanguage(settings.general_default_language);
|
||||
}
|
||||
|
||||
setGeneralSettings({
|
||||
site_url: settings.general_site_url || '',
|
||||
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
|
||||
@@ -175,10 +178,15 @@ export function useSettingsState() {
|
||||
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
||||
),
|
||||
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
||||
max_upload_batch_size_mb: toNumber(settings.general_max_upload_batch_size_mb, 95),
|
||||
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
||||
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false),
|
||||
use_original_filenames_for_downloads: toBoolean(
|
||||
settings.general_use_original_filenames_for_downloads,
|
||||
false
|
||||
),
|
||||
default_language: settings.general_default_language || 'en',
|
||||
date_format: settings.general_date_format
|
||||
? (typeof settings.general_date_format === 'string'
|
||||
@@ -214,6 +222,7 @@ export function useSettingsState() {
|
||||
event_require_event_date: toBoolean(settings.event_require_event_date, true),
|
||||
event_require_expiration: toBoolean(settings.event_require_expiration, true),
|
||||
event_default_require_password: toBoolean(settings.event_default_require_password, true),
|
||||
event_default_feedback_enabled: toBoolean(settings.event_default_feedback_enabled, false),
|
||||
gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true),
|
||||
event_phone_field_enabled: toBoolean(settings.event_phone_field_enabled, false)
|
||||
});
|
||||
@@ -230,7 +239,7 @@ export function useSettingsState() {
|
||||
sitemap_url: settings.seo_sitemap_url || ''
|
||||
});
|
||||
}
|
||||
}, [settings, i18n]);
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (adminProfile) {
|
||||
|
||||
@@ -169,6 +169,25 @@ export const EventsTab: React.FC<EventsTabProps> = ({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eventSettings.event_default_feedback_enabled}
|
||||
onChange={(e) => setEventSettings(prev => ({ ...prev, event_default_feedback_enabled: e.target.checked }))}
|
||||
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('settings.events.defaultFeedbackEnabled', 'Enable Guest Feedback by default')}
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('settings.events.defaultFeedbackEnabledHelp', 'Pre-check "Guest Feedback" when creating new events. Individual feedback options (likes, ratings, comments) can still be customised per event.')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
|
||||
@@ -162,6 +162,28 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
||||
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.general.maxUploadBatchSize')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={generalSettings.max_upload_batch_size_mb}
|
||||
onChange={(e) => {
|
||||
const parsed = parseInt(e.target.value, 10);
|
||||
setGeneralSettings(prev => ({
|
||||
...prev,
|
||||
max_upload_batch_size_mb: Number.isFinite(parsed)
|
||||
? Math.max(1, parsed)
|
||||
: prev.max_upload_batch_size_mb
|
||||
}));
|
||||
}}
|
||||
min="1"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('settings.general.maxUploadBatchSizeHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -229,6 +251,21 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
||||
{t('settings.general.enableShortGalleryUrlsHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={generalSettings.use_original_filenames_for_downloads}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, use_original_filenames_for_downloads: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.general.useOriginalFilenames')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 ml-6 mt-1">
|
||||
{t('settings.general.useOriginalFilenamesHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ interface ThumbnailSettings {
|
||||
quality: number;
|
||||
fit: string;
|
||||
format: string;
|
||||
// Lightbox preview tier (#492). Off by default; admin opts in.
|
||||
lightbox_preview_enabled: boolean;
|
||||
}
|
||||
|
||||
const defaultSettings: ThumbnailSettings = {
|
||||
@@ -20,8 +22,20 @@ const defaultSettings: ThumbnailSettings = {
|
||||
quality: 85,
|
||||
fit: 'cover',
|
||||
format: 'jpeg',
|
||||
lightbox_preview_enabled: false,
|
||||
};
|
||||
|
||||
// Backend returns the lightbox toggle as a JSON-stringified boolean
|
||||
// per migration 104. Tolerate raw boolean / "true" / "false" / "1" /
|
||||
// "0" coming back so the form mirrors whatever shape lands.
|
||||
function parseLightboxFlag(raw: unknown): boolean {
|
||||
if (raw === true || raw === 1) return true;
|
||||
if (typeof raw !== 'string') return false;
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (trimmed === 'true' || trimmed === '"true"' || trimmed === '1') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface FetchedSettings {
|
||||
settings: Record<string, { value: string; description: string }>;
|
||||
fitOptions: Array<'cover' | 'contain' | 'fill' | 'inside' | 'outside'>;
|
||||
@@ -51,6 +65,7 @@ export const ThumbnailsTab: React.FC = () => {
|
||||
quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality,
|
||||
fit: s.thumbnail_fit?.value || defaultSettings.fit,
|
||||
format: s.thumbnail_format?.value || defaultSettings.format,
|
||||
lightbox_preview_enabled: parseLightboxFlag(s.lightbox_preview_enabled?.value),
|
||||
});
|
||||
}
|
||||
}, [fetchedData]);
|
||||
@@ -83,6 +98,23 @@ export const ThumbnailsTab: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Lightbox preview tier (#492). Eager regeneration counterpart to
|
||||
// ensurePreviewImage's lazy on-first-open generation. Useful after
|
||||
// flipping the toggle on so guests don't pay the lazy-cost on the
|
||||
// very first lightbox open per gallery.
|
||||
const regeneratePreviewsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await api.post('/admin/thumbnails/regenerate-previews');
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message || t('settings.thumbnails.previewsRegenerateStarted', 'Lightbox preview regeneration started'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.thumbnails.previewsRegenerateError', 'Failed to start preview regeneration'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = <K extends keyof ThumbnailSettings>(
|
||||
key: K,
|
||||
value: ThumbnailSettings[K]
|
||||
@@ -104,6 +136,7 @@ export const ThumbnailsTab: React.FC = () => {
|
||||
quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality,
|
||||
fit: s.thumbnail_fit?.value || defaultSettings.fit,
|
||||
format: s.thumbnail_format?.value || defaultSettings.format,
|
||||
lightbox_preview_enabled: parseLightboxFlag(s.lightbox_preview_enabled?.value),
|
||||
});
|
||||
setIsDirty(false);
|
||||
}
|
||||
@@ -255,6 +288,51 @@ export const ThumbnailsTab: React.FC = () => {
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Lightbox preview tier (#492). Independent opt-in from the
|
||||
thumbnail size/quality settings above — costs disk but
|
||||
dramatically speeds up lightbox open on mobile / slow
|
||||
connections by serving an aspect-preserved ~1920px JPEG
|
||||
instead of the multi-megabyte original. */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
|
||||
<Image className="w-5 h-5 text-primary-600" />
|
||||
{t('settings.thumbnails.lightboxTitle', 'Lightbox Preview Tier')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('settings.thumbnails.lightboxHelp', 'When enabled, the lightbox loads an aspect-preserved ~1920px JPEG (typically 200–500 KB) instead of the full original (often 5–12 MB). Originals are still served when guests click Download. Costs roughly one extra preview file per photo on disk; previews are generated lazily on first open and stored in /previews.')}
|
||||
</p>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
|
||||
checked={settings.lightbox_preview_enabled}
|
||||
onChange={(e) => handleChange('lightbox_preview_enabled', e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('settings.thumbnails.lightboxToggle', 'Use medium-resolution previews in the lightbox')}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
|
||||
{t('settings.thumbnails.lightboxToggleHelp', 'Off by default. Flip on after deciding the perceived-perf win is worth the extra disk usage.')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Eager regeneration so guests don't pay the lazy first-open
|
||||
cost. Only useful after the toggle is on; gated so admins
|
||||
don't accidentally kick off a job that won't be visible. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => regeneratePreviewsMutation.mutate()}
|
||||
isLoading={regeneratePreviewsMutation.isPending}
|
||||
disabled={!settings.lightbox_preview_enabled}
|
||||
leftIcon={regeneratePreviewsMutation.isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <RefreshCw className="w-5 h-5" />}
|
||||
>
|
||||
{t('settings.thumbnails.regeneratePreviewsButton', 'Regenerate All Previews')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Info Box */}
|
||||
<Card padding="md" className="bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
@@ -65,6 +65,35 @@ export const useDownloadPhoto = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Save-aware download — opens the OS share sheet on mobile (so "Save to
|
||||
// Photos" lands the file in the Photos/Gallery app instead of Files),
|
||||
// falls back to a regular download on browsers without Web Share file
|
||||
// support. See galleryService.savePhotoToDevice for the negotiation
|
||||
// (#531).
|
||||
//
|
||||
// Toast omitted on success because the share-sheet path doesn't really
|
||||
// finish from this code's perspective — the OS UI takes over and the
|
||||
// user picks where it goes. Showing "Photo downloaded" before they've
|
||||
// even picked is misleading. The fallback download path is also silent
|
||||
// to keep the two paths symmetrical; the file appearing in Downloads
|
||||
// is its own affordance.
|
||||
export const useSavePhotoToDevice = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
slug,
|
||||
photoId,
|
||||
filename,
|
||||
}: {
|
||||
slug: string;
|
||||
photoId: number;
|
||||
filename: string;
|
||||
}) => galleryService.savePhotoToDevice(slug, photoId, filename),
|
||||
onError: () => {
|
||||
toast.error('Failed to save photo');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDownloadAllPhotos = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ slug, zipReady }: { slug: string; zipReady?: boolean }) =>
|
||||
|
||||
@@ -1031,6 +1031,8 @@
|
||||
"maxFileSize": "Max. Dateigröße (MB)",
|
||||
"maxFilesPerUpload": "Max. Dateien pro Upload",
|
||||
"maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).",
|
||||
"maxUploadBatchSize": "Max. Upload-Paketgröße (MB)",
|
||||
"maxUploadBatchSizeHelp": "Maximale Größe pro Upload-Anfrage. Reduzieren Sie diesen Wert bei Nutzung eines Reverse-Proxys mit Größenbeschränkung (z.B. Cloudflare: 100MB).",
|
||||
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||
"featureToggles": "Funktionsschalter",
|
||||
@@ -1038,6 +1040,8 @@
|
||||
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
||||
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
|
||||
"enableShortGalleryUrlsHelp": "Entfernt den Veranstaltungs-Slug aus neuen Freigabelinks und lässt bestehende Links weiterhin funktionieren.",
|
||||
"useOriginalFilenames": "Originale Dateinamen beim Download verwenden",
|
||||
"useOriginalFilenamesHelp": "Wenn aktiviert, verwenden Einzel- und ZIP-Downloads den Original-Dateinamen der Kamera (z. B. DSC_1234.jpg) statt des umbenannten Namens. Der Speicher bleibt unverändert; Duplikate innerhalb einer Veranstaltung erhalten ein numerisches Suffix.",
|
||||
"maintenanceMode": "Wartungsmodus aktivieren",
|
||||
"language": "Sprache",
|
||||
"defaultLanguageHelp": "Sprache, die Gästen vor der Anmeldung angezeigt wird",
|
||||
@@ -1242,6 +1246,13 @@
|
||||
"regenerateButton": "Alle Vorschaubilder neu generieren",
|
||||
"regenerateStarted": "Neugenerierung der Vorschaubilder gestartet",
|
||||
"regenerateError": "Neugenerierung der Vorschaubilder konnte nicht gestartet werden",
|
||||
"lightboxTitle": "Lightbox-Vorschau-Stufe",
|
||||
"lightboxHelp": "Wenn aktiviert, lädt die Lightbox ein seitenverhältnis-erhaltendes JPEG mit ~1920 px (typischerweise 200–500 KB) statt des vollen Originals (oft 5–12 MB). Beim Download durch Gäste wird weiterhin das Original ausgeliefert. Kostet pro Foto eine zusätzliche Vorschaudatei auf der Festplatte; Vorschauen werden beim ersten Öffnen erzeugt und unter /previews gespeichert.",
|
||||
"lightboxToggle": "Mittelauflösende Vorschauen in der Lightbox verwenden",
|
||||
"lightboxToggleHelp": "Standardmäßig deaktiviert. Aktivieren, sobald der gefühlte Geschwindigkeitsgewinn den zusätzlichen Speicherbedarf rechtfertigt.",
|
||||
"regeneratePreviewsButton": "Alle Vorschauen neu generieren",
|
||||
"previewsRegenerateStarted": "Neugenerierung der Lightbox-Vorschauen gestartet",
|
||||
"previewsRegenerateError": "Neugenerierung der Vorschauen konnte nicht gestartet werden",
|
||||
"saveSuccess": "Vorschaubild-Einstellungen gespeichert",
|
||||
"saveError": "Vorschaubild-Einstellungen konnten nicht gespeichert werden",
|
||||
"loadError": "Vorschaubild-Einstellungen konnten nicht geladen werden",
|
||||
@@ -1792,6 +1803,12 @@
|
||||
"sizeMedium": "Mittel (Standard)",
|
||||
"sizeLarge": "Groß",
|
||||
"sizeXLarge": "Sehr groß"
|
||||
},
|
||||
"promo": {
|
||||
"alignment": "Ausrichtung",
|
||||
"alignLeft": "Links",
|
||||
"alignCenter": "Zentriert (Standard – wie der Footer)",
|
||||
"alignRight": "Rechts"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -670,6 +670,8 @@
|
||||
"maxFileSize": "Max File Size (MB)",
|
||||
"maxFilesPerUpload": "Max Files per Upload",
|
||||
"maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).",
|
||||
"maxUploadBatchSize": "Max Upload Batch Size (MB)",
|
||||
"maxUploadBatchSizeHelp": "Maximum size per upload request. Lower this if behind a reverse proxy with request size limits (e.g. Cloudflare: 100MB).",
|
||||
"allowedFileTypes": "Allowed File Types",
|
||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||
"featureToggles": "Feature Toggles",
|
||||
@@ -677,6 +679,8 @@
|
||||
"enableRegistration": "Allow self-registration for admins",
|
||||
"enableShortGalleryUrls": "Use short gallery URLs",
|
||||
"enableShortGalleryUrlsHelp": "Removes the event slug from new share links while keeping existing links working.",
|
||||
"useOriginalFilenames": "Use original filenames on download",
|
||||
"useOriginalFilenamesHelp": "When on, single-photo and ZIP downloads use the original camera filename (e.g. DSC_1234.jpg) instead of the sanitized name. Storage is unchanged; duplicates in the same event get a numeric suffix.",
|
||||
"maintenanceMode": "Enable maintenance mode",
|
||||
"language": "Language",
|
||||
"defaultLanguageHelp": "Language shown to guests before login",
|
||||
@@ -948,6 +952,13 @@
|
||||
"regenerateButton": "Regenerate All Thumbnails",
|
||||
"regenerateStarted": "Thumbnail regeneration started",
|
||||
"regenerateError": "Failed to start thumbnail regeneration",
|
||||
"lightboxTitle": "Lightbox Preview Tier",
|
||||
"lightboxHelp": "When enabled, the lightbox loads an aspect-preserved ~1920px JPEG (typically 200–500 KB) instead of the full original (often 5–12 MB). Originals are still served when guests click Download. Costs roughly one extra preview file per photo on disk; previews are generated lazily on first open and stored in /previews.",
|
||||
"lightboxToggle": "Use medium-resolution previews in the lightbox",
|
||||
"lightboxToggleHelp": "Off by default. Flip on after deciding the perceived-perf win is worth the extra disk usage.",
|
||||
"regeneratePreviewsButton": "Regenerate All Previews",
|
||||
"previewsRegenerateStarted": "Lightbox preview regeneration started",
|
||||
"previewsRegenerateError": "Failed to start preview regeneration",
|
||||
"saveSuccess": "Thumbnail settings saved",
|
||||
"saveError": "Failed to save thumbnail settings",
|
||||
"loadError": "Failed to load thumbnail settings",
|
||||
@@ -1462,6 +1473,12 @@
|
||||
"sizeMedium": "Medium (default)",
|
||||
"sizeLarge": "Large",
|
||||
"sizeXLarge": "Extra large"
|
||||
},
|
||||
"promo": {
|
||||
"alignment": "Alignment",
|
||||
"alignLeft": "Left",
|
||||
"alignCenter": "Center (default — matches footer)",
|
||||
"alignRight": "Right"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1372
-1309
File diff suppressed because it is too large
Load Diff
@@ -1462,6 +1462,12 @@
|
||||
"sizeMedium": "Middel (standaard)",
|
||||
"sizeLarge": "Groot",
|
||||
"sizeXLarge": "Extra groot"
|
||||
},
|
||||
"promo": {
|
||||
"alignment": "Uitlijning",
|
||||
"alignLeft": "Links",
|
||||
"alignCenter": "Gecentreerd (standaard — komt overeen met footer)",
|
||||
"alignRight": "Rechts"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -1479,6 +1479,12 @@
|
||||
"sizeMedium": "Médio (padrão)",
|
||||
"sizeLarge": "Grande",
|
||||
"sizeXLarge": "Extra grande"
|
||||
},
|
||||
"promo": {
|
||||
"alignment": "Alinhamento",
|
||||
"alignLeft": "Esquerda",
|
||||
"alignCenter": "Centro (padrão — igual ao rodapé)",
|
||||
"alignRight": "Direita"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -1496,6 +1496,12 @@
|
||||
"sizeMedium": "Средний (по умолчанию)",
|
||||
"sizeLarge": "Большой",
|
||||
"sizeXLarge": "Очень большой"
|
||||
},
|
||||
"promo": {
|
||||
"alignment": "Выравнивание",
|
||||
"alignLeft": "По левому краю",
|
||||
"alignCenter": "По центру (по умолчанию — как в подвале)",
|
||||
"alignRight": "По правому краю"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -43,6 +43,7 @@ export const BrandingPage: React.FC = () => {
|
||||
youtube_url: '',
|
||||
promo_markdown: '',
|
||||
promo_position: 'above_footer',
|
||||
promo_alignment: 'center',
|
||||
});
|
||||
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
|
||||
@@ -420,18 +421,36 @@ export const BrandingPage: React.FC = () => {
|
||||
{t('branding.promo.help', 'Markdown shown above or below the gallery footer (e.g. seasonal offer, print discount). Per-event overrides take priority.')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.promo.position', 'Position')}
|
||||
</label>
|
||||
<select
|
||||
value={brandingSettings.promo_position || 'above_footer'}
|
||||
onChange={(e) => handleBrandingChange('promo_position', e.target.value as 'above_footer' | 'below_footer')}
|
||||
className="w-full sm:w-64 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="above_footer">{t('branding.promo.aboveFooter', 'Above footer')}</option>
|
||||
<option value="below_footer">{t('branding.promo.belowFooter', 'Below footer')}</option>
|
||||
</select>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.promo.position', 'Position')}
|
||||
</label>
|
||||
<select
|
||||
value={brandingSettings.promo_position || 'above_footer'}
|
||||
onChange={(e) => handleBrandingChange('promo_position', e.target.value as 'above_footer' | 'below_footer')}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="above_footer">{t('branding.promo.aboveFooter', 'Above footer')}</option>
|
||||
<option value="below_footer">{t('branding.promo.belowFooter', 'Below footer')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Horizontal alignment (#482). Defaults to center
|
||||
so the banner aligns with the gallery footer. */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.promo.alignment', 'Alignment')}
|
||||
</label>
|
||||
<select
|
||||
value={brandingSettings.promo_alignment || 'center'}
|
||||
onChange={(e) => handleBrandingChange('promo_alignment', e.target.value as 'left' | 'center' | 'right')}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="left">{t('branding.promo.alignLeft', 'Left')}</option>
|
||||
<option value="center">{t('branding.promo.alignCenter', 'Center (default — matches footer)')}</option>
|
||||
<option value="right">{t('branding.promo.alignRight', 'Right')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
@@ -453,9 +472,16 @@ export const BrandingPage: React.FC = () => {
|
||||
<div className="text-xs uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('branding.promo.preview', 'Preview')}
|
||||
</div>
|
||||
{/* Preview mirrors the live gallery render — same
|
||||
alignment class so the admin sees what guests
|
||||
will see (#482). */}
|
||||
<MarkdownContent
|
||||
source={brandingSettings.promo_markdown}
|
||||
className="text-sm text-neutral-800 dark:text-neutral-200 prose-sm prose-a:text-primary-600 dark:prose-a:text-primary-400"
|
||||
className={`text-sm text-neutral-800 dark:text-neutral-200 prose prose-sm prose-a:text-primary-600 dark:prose-a:text-primary-400 ${
|
||||
brandingSettings.promo_alignment === 'left' ? 'text-left'
|
||||
: brandingSettings.promo_alignment === 'right' ? 'text-right'
|
||||
: 'text-center'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -303,7 +303,7 @@ export const CMSPage: React.FC = () => {
|
||||
const branding = publicSiteBranding || publicSiteDefaults?.branding;
|
||||
const substitutedHtml = applyBrandTokens(publicSiteSanitizedHtml, branding);
|
||||
const inlineStyles = [
|
||||
branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n}` : '',
|
||||
branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n --brand-surface: ${branding.colors.surface || '#ffffff'};\n --brand-elevated: ${branding.colors.elevated || '#f5f5f5'};\n --brand-border: ${branding.colors.border || '#e5e5e5'};\n --brand-muted-text: ${branding.colors.mutedText || '#737373'};\n}` : '',
|
||||
publicSiteBaseCss,
|
||||
publicSiteSanitizedCss ? `/* Custom styles */\n${publicSiteSanitizedCss}` : ''
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
@@ -246,6 +246,25 @@ export const CreateEventPage: React.FC = () => {
|
||||
}));
|
||||
}, [publicSettings]);
|
||||
|
||||
// Honour the global "Enable Guest Feedback by default" admin setting (#520).
|
||||
// Same one-shot apply pattern as require_password above — only seeds the
|
||||
// master toggle. The sub-toggles (likes / ratings / comments) keep their
|
||||
// hard-coded true defaults so a flipped master immediately gives sensible
|
||||
// behaviour without a second admin setting to manage.
|
||||
const feedbackEnabledDefaultApplied = useRef(false);
|
||||
useEffect(() => {
|
||||
if (feedbackEnabledDefaultApplied.current) return;
|
||||
if (publicSettings?.event_default_feedback_enabled === undefined) return;
|
||||
feedbackEnabledDefaultApplied.current = true;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
feedback_settings: {
|
||||
...prev.feedback_settings,
|
||||
feedback_enabled: publicSettings.event_default_feedback_enabled === true
|
||||
}
|
||||
}));
|
||||
}, [publicSettings]);
|
||||
|
||||
// Apply the global Branding default theme on first load so admins who set a
|
||||
// site-wide default in Branding actually see it on new events (#323).
|
||||
// This is the "always inherit colours from Branding" guarantee — every new
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
import { SUPPORTED_LANGUAGES } from '../../components/common/LanguageSelector';
|
||||
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
|
||||
import {
|
||||
customerAdminService,
|
||||
@@ -227,11 +228,12 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
onChange={setField('preferredLanguage')}
|
||||
className="input"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="nl">Nederlands</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="ru">Русский</option>
|
||||
{/* Drive the option list from SUPPORTED_LANGUAGES so adding a
|
||||
locale (#510 added es; fr was already missing here) only
|
||||
needs to touch LanguageSelector. */}
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>{lang.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -372,6 +372,12 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [logoUploading, setLogoUploading] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||
// Tracks whether the admin actually interacted with the theme picker
|
||||
// during this edit session. Prevents the save handler from writing the
|
||||
// initial display state back to `events.color_theme`, which silently
|
||||
// overwrote branding inheritance on events with a NULL color_theme
|
||||
// (API-created events — #550 follow-up).
|
||||
const [themeChanged, setThemeChanged] = useState(false);
|
||||
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
|
||||
|
||||
// Fetch CSS templates when component mounts or editing starts
|
||||
@@ -643,10 +649,20 @@ export const EventDetailsPage: React.FC = () => {
|
||||
setCurrentPresetName('default');
|
||||
}
|
||||
} else {
|
||||
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
|
||||
setCurrentPresetName('default');
|
||||
// No color_theme stored — the gallery renders with the site
|
||||
// branding theme as a fallback. Mirror that here so the picker
|
||||
// shows the same palette the admin sees on the gallery, rather
|
||||
// than the hardcoded Classic Grid preset that has nothing to do
|
||||
// with their branding (#550 follow-up). currentPresetName=custom
|
||||
// because the inherited config isn't a named preset; combined
|
||||
// with themeChanged=false below, saving without touching the
|
||||
// picker leaves color_theme NULL and preserves inheritance.
|
||||
const branding = publicSettings?.theme_config as ThemeConfig | undefined;
|
||||
setCurrentTheme(branding ?? GALLERY_THEME_PRESETS.default.config);
|
||||
setCurrentPresetName(branding ? 'custom' : 'default');
|
||||
}
|
||||
|
||||
setThemeChanged(false);
|
||||
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
@@ -765,7 +781,11 @@ export const EventDetailsPage: React.FC = () => {
|
||||
if (editForm.welcome_message !== undefined && editForm.welcome_message !== null) {
|
||||
updateData.welcome_message = editForm.welcome_message;
|
||||
}
|
||||
if (themeToSave) {
|
||||
// Only persist color_theme when the admin actually interacted with
|
||||
// the picker. Writing the initial display state back to the row
|
||||
// silently overwrote NULL (= "inherit branding") with the picker's
|
||||
// default preset on any save (#550 follow-up).
|
||||
if (themeChanged && themeToSave) {
|
||||
updateData.color_theme = themeToSave;
|
||||
}
|
||||
if (editForm.upload_category_id !== undefined) {
|
||||
@@ -2210,10 +2230,12 @@ export const EventDetailsPage: React.FC = () => {
|
||||
onChange={(theme) => {
|
||||
setCurrentTheme(theme);
|
||||
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(theme) }));
|
||||
setThemeChanged(true);
|
||||
}}
|
||||
presetName={currentPresetName}
|
||||
onPresetChange={(presetName) => {
|
||||
setCurrentPresetName(presetName);
|
||||
setThemeChanged(true);
|
||||
if (presetName !== 'custom') {
|
||||
const preset = GALLERY_THEME_PRESETS[presetName];
|
||||
if (preset) {
|
||||
@@ -2248,6 +2270,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
setCurrentTheme(merged);
|
||||
setCurrentPresetName('custom');
|
||||
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(merged) }));
|
||||
setThemeChanged(true);
|
||||
toast.success(t('toast.brandingPaletteSynced', 'Palette synced from Branding.'));
|
||||
}}
|
||||
isPreviewMode={true}
|
||||
|
||||
@@ -9,6 +9,30 @@ import { cmsService } from '../../services/cms.service';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import '../../styles/prose-overrides.css';
|
||||
|
||||
// Force rel="noopener noreferrer" on target="_blank" anchors in CMS-authored
|
||||
// HTML so editors can't accidentally (or maliciously) introduce reverse
|
||||
// tabnabbing via the legal pages.
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
// CMS-configured external_url may be edited by lower-privileged staff; reject
|
||||
// anything outside http(s) so the legal route can't be turned into a
|
||||
// javascript:/data: launcher.
|
||||
const sanitizeExternalUrl = (url: string): string | null => {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
return null;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const LegalPage: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { i18n } = useTranslation();
|
||||
@@ -47,12 +71,15 @@ export const LegalPage: React.FC = () => {
|
||||
// External-URL override: full-page redirect so the visitor lands on the
|
||||
// operator's own canonical legal page. Use replace() so the back button
|
||||
// returns to the gallery instead of looping back through the redirect.
|
||||
const willRedirect = !!(page?.use_external_url && page?.external_url);
|
||||
const safeExternalUrl = page?.use_external_url && page?.external_url
|
||||
? sanitizeExternalUrl(page.external_url)
|
||||
: null;
|
||||
const willRedirect = !!safeExternalUrl;
|
||||
useEffect(() => {
|
||||
if (willRedirect && page?.external_url) {
|
||||
window.location.replace(page.external_url);
|
||||
if (safeExternalUrl) {
|
||||
window.location.replace(safeExternalUrl);
|
||||
}
|
||||
}, [willRedirect, page?.external_url]);
|
||||
}, [safeExternalUrl]);
|
||||
|
||||
if (isLoading || willRedirect) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Coverage for #557 — iOS Web Share path on multi-photo selection.
|
||||
*
|
||||
* downloadSelectedPhotos historically POSTed to /download-selected and
|
||||
* triggered a zip download. On iOS with a small selection it now routes
|
||||
* through navigator.share({ files }) so the photos land in Photos via
|
||||
* the share sheet's "Save N Images" action. Above the file-count cap
|
||||
* or anywhere off-iOS, behaviour is unchanged.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15';
|
||||
const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/120.0.0.0';
|
||||
|
||||
const fetchedFor = (id: number) => ({
|
||||
blob: new Blob([`photo-${id}`], { type: 'image/jpeg' }),
|
||||
serverFilename: `IMG_${String(id).padStart(4, '0')}.jpg`,
|
||||
});
|
||||
|
||||
// Mock the axios layer so the test never makes a network call. The
|
||||
// real api.post resolves with { data: Blob } for the zip path; the
|
||||
// shape only matters when the fallback branch is exercised.
|
||||
vi.mock('../../config/api', () => ({
|
||||
api: {
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
let galleryService: typeof import('../gallery.service').galleryService;
|
||||
let apiMock: { post: ReturnType<typeof vi.fn>; get: ReturnType<typeof vi.fn> };
|
||||
|
||||
const installNavigator = (overrides: Partial<{
|
||||
userAgent: string;
|
||||
platform: string;
|
||||
maxTouchPoints: number;
|
||||
share: ReturnType<typeof vi.fn>;
|
||||
canShare: ReturnType<typeof vi.fn>;
|
||||
}>) => {
|
||||
const desc = (value: any) => ({ value, configurable: true, writable: true });
|
||||
Object.defineProperties(navigator, {
|
||||
userAgent: desc(overrides.userAgent ?? ''),
|
||||
platform: desc(overrides.platform ?? ''),
|
||||
maxTouchPoints: desc(overrides.maxTouchPoints ?? 0),
|
||||
});
|
||||
(navigator as any).share = overrides.share;
|
||||
(navigator as any).canShare = overrides.canShare;
|
||||
};
|
||||
|
||||
describe('galleryService.downloadSelectedPhotos — iOS Web Share path (#557)', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const services = await import('../gallery.service');
|
||||
galleryService = services.galleryService;
|
||||
apiMock = (await import('../../config/api')).api as any;
|
||||
apiMock.post.mockReset();
|
||||
apiMock.post.mockResolvedValue({ data: new Blob(['zip-bytes'], { type: 'application/zip' }) });
|
||||
|
||||
// jsdom doesn't ship URL.createObjectURL / revokeObjectURL —
|
||||
// the zip-fallback path needs both to materialise the <a> link.
|
||||
(window.URL.createObjectURL as any) = vi.fn(() => 'blob:fake');
|
||||
(window.URL.revokeObjectURL as any) = vi.fn();
|
||||
|
||||
// fetchPhotoBlob is the network-dependent helper; stub it across
|
||||
// every test so we never touch the real download endpoint.
|
||||
vi.spyOn(galleryService, 'fetchPhotoBlob').mockImplementation((_slug, id) =>
|
||||
Promise.resolve(fetchedFor(id) as any),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (navigator as any).share;
|
||||
delete (navigator as any).canShare;
|
||||
});
|
||||
|
||||
it('routes through navigator.share on iOS with a small selection', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||
|
||||
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
|
||||
|
||||
expect(share).toHaveBeenCalledTimes(1);
|
||||
const shareArg = share.mock.calls[0][0];
|
||||
expect(shareArg.files).toHaveLength(3);
|
||||
expect((shareArg.files[0] as File).name).toBe('IMG_0001.jpg');
|
||||
// Zip endpoint must NOT be called when share succeeds.
|
||||
expect(apiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the zip endpoint on Android, even with canShare available', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
installNavigator({ userAgent: ANDROID_UA, share, canShare });
|
||||
|
||||
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
|
||||
|
||||
expect(share).not.toHaveBeenCalled();
|
||||
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.post).toHaveBeenCalledWith(
|
||||
'/gallery/wedding-2026/download-selected',
|
||||
{ photo_ids: [1, 2, 3] },
|
||||
{ responseType: 'blob' },
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the zip endpoint above the 25-file cap', async () => {
|
||||
// 26 photos: even on iOS, this exceeds MAX_WEB_SHARE_FILES so the
|
||||
// Web Share path is skipped entirely (no fetchPhotoBlob calls,
|
||||
// no share() call, no canShare() probe).
|
||||
const share = vi.fn();
|
||||
const canShare = vi.fn();
|
||||
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||
|
||||
const ids = Array.from({ length: 26 }, (_, i) => i + 1);
|
||||
await galleryService.downloadSelectedPhotos('wedding-2026', ids);
|
||||
|
||||
expect(galleryService.fetchPhotoBlob).not.toHaveBeenCalled();
|
||||
expect(share).not.toHaveBeenCalled();
|
||||
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('takes the Web Share path at exactly the 25-file boundary', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||
|
||||
const ids = Array.from({ length: 25 }, (_, i) => i + 1);
|
||||
await galleryService.downloadSelectedPhotos('wedding-2026', ids);
|
||||
|
||||
expect(share).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT fall back to the zip endpoint when the user dismisses the share sheet (AbortError)', async () => {
|
||||
const abortErr = Object.assign(new Error('user dismissed'), { name: 'AbortError' });
|
||||
const share = vi.fn().mockRejectedValue(abortErr);
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||
|
||||
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
|
||||
|
||||
expect(share).toHaveBeenCalledTimes(1);
|
||||
// A surprise zip in Downloads would defeat the user's deliberate
|
||||
// dismissal of the share sheet.
|
||||
expect(apiMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the zip endpoint when share() rejects with a non-Abort error', async () => {
|
||||
const notAllowed = Object.assign(new Error('blocked'), { name: 'NotAllowedError' });
|
||||
const share = vi.fn().mockRejectedValue(notAllowed);
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||
|
||||
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
|
||||
|
||||
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the zip endpoint when canShare({files}) returns false (older iOS)', async () => {
|
||||
const share = vi.fn();
|
||||
const canShare = vi.fn().mockReturnValue(false);
|
||||
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||
|
||||
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
|
||||
|
||||
expect(share).not.toHaveBeenCalled();
|
||||
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the zip endpoint when any photo fetch fails (partial shares would be confusing)', async () => {
|
||||
const share = vi.fn();
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||
|
||||
// Re-stub fetchPhotoBlob so the 2nd of 3 photos fails — the Promise.all
|
||||
// collapse must route the whole batch to the zip endpoint rather
|
||||
// than sharing only the photos that resolved.
|
||||
(galleryService.fetchPhotoBlob as any).mockReset();
|
||||
(galleryService.fetchPhotoBlob as any)
|
||||
.mockResolvedValueOnce(fetchedFor(1))
|
||||
.mockRejectedValueOnce(new Error('network'))
|
||||
.mockResolvedValueOnce(fetchedFor(3));
|
||||
|
||||
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
|
||||
|
||||
expect(share).not.toHaveBeenCalled();
|
||||
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the zip endpoint when the selection is empty (no Web Share invocation)', async () => {
|
||||
const share = vi.fn();
|
||||
const canShare = vi.fn();
|
||||
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||
|
||||
await galleryService.downloadSelectedPhotos('wedding-2026', []);
|
||||
|
||||
expect(galleryService.fetchPhotoBlob).not.toHaveBeenCalled();
|
||||
expect(share).not.toHaveBeenCalled();
|
||||
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Regression coverage for issue #554.
|
||||
*
|
||||
* `savePhotoToDevice` originally (PR #531) routed through navigator.share
|
||||
* whenever `canShare({files})` returned true, on the assumption that any
|
||||
* mobile share sheet would expose a "Save Image" action. That's only true
|
||||
* on iOS — Android's share sheet only lists installed apps that handle
|
||||
* image/* intents, so the user gets an app-picker instead of a save
|
||||
* dialog. These tests pin the iOS-only gating: iOS goes through Web Share,
|
||||
* everywhere else falls through to <a download>.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15';
|
||||
const IPADOS_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
|
||||
const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/120.0.0.0';
|
||||
const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15';
|
||||
|
||||
// Stub the photo blob fetch + the <a download> trigger so the test
|
||||
// only exercises the iOS-vs-other branching logic. fetchPhotoBlob is
|
||||
// network; triggerBrowserDownload calls document.createElement + click
|
||||
// which is side-effecty in jsdom (and not what we're testing here).
|
||||
const fetchedBlob = { blob: new Blob(['x'], { type: 'image/jpeg' }), serverFilename: 'IMG_0001.jpg' };
|
||||
|
||||
let galleryService: typeof import('../gallery.service').galleryService;
|
||||
|
||||
const installNavigator = (overrides: Partial<{
|
||||
userAgent: string;
|
||||
platform: string;
|
||||
maxTouchPoints: number;
|
||||
share: ReturnType<typeof vi.fn>;
|
||||
canShare: ReturnType<typeof vi.fn>;
|
||||
}>) => {
|
||||
const desc = (value: any) => ({ value, configurable: true, writable: true });
|
||||
Object.defineProperties(navigator, {
|
||||
userAgent: desc(overrides.userAgent ?? ''),
|
||||
platform: desc(overrides.platform ?? ''),
|
||||
maxTouchPoints: desc(overrides.maxTouchPoints ?? 0),
|
||||
});
|
||||
// share / canShare don't exist on jsdom's navigator by default, so
|
||||
// they're plain assignments rather than defineProperty.
|
||||
(navigator as any).share = overrides.share;
|
||||
(navigator as any).canShare = overrides.canShare;
|
||||
};
|
||||
|
||||
describe('galleryService.savePhotoToDevice — iOS gating (#554)', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
galleryService = (await import('../gallery.service')).galleryService;
|
||||
|
||||
vi.spyOn(galleryService, 'fetchPhotoBlob').mockResolvedValue(fetchedBlob as any);
|
||||
vi.spyOn(galleryService, 'triggerBrowserDownload').mockImplementation(() => undefined);
|
||||
vi.spyOn(galleryService, 'triggerDirectDownload').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (navigator as any).share;
|
||||
delete (navigator as any).canShare;
|
||||
});
|
||||
|
||||
it('routes through navigator.share on iOS when canShare({files}) is true', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||
|
||||
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
|
||||
|
||||
expect(share).toHaveBeenCalledTimes(1);
|
||||
expect(canShare).toHaveBeenCalledWith({ files: expect.any(Array) });
|
||||
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
|
||||
expect(galleryService.triggerDirectDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('navigates straight to the download URL on Android (no blob round-trip)', async () => {
|
||||
// #554 fix: canShare is true on Chrome Android but the share sheet
|
||||
// has no "Save Image" action, so the share path is iOS-only. The
|
||||
// follow-up issue (Rekoo-PS, post-#556) was that the Android
|
||||
// fallback fetched the blob through JS before clicking <a download>,
|
||||
// adding ~5s of dead air before the browser's download UI appeared
|
||||
// and prompting users to re-click. Going straight to the download
|
||||
// URL hands the fetch to the browser, which shows its own progress
|
||||
// immediately — no spinner needed.
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
installNavigator({ userAgent: ANDROID_UA, share, canShare });
|
||||
|
||||
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
|
||||
|
||||
expect(share).not.toHaveBeenCalled();
|
||||
expect(galleryService.fetchPhotoBlob).not.toHaveBeenCalled();
|
||||
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
|
||||
expect(galleryService.triggerDirectDownload).toHaveBeenCalledTimes(1);
|
||||
expect(galleryService.triggerDirectDownload).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/gallery\/slug\/download\/1$/),
|
||||
'fallback.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates straight to the download URL on desktop Safari (no share / canShare APIs)', async () => {
|
||||
installNavigator({ userAgent: DESKTOP_UA });
|
||||
|
||||
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
|
||||
|
||||
expect(galleryService.fetchPhotoBlob).not.toHaveBeenCalled();
|
||||
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
|
||||
expect(galleryService.triggerDirectDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('detects iPadOS 13+ (reports as MacIntel + touch) as iOS', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
installNavigator({
|
||||
userAgent: IPADOS_UA,
|
||||
platform: 'MacIntel',
|
||||
maxTouchPoints: 5,
|
||||
share,
|
||||
canShare,
|
||||
});
|
||||
|
||||
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
|
||||
|
||||
expect(share).toHaveBeenCalledTimes(1);
|
||||
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
|
||||
expect(galleryService.triggerDirectDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT treat a regular Mac (MacIntel + no touch) as iOS', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
installNavigator({
|
||||
userAgent: DESKTOP_UA,
|
||||
platform: 'MacIntel',
|
||||
maxTouchPoints: 0,
|
||||
share,
|
||||
canShare,
|
||||
});
|
||||
|
||||
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
|
||||
|
||||
expect(share).not.toHaveBeenCalled();
|
||||
expect(galleryService.triggerDirectDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fall back to download when the user dismisses the iOS share sheet (AbortError)', async () => {
|
||||
// AbortError signals a deliberate user dismissal; falling back to a
|
||||
// download would surprise them with a file landing in Downloads
|
||||
// anyway, defeating the dismissal.
|
||||
const abortErr = Object.assign(new Error('user cancelled'), { name: 'AbortError' });
|
||||
const share = vi.fn().mockRejectedValue(abortErr);
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||
|
||||
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
|
||||
|
||||
expect(share).toHaveBeenCalledTimes(1);
|
||||
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does fall back to download when navigator.share() rejects with a non-Abort error', async () => {
|
||||
const otherErr = Object.assign(new Error('not allowed'), { name: 'NotAllowedError' });
|
||||
const share = vi.fn().mockRejectedValue(otherErr);
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||
|
||||
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
|
||||
|
||||
expect(galleryService.triggerBrowserDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,34 @@
|
||||
import { api } from '../config/api';
|
||||
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
import { parseContentDispositionFilename } from '../utils/contentDisposition';
|
||||
|
||||
// iOS is the only platform whose system share sheet exposes a
|
||||
// first-party "Save Image" / "Save to Photos" action for files
|
||||
// shared via navigator.share(). On Android the share sheet only
|
||||
// lists installed apps that registered an image/* intent (WhatsApp,
|
||||
// Telegram, etc.) — there is no built-in save-to-gallery action,
|
||||
// so the share path produces a useless app-picker for users who
|
||||
// just wanted to save the photo (#554). UA-sniff is the only signal
|
||||
// available because feature detection (canShare) is true on both.
|
||||
//
|
||||
// The MacIntel + maxTouchPoints clause covers iPadOS 13+ which
|
||||
// identifies as Mac in navigator.userAgent but supports the same
|
||||
// share-to-Photos flow as iOS Safari.
|
||||
function isIOS(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
const ua = navigator.userAgent || '';
|
||||
if (/iPad|iPhone|iPod/.test(ua)) return true;
|
||||
return navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1;
|
||||
}
|
||||
|
||||
// Hard cap on the multi-file Web Share path (#557). iOS Safari's share
|
||||
// sheet starts to choke and silently fail beyond ~25–30 files in
|
||||
// practice; equally important, every File materialises as an in-memory
|
||||
// Blob before share() is invoked, so a 500-photo @ 10 MB selection
|
||||
// would buffer 5 GB on the device. Above this cap we fall through to
|
||||
// the existing server-side zip flow.
|
||||
const MAX_WEB_SHARE_FILES = 25;
|
||||
|
||||
export const galleryService = {
|
||||
// Verify share token
|
||||
@@ -47,40 +75,129 @@ export const galleryService = {
|
||||
};
|
||||
},
|
||||
|
||||
// Download single photo
|
||||
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
||||
// Save single photo. iOS routes through the Web Share API so the
|
||||
// share sheet's "Save Image" action lands the file in Photos.
|
||||
// Everywhere else (Android, desktop) navigates a hidden anchor
|
||||
// straight at the download URL — the browser's native download UI
|
||||
// shows up immediately and its progress lives in the notification
|
||||
// shade. Buffering the blob through fetch first (the original
|
||||
// path) added ~5s of dead air on cellular before any visible
|
||||
// feedback, prompting users to re-click and produce duplicate
|
||||
// downloads (#554 follow-up). Direct navigation eliminates the
|
||||
// latency outright rather than masking it with a spinner.
|
||||
async savePhotoToDevice(slug: string, photoId: number, filename: string): Promise<void> {
|
||||
if (!isIOS()) {
|
||||
this.triggerDirectDownload(
|
||||
api.getUri({ url: `/gallery/${slug}/download/${photoId}` }),
|
||||
filename,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetched = await this.fetchPhotoBlob(slug, photoId);
|
||||
const resolvedFilename = fetched.serverFilename || filename;
|
||||
|
||||
// canShare() returns false on browsers without Web Share file
|
||||
// support. Probe with a representative File so the negotiation
|
||||
// is accurate — `canShare({ files: [] })` returns true on some
|
||||
// browsers that don't actually accept files at share() time.
|
||||
const file = new File([fetched.blob], resolvedFilename, {
|
||||
type: fetched.blob.type || 'image/jpeg',
|
||||
});
|
||||
const canShareFile =
|
||||
typeof navigator !== 'undefined' &&
|
||||
typeof navigator.canShare === 'function' &&
|
||||
navigator.canShare({ files: [file] });
|
||||
|
||||
if (canShareFile) {
|
||||
try {
|
||||
await navigator.share({ files: [file], title: resolvedFilename });
|
||||
return;
|
||||
} catch (err) {
|
||||
// AbortError = user dismissed the share sheet. Don't fall back —
|
||||
// they made a choice. Any other failure (NotAllowedError,
|
||||
// DataError, etc.) is unexpected; surface a download instead so
|
||||
// the user still gets the file.
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
|
||||
this.triggerBrowserDownload(fetched.blob, resolvedFilename);
|
||||
},
|
||||
|
||||
// Fetch the photo as a Blob + the server-suggested filename, falling
|
||||
// back to the view endpoint when the original isn't available. Shared
|
||||
// between the regular download flow and the Web Share path (#531).
|
||||
// The server's Content-Disposition is the source of truth for the
|
||||
// filename (#493 — "use original camera filename" toggle reaches disk
|
||||
// through this header).
|
||||
async fetchPhotoBlob(
|
||||
slug: string,
|
||||
photoId: number,
|
||||
): Promise<{ blob: Blob; serverFilename: string | null }> {
|
||||
const readResponse = (response: { data: Blob; headers: Record<string, string> }) => {
|
||||
const headerName =
|
||||
response.headers['content-disposition'] || response.headers['Content-Disposition'];
|
||||
return {
|
||||
blob: response.data,
|
||||
serverFilename: parseContentDispositionFilename(headerName),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
// Fallback: use the view endpoint if direct download fails (e.g., missing original)
|
||||
try {
|
||||
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (fallbackErr) {
|
||||
throw fallbackErr;
|
||||
}
|
||||
return readResponse(response);
|
||||
} catch {
|
||||
// Fallback: view endpoint when /download isn't available (e.g.
|
||||
// the original is missing and only a derivative remains). The
|
||||
// view endpoint doesn't emit a download-oriented Content-Disposition,
|
||||
// so serverFilename will be null and the caller's name wins.
|
||||
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return readResponse(response);
|
||||
}
|
||||
},
|
||||
|
||||
// Trigger a regular browser download via a transient <a download>
|
||||
// anchor. Extracted from downloadPhoto so the share-fallback path
|
||||
// can reuse it without re-fetching the blob.
|
||||
triggerBrowserDownload(blob: Blob, filename: string): void {
|
||||
const url = window.URL.createObjectURL(new Blob([blob]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// Trigger a browser-native download by navigating a hidden anchor at
|
||||
// the URL directly. The browser fetches the response itself (showing
|
||||
// its own progress UI), so unlike triggerBrowserDownload the JS layer
|
||||
// never materialises the bytes. `filename` is a hint; the server's
|
||||
// Content-Disposition wins per spec, which is what carries the #493
|
||||
// original-camera-filename setting through to disk.
|
||||
triggerDirectDownload(href: string, filename: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
},
|
||||
|
||||
// Download single photo — kept as the canonical name for the existing
|
||||
// grid + lightbox-action callers that haven't been migrated to the
|
||||
// share-aware savePhotoToDevice path yet.
|
||||
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
||||
const fetched = await this.fetchPhotoBlob(slug, photoId);
|
||||
this.triggerBrowserDownload(fetched.blob, fetched.serverFilename || filename);
|
||||
},
|
||||
|
||||
// Download all photos as ZIP
|
||||
// When a pre-generated zip is available, use native browser download (Content-Length → progress bar).
|
||||
// Otherwise fall back to blob download.
|
||||
@@ -112,8 +229,24 @@ export const galleryService = {
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// Download selected photos as ZIP
|
||||
// Download selected photos. On iOS with a small selection, route
|
||||
// through Web Share so the files land directly in Photos via the
|
||||
// share sheet's "Save N Images" action (#557, extending #531 to the
|
||||
// multi-photo case). Above the cap, or anywhere else, fall through
|
||||
// to the existing server-side zip flow.
|
||||
async downloadSelectedPhotos(slug: string, photoIds: number[]): Promise<void> {
|
||||
if (
|
||||
isIOS() &&
|
||||
photoIds.length > 0 &&
|
||||
photoIds.length <= MAX_WEB_SHARE_FILES
|
||||
) {
|
||||
const status = await this.trySaveMultipleToDevice(slug, photoIds);
|
||||
// 'shared' = share() resolved; 'dismissed' = user closed the
|
||||
// share sheet — both terminate the flow without touching the
|
||||
// zip path. Only 'fallback' continues below.
|
||||
if (status !== 'fallback') return;
|
||||
}
|
||||
|
||||
const response = await api.post(`/gallery/${slug}/download-selected`, { photo_ids: photoIds }, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
@@ -128,6 +261,53 @@ export const galleryService = {
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// iOS-only Web Share path for a selection of photos.
|
||||
//
|
||||
// Returns:
|
||||
// 'shared' — navigator.share resolved; files are now in the OS share sheet
|
||||
// 'dismissed' — user cancelled the share sheet (AbortError); do NOT fall back
|
||||
// 'fallback' — capability missing or unexpected failure; caller should
|
||||
// use the server-side zip path instead
|
||||
//
|
||||
// Callers must gate by isIOS() + count <= MAX_WEB_SHARE_FILES before
|
||||
// invoking this; the method does not re-check those conditions.
|
||||
async trySaveMultipleToDevice(
|
||||
slug: string,
|
||||
photoIds: number[],
|
||||
): Promise<'shared' | 'dismissed' | 'fallback'> {
|
||||
let fetched: Array<{ blob: Blob; serverFilename: string | null }>;
|
||||
try {
|
||||
// Parallel fetch — modern browsers cap at ~6 connections per origin
|
||||
// on HTTP/1.1, unlimited on HTTP/2, so 25 concurrent requests is
|
||||
// safe without an explicit semaphore. A single failed fetch
|
||||
// collapses the whole selection back to the zip path; partial
|
||||
// shares would leave the user wondering which photos were saved.
|
||||
fetched = await Promise.all(photoIds.map((id) => this.fetchPhotoBlob(slug, id)));
|
||||
} catch {
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
const files = fetched.map((entry, idx) => {
|
||||
const name = entry.serverFilename || `photo-${photoIds[idx]}.jpg`;
|
||||
return new File([entry.blob], name, { type: entry.blob.type || 'image/jpeg' });
|
||||
});
|
||||
|
||||
const canShareFiles =
|
||||
typeof navigator !== 'undefined' &&
|
||||
typeof navigator.canShare === 'function' &&
|
||||
navigator.canShare({ files });
|
||||
|
||||
if (!canShareFiles) return 'fallback';
|
||||
|
||||
try {
|
||||
await navigator.share({ files });
|
||||
return 'shared';
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return 'dismissed';
|
||||
return 'fallback';
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle photo visibility (client-only)
|
||||
async togglePhotoVisibility(slug: string, photoId: number, visibility: 'visible' | 'hidden'): Promise<void> {
|
||||
await api.patch(`/gallery/${slug}/photos/${photoId}/visibility`, { visibility });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from '../config/api';
|
||||
import { parseContentDispositionFilename } from '../utils/contentDisposition';
|
||||
|
||||
export interface AdminPhoto {
|
||||
id: number;
|
||||
@@ -102,11 +103,18 @@ class PhotosService {
|
||||
const response = await api.get(`/admin/events/${eventId}/photos/${photoId}/download`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
|
||||
// Read the filename from the server's Content-Disposition so the
|
||||
// #493 original-filename toggle reaches disk for admin downloads
|
||||
// too (see contentDisposition.ts).
|
||||
const headerName =
|
||||
response.headers['content-disposition'] || response.headers['Content-Disposition'];
|
||||
const serverFilename = parseContentDispositionFilename(headerName);
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.download = serverFilename || filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
@@ -39,6 +39,9 @@ export interface PublicSettings {
|
||||
branding_youtube_url?: string;
|
||||
branding_promo_markdown?: string;
|
||||
branding_promo_position?: 'above_footer' | 'below_footer';
|
||||
// Per-install promo banner alignment (#482). Defaults to 'center'
|
||||
// so the banner aligns with the gallery footer's centering.
|
||||
branding_promo_alignment?: 'left' | 'center' | 'right';
|
||||
theme_config: any;
|
||||
default_language: string;
|
||||
enable_analytics: boolean;
|
||||
@@ -59,6 +62,7 @@ export interface PublicSettings {
|
||||
event_require_event_date?: boolean;
|
||||
event_require_expiration?: boolean;
|
||||
event_default_require_password?: boolean;
|
||||
event_default_feedback_enabled?: boolean;
|
||||
gallery_show_filter_bar?: boolean;
|
||||
event_phone_field_enabled?: boolean;
|
||||
// SEO meta tags (consumed by RobotsMetaTags)
|
||||
|
||||
@@ -42,6 +42,9 @@ export interface BrandingSettings {
|
||||
youtube_url?: string;
|
||||
promo_markdown?: string;
|
||||
promo_position?: 'above_footer' | 'below_footer';
|
||||
// Per-install promo banner alignment (#482). Defaults to center
|
||||
// so the banner aligns with the gallery footer.
|
||||
promo_alignment?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export interface ThemeSettings {
|
||||
@@ -149,6 +152,10 @@ export interface PublicSiteBranding {
|
||||
accent: string;
|
||||
background: string;
|
||||
text: string;
|
||||
surface?: string;
|
||||
elevated?: string;
|
||||
border?: string;
|
||||
mutedText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -336,7 +343,10 @@ export const settingsService = {
|
||||
twitter_url: rawSettings.branding_twitter_url || '',
|
||||
youtube_url: rawSettings.branding_youtube_url || '',
|
||||
promo_markdown: rawSettings.branding_promo_markdown || '',
|
||||
promo_position: rawSettings.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer'
|
||||
promo_position: rawSettings.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer',
|
||||
promo_alignment: ['left', 'center', 'right'].includes(rawSettings.branding_promo_alignment)
|
||||
? rawSettings.branding_promo_alignment
|
||||
: 'center'
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user