chore(release): promote beta → main as v3.42.1

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.

## Major themes since v2.6.5

* Multi-administrator support with RBAC (super admin / admin / editor)
* Async upload pipeline (background worker pool for sharp/ffmpeg/EXIF/
  watermark/webhooks; bytes-on-wire returns 202)
* Self-hosted webfonts (filesystem-driven scanner; replaces Google Fonts
  CDN; GDPR-compliant)
* 8-token CI palette + force color mode (full theming across admin and
  public site, with WCAG-safe contrast helpers)
* Native multi-arch Docker images (Apple Silicon + ARM64 Linux native)
* Native S3 storage backend (S3 + S3-compatible providers)
* Comprehensive video support (MP4/WebM/MOV upload, stream, play)
* Outbound webhooks for event/photo lifecycle (HMAC-signed)
* Gallery layout overhaul (decoupled header style, banner option,
  theme-aware skeletons, lazy-loaded folder tree picker)
* Multilingual email templates (EN/DE/NL/PT/RU translations table)
* Bulk operations (delete with password gate, archive)
* Photo dimensions backfill (true masonry layout)
* Customer client access (review area before guest share)
* Image security (devtools detection, watermarking, right-click,
  secure thumbnails)

## Notable bug fixes from beta

* `/auth/session` symmetry — three rounds of fixes (#350, #355, #363,
  #398) for the admin-login redirect-loop family
* 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
* 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 via apk for Alpine compatibility (was glibc-bundled)
* Admin events search and counters not bounded to first 100 (#346)

## Conflict resolution notes

* `README.md` — kept main's leaner v2.6.5 rewrite (#281); added a
  Contributors section adapted from PR #393.
* `DEPLOYMENT_GUIDE.md` — beta version (more recent, includes External
  Media docs already backported to main).
* `CHANGELOG.md` — new 3.42.1 entry leads, beta's 3.x history follows,
  main's 2.x entries appended below a divider so the historical chain
  is preserved.
* `package.json` (backend + frontend) — beta's structure with version
  bumped from `3.42.1-beta.0` → `3.42.1`.
* `package-lock.json` (backend + frontend) — regenerated via
  `npm install --package-lock-only`.
* `.release-please-manifest.json` — bumped from `2.6.5` → `3.42.1` so
  the next release-please run on main starts from the correct base.

## Pre-flight checks

* Frontend `tsc --noEmit` — clean
* Frontend `vite build` — clean (~3.5s, 2.6 MB main chunk; existing
  warning about chunking, not new)
* Backend `npm test` — pre-existing failures in 6 integration suites
  (DB-fixture-dependent, not regressions)
* Frontend `vitest` — pre-existing failures in
  ThemeCustomizerEnhanced.test.tsx (missing QueryClientProvider after
  PR #390 added useQuery; not a regression of this merge)

The pre-existing test failures are tracked as separate follow-ups and
do not block this release promotion.
This commit is contained in:
Paul Nothaft
2026-05-07 12:47:45 +02:00
328 changed files with 30942 additions and 5787 deletions
+107
View File
@@ -7,6 +7,34 @@ NODE_ENV=production
# JWT Secret (generate with: openssl rand -base64 64)
JWT_SECRET=your_very_long_random_jwt_secret_here
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
# auto - decide per request: Secure on HTTPS, not on HTTP
#
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
# (via reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
# HTTP (e.g. LAN access at http://192.168.x.x:3010). The backend reads
# req.secure from Express, which respects the X-Forwarded-Proto header
# when the proxy is in the trust list.
#
# Requirements for auto mode:
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
# 192.168.x, link-local). Proxies outside those ranges need custom
# trust proxy configuration.
# COOKIE_SECURE=auto
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
# COOKIE_SAMESITE=Lax
# Cookie Domain — set this if serving auth cookies across subdomains.
# Leave unset for same-origin setups.
# COOKIE_DOMAIN=.example.com
# Database Configuration (PostgreSQL)
DATABASE_CLIENT=pg
DB_USER=picpeak
@@ -86,6 +114,85 @@ APP_STORAGE=./storage
APP_DATA=./data
LOGS=./logs
# ─── Storage Backend ────────────────────────────────────────────────────────
# PicPeak can store photos, thumbnails and archive zips on the local filesystem
# (default) or on any S3-compatible object store (AWS S3, MinIO, Cloudflare R2,
# Backblaze B2, Wasabi, DigitalOcean Spaces, …).
#
# STORAGE_BACKEND=local (default)
# Uses STORAGE_PATH on the local filesystem. Backwards compatible — every
# existing deployment keeps working unchanged.
#
# STORAGE_BACKEND=s3
# Reads STORAGE_S3_* below. Auto-import via the filesystem watcher is
# disabled in this mode (S3 has no inotify) — every photo must enter via the
# admin upload UI/API. Run `node backend/scripts/migrate-storage.js` to copy
# existing local content to S3 before flipping the env.
#
# STORAGE_BACKEND=local
#
# STORAGE_S3_BUCKET=picpeak
# STORAGE_S3_REGION=us-east-1
# STORAGE_S3_ACCESS_KEY=AKIAxxxxxxxxxxxxxxxx
# STORAGE_S3_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxx
# Custom endpoint — set this for MinIO / R2 / B2 / Spaces. Leave unset for AWS.
# STORAGE_S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com
# Optional namespace prefix inside the bucket — useful for multi-deployment buckets.
# STORAGE_S3_PREFIX=picpeak
# STORAGE_S3_FORCE_PATH_STYLE=false # MinIO needs true; auto-on when endpoint is set
# STORAGE_S3_SSL=true
#
# Minimum IAM policy (AWS S3) for the bucket above:
# {
# "Version": "2012-10-17",
# "Statement": [{
# "Effect": "Allow",
# "Action": [
# "s3:GetObject", "s3:PutObject", "s3:DeleteObject",
# "s3:ListBucket", "s3:GetBucketLocation"
# ],
# "Resource": [
# "arn:aws:s3:::picpeak",
# "arn:aws:s3:::picpeak/*"
# ]
# }]
# }
#
# EXTERNAL_MEDIA_ROOT (above) always lives on the local filesystem regardless
# of STORAGE_BACKEND — reference-mode galleries are not migrated to S3 in v1.
# ─── Outbound Webhooks (#327) ────────────────────────────────────────────────
# PicPeak POSTs event/photo lifecycle notifications to URLs you configure
# under Settings → Webhooks. Each delivery is signed HMAC-SHA256 with a
# per-webhook secret in the X-PicPeak-Signature header.
#
# WEBHOOK_ALLOW_PRIVATE_URLS (default: false)
# Block URLs resolving to private IPs / loopback / .local etc. as an
# SSRF mitigation. Set to "true" ONLY in dev when your receiver is on
# the same docker network or localhost. Production deployments must
# leave this OFF.
# WEBHOOK_ALLOW_PRIVATE_URLS=false
#
# WEBHOOK_DELIVERY_INTERVAL_MS (default: 5000)
# How often the worker polls webhook_deliveries for pending rows.
# WEBHOOK_DELIVERY_INTERVAL_MS=5000
#
# WEBHOOK_DELIVERY_CONCURRENCY (default: 5)
# Maximum in-flight deliveries per worker tick. One slow consumer can
# monopolize all 5 slots — bump this if your receivers are slow OR ship
# a separate webhook-only deployment.
# WEBHOOK_DELIVERY_CONCURRENCY=5
#
# WEBHOOK_HTTP_TIMEOUT_MS (default: 10000)
# Per-request timeout. Beyond this, the delivery is recorded as a
# network error and retried.
# WEBHOOK_HTTP_TIMEOUT_MS=10000
#
# WEBHOOK_MAX_ATTEMPTS (default: 5)
# Total attempts before a delivery is marked failed. Backoff between
# attempts is exponential: 1m, 5m, 30m, 2h, 12h.
# WEBHOOK_MAX_ATTEMPTS=5
# Note on FRONTEND_API_URL (documentation only):
# When using pre-built frontend images, runtime env vars cannot override the built JS.
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
+317 -111
View File
@@ -1,10 +1,22 @@
name: Build and Push Docker Images
# This workflow is triggered by:
# - Push to main/develop branches (builds 'latest' or branch-tagged images)
# - Push to main/beta branches (builds 'latest'/'stable' or 'beta' tagged images)
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
# - GitHub Releases (created by Release Please)
# - Pull requests (build verification only, no push by default)
# - Manual workflow dispatch
#
# Multi-arch strategy:
# Each image (backend, frontend) is built once per architecture on a
# native runner — linux/amd64 on ubuntu-latest, linux/arm64 on
# ubuntu-24.04-arm. Each leg pushes by digest to GHCR. A follow-up
# merge job combines the digests into a multi-arch manifest and applies
# the human-readable tags. This is the pattern documented at
# https://docs.docker.com/build/ci/github-actions/multi-platform/
#
# Native runners are used instead of QEMU because npm install under
# QEMU was previously too slow/unreliable for regular branch builds.
on:
push:
@@ -27,51 +39,44 @@ on:
env:
REGISTRY: ghcr.io
BACKEND_IMAGE_NAME: ${{ github.repository }}/backend
FRONTEND_IMAGE_NAME: ${{ github.repository }}/frontend
# BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the
# "Compute image names" step. GHCR requires all-lowercase repository names,
# but ${{ github.repository }} preserves the original case (e.g. "Luca-Timo/...").
# Computing them with bash parameter expansion (${VAR,,}) keeps the workflow
# working on forks regardless of the owner's name casing.
jobs:
# -----------------------------------------------------------------------------
# Backend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
build-backend:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine build context
id: context
- name: Compute image names (lowercase for GHCR)
run: |
# Determine if this is a beta or stable release
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Determine build platforms
id: platforms
- name: Prepare platform pair
run: |
# Only build ARM64 for tagged releases (v*.*.*)
# QEMU emulation is too slow/unreliable for npm operations on regular builds
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "skip_qemu=false" >> $GITHUB_OUTPUT
else
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
echo "skip_qemu=true" >> $GITHUB_OUTPUT
fi
- name: Set up QEMU
if: steps.platforms.outputs.skip_qemu != 'true'
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -86,6 +91,108 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine if pushing
id: push-decision
run: |
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
else
echo "push=true" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata for Backend (labels only)
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Backend
org.opencontainers.image.description=PicPeak photo sharing platform backend service
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
- name: Build Backend image (push by digest)
id: build
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }}
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-backend.outputs.version }}
- name: Export digest
if: steps.push-decision.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: steps.push-decision.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-backend-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-backend:
needs: build-backend
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
# Only run when at least one digest was pushed (i.e. not on PRs without push intent).
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Download digest artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-backend-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Extract metadata for Backend
id: meta-backend
uses: docker/metadata-action@v5
@@ -107,23 +214,15 @@ jobs:
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Build and push Backend Docker image
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
# Always build; only push when registry login succeeded
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
platforms: ${{ steps.platforms.outputs.platforms }}
cache-from: type=gha,scope=backend
cache-to: type=gha,mode=max,scope=backend
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-backend.outputs.version }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
@@ -142,47 +241,37 @@ jobs:
sarif_file: 'trivy-backend.sarif'
category: 'backend-vulnerabilities'
# -----------------------------------------------------------------------------
# Frontend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
build-frontend:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine build context
id: context
- name: Compute image names (lowercase for GHCR)
run: |
# Determine if this is a beta or stable release
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Determine build platforms
id: platforms
- name: Prepare platform pair
run: |
# Only build ARM64 for tagged releases (v*.*.*)
# QEMU emulation is too slow/unreliable for npm operations on regular builds
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "skip_qemu=false" >> $GITHUB_OUTPUT
else
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
echo "skip_qemu=true" >> $GITHUB_OUTPUT
fi
- name: Set up QEMU
if: steps.platforms.outputs.skip_qemu != 'true'
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -197,6 +286,107 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine if pushing
id: push-decision
run: |
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
else
echo "push=true" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata for Frontend (labels only)
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Frontend
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
- name: Build Frontend image (push by digest)
id: build
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }}
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-frontend.outputs.version }}
- name: Export digest
if: steps.push-decision.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: steps.push-decision.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-frontend-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-frontend:
needs: build-frontend
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Download digest artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-frontend-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Extract metadata for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
@@ -218,23 +408,15 @@ jobs:
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
# Always build; only push when registry login succeeded
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
platforms: ${{ steps.platforms.outputs.platforms }}
cache-from: type=gha,scope=frontend
cache-to: type=gha,mode=max,scope=frontend
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-frontend.outputs.version }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
@@ -253,45 +435,69 @@ jobs:
sarif_file: 'trivy-frontend.sarif'
category: 'frontend-vulnerabilities'
# Note: The publish-manifest job is not needed since docker/build-push-action@v5
# automatically creates multi-arch manifests when building for multiple platforms.
# The images are already properly tagged and include all architectures.
summary:
needs: [build-backend, build-frontend]
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
if: always()
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Build Summary
run: |
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.build-backend.result }}" == "success" ]]; then
echo "✅ **Backend**: Successfully built" >> $GITHUB_STEP_SUMMARY
echo "✅ **Backend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Backend**: Build failed" >> $GITHUB_STEP_SUMMARY
echo "❌ **Backend build (per-arch)**: ${{ needs.build-backend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.merge-backend.result }}" == "success" ]]; then
echo "✅ **Backend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.merge-backend.result }}" == "skipped" ]]; then
echo "️ **Backend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Backend manifest merge**: ${{ needs.merge-backend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then
echo "✅ **Frontend**: Successfully built" >> $GITHUB_STEP_SUMMARY
echo "✅ **Frontend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Frontend**: Build failed" >> $GITHUB_STEP_SUMMARY
echo "❌ **Frontend build (per-arch)**: ${{ needs.build-frontend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.merge-frontend.result }}" == "success" ]]; then
echo "✅ **Frontend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.merge-frontend.result }}" == "skipped" ]]; then
echo "️ **Frontend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Frontend manifest merge**: ${{ needs.merge-frontend.result }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
echo "Published manifests include both \`linux/amd64\` and \`linux/arm64\` (built natively, no QEMU)." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
echo "Images are tagged based on:" >> $GITHUB_STEP_SUMMARY
echo "- Branch name (for branch pushes)" >> $GITHUB_STEP_SUMMARY
echo "- PR number (for pull requests)" >> $GITHUB_STEP_SUMMARY
echo "- PR number (for pull requests, when push is enabled)" >> $GITHUB_STEP_SUMMARY
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
echo "- Short SHA with branch prefix" >> $GITHUB_STEP_SUMMARY
echo "- Short SHA" >> $GITHUB_STEP_SUMMARY
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
echo "- \`stable\` (for main branch and stable releases)" >> $GITHUB_STEP_SUMMARY
echo "- \`beta\` (for beta branch and pre-releases)" >> $GITHUB_STEP_SUMMARY
+15 -1
View File
@@ -69,7 +69,9 @@ backend/data/
backend/docs/
backend/logs/
logs/
storage/
# Anchored to repo root: matches the top-level runtime storage dir,
# NOT backend/src/services/storage/ (the storage backend abstraction code).
/storage/
data/
certbot/
@@ -94,12 +96,24 @@ docs/FRONTEND_ARCHITECTURE.md
docs/DEVELOPER_ONBOARDING.md
docs/ENVIRONMENT_VARIABLES.md
# Build artifact: OpenAPI spec generated locally + synced into the
# picpeak-docs repo. Never tracked here — the docs site at
# docs.picpeak.app is the source of truth.
docs/openapi.json
docs/openapi.yaml
# Local backup directory (from testing)
backup/
# Local artifacts from browser tooling
.playwright-mcp/
# Local-only E2E suite (never pushed; runs as pre-push gate on this machine)
tests/e2e/local/
playwright-local-results/
e2e-test.log
scripts/e2e-local.sh
# Local SQLite files in backend
backend/*.sqlite*
backend/*.db
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.17.1-beta.0"
".": "3.42.1-beta.0"
}
+1 -3
View File
@@ -1,3 +1 @@
{
".": "2.6.5"
}
{ ".": "3.42.1" }
+788 -18
View File
@@ -5,45 +5,777 @@ 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).
## [2.6.5](https://github.com/the-luap/picpeak/compare/v2.6.4...v2.6.5) (2026-04-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.
### Major themes since v2.6.5
* **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
### Bug fixes (highlights from beta)
* `/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)
### Bug Fixes
* **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))
## [3.42.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.41.0-beta.0...v3.42.0-beta.0) (2026-05-07)
### Features
* **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))
## [3.41.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.40.1-beta.0...v3.41.0-beta.0) (2026-05-06)
### Features
* **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))
* **email:** expand email palette to 8 tokens + Sync from Branding button ([47b6b39](https://github.com/the-luap/picpeak/commit/47b6b39f3a942aee93b970031d95a952cb769d09))
* **events:** Sync from Branding button in gallery theme customizer + clarified default inheritance ([bdbe7b8](https://github.com/the-luap/picpeak/commit/bdbe7b80a13b8b215ac544ba9105100e792eeda2))
* **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))
* **theme:** expand color settings to 8-token CI palette + alt button ([114aab5](https://github.com/the-luap/picpeak/commit/114aab57771a4bba03a9e5c616c75a37c9b25969))
### Bug Fixes
* **admin:** tab underlines use accent (not accent-dark) for proper highlight color ([565ae45](https://github.com/the-luap/picpeak/commit/565ae45ca71e46166c8bbfc0eb0b6da92d74f120))
* **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))
* **cms:** apply dark mode to CMS editor, public CMS, and admin modals ([d2a10f6](https://github.com/the-luap/picpeak/commit/d2a10f6523655488267d6f68835d7adb46dcf962))
* **theme:** centralise force-mode enforcement inside ThemeContext so every gallery flips ([21188f4](https://github.com/the-luap/picpeak/commit/21188f48d76dd29bc1251bcc6faf9d6d96c805b5))
## [3.40.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.40.0-beta.0...v3.40.1-beta.0) (2026-05-04)
### Bug Fixes
* **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))
## [3.40.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.39.1-beta.0...v3.40.0-beta.0) (2026-05-04)
### Features
* **branding:** per-family generic fallback via meta.json ([dcff451](https://github.com/the-luap/picpeak/commit/dcff4515721482e06c2ef1c1eb34f9e12754c07c))
* **branding:** self-hosted webfonts with filesystem scanner ([d04bf28](https://github.com/the-luap/picpeak/commit/d04bf288084144bf53ef0ba988fa32ed703d7351))
### Bug Fixes
* **fonts:** drop immutable Cache-Control to allow font replacement rollout ([5703fcb](https://github.com/the-luap/picpeak/commit/5703fcb80680155e3b637dd5fc15c430de963c40))
### 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))
* **fonts:** cache rollout, stale-list note, meta.json ([bd0e052](https://github.com/the-luap/picpeak/commit/bd0e052b1a1847718151a16117dacc6c42a2178e))
## [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)
## [3.39.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.39.0-beta.0...v3.39.1-beta.0) (2026-05-04)
### 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))
* **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))
## [2.6.2](https://github.com/the-luap/picpeak/compare/v2.6.1...v2.6.2) (2026-03-16)
## [3.39.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.38.0-beta.0...v3.39.0-beta.0) (2026-05-04)
### Features
* **gallery:** decouple header style from layout, add banner option ([1f1a856](https://github.com/the-luap/picpeak/commit/1f1a856083b1966ed4b32a23a14442f1727cecef))
## [3.38.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.37.0-beta.0...v3.38.0-beta.0) (2026-05-04)
### Features
* **events:** bulk delete with password confirmation ([#384](https://github.com/the-luap/picpeak/issues/384)) ([647aea2](https://github.com/the-luap/picpeak/commit/647aea21ae42fe0d089bf45568702617a25b98e4))
## [3.37.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.36.0-beta.0...v3.37.0-beta.0) (2026-05-04)
### Features
* **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))
## [3.36.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.35.0-beta.0...v3.36.0-beta.0) (2026-05-04)
### Features
* **events:** prefill admin email + admin picker on event creation ([3fe8e61](https://github.com/the-luap/picpeak/commit/3fe8e61bd1175e35dcb61604447e5d9c2e902ec5))
## [3.35.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.34.2-beta.0...v3.35.0-beta.0) (2026-05-04)
### Features
* **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))
### 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))
* **events:** match scrollbar to theme in external folder tree picker ([bd42ee1](https://github.com/the-luap/picpeak/commit/bd42ee1ce03b8f6e7b011b53f2c71453be931cc6))
## [2.6.1](https://github.com/the-luap/picpeak/compare/v2.6.0...v2.6.1) (2026-03-11)
## [3.34.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.34.1-beta.0...v3.34.2-beta.0) (2026-05-04)
### Bug Fixes
* **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))
## [3.34.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.34.0-beta.0...v3.34.1-beta.0) (2026-05-03)
### Bug Fixes
* **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))
## [3.34.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.33.2-beta.0...v3.34.0-beta.0) (2026-05-03)
### Features
* **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))
## [3.33.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.33.1-beta.0...v3.33.2-beta.0) (2026-05-03)
### Bug Fixes
* **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))
## [3.33.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.33.0-beta.0...v3.33.1-beta.0) (2026-05-03)
### Bug Fixes
* **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))
## [3.33.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.32.5-beta.0...v3.33.0-beta.0) (2026-05-02)
### Features
* native multi-arch Docker images (Apple Silicon, ARM64 Linux) ([df30618](https://github.com/the-luap/picpeak/commit/df3061893d154152b75b8ab0d07e0b1e0078431d))
## [3.32.5-beta.0](https://github.com/the-luap/picpeak/compare/v3.32.4-beta.0...v3.32.5-beta.0) (2026-05-02)
### Bug Fixes
* **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))
## [3.32.4-beta.0](https://github.com/the-luap/picpeak/compare/v3.32.3-beta.0...v3.32.4-beta.0) (2026-05-01)
### Bug Fixes
* **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))
* **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))
## [3.32.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.32.2-beta.0...v3.32.3-beta.0) (2026-05-01)
### Bug Fixes
* **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))
* **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))
## [3.32.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.32.1-beta.0...v3.32.2-beta.0) (2026-05-01)
### Bug Fixes
* 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:** 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))
* **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))
* **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))
## [3.32.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.32.0-beta.0...v3.32.1-beta.0) (2026-04-30)
### Documentation
* 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))
## [3.32.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.31.1-beta.0...v3.32.0-beta.0) (2026-04-29)
### Features
* **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))
## [3.31.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.31.0-beta.0...v3.31.1-beta.0) (2026-04-28)
### Bug Fixes
* **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))
* **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))
* **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))
* mobile lightbox + share previews + customer phone bug triage ([1e40677](https://github.com/the-luap/picpeak/commit/1e4067713ce9a808a7b49319bc262e5c9a6599c6))
* **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))
## [3.31.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.30.0-beta.0...v3.31.0-beta.0) (2026-04-28)
### Features
* **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))
* 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))
* outbound webhooks for event/photo lifecycle ([#327](https://github.com/the-luap/picpeak/issues/327)) ([c488f48](https://github.com/the-luap/picpeak/commit/c488f481caacf0d63dafc47f509e8de2708bc30f))
* presigned download UI + S3 prefix walker auto-importer (follow-ups) ([446d80a](https://github.com/the-luap/picpeak/commit/446d80a4cc5eb0389994e29585b2a98dad373db2))
* S3 storage + webhooks + settings dedupe + backup fixes ([06d54be](https://github.com/the-luap/picpeak/commit/06d54bec4d0afc4a1b9ba6f2449ed7d79f1d3e8f))
### Bug Fixes
* **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))
## [3.30.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.29.1-beta.0...v3.30.0-beta.0) (2026-04-27)
### Features
* 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))
* 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))
* 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))
### Bug Fixes
* dedupe parallel admin 401 redirects to /admin/login ([038e84c](https://github.com/the-luap/picpeak/commit/038e84cae7f56a0a1af8c71b85881ca5d320c6e3))
* floor password_changed_at when comparing against JWT iat ([793e410](https://github.com/the-luap/picpeak/commit/793e410554b461522fbe24014dfd3baa915da2bb))
* 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))
### Documentation
* add Buy Me a Coffee badge + Support section ([46bc894](https://github.com/the-luap/picpeak/commit/46bc894d917bd55dbd9bafaa64fd38db21488b81))
## [3.29.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.29.0-beta.0...v3.29.1-beta.0) (2026-04-26)
### Bug Fixes
* 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))
* 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))
* 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))
## [3.29.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.3-beta.0...v3.29.0-beta.0) (2026-04-23)
### Features
* 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))
## [3.28.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.2-beta.0...v3.28.3-beta.0) (2026-04-13)
### Bug Fixes
* 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))
## [3.28.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.1-beta.0...v3.28.2-beta.0) (2026-04-12)
### Bug Fixes
* 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))
## [3.28.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.0-beta.0...v3.28.1-beta.0) (2026-04-12)
### Bug Fixes
* 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))
## [3.28.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.27.0-beta.0...v3.28.0-beta.0) (2026-04-11)
### Features
* 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))
### Bug Fixes
* 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))
## [3.27.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.2-beta.0...v3.27.0-beta.0) (2026-04-11)
### Features
* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0))
* add Apple Liquid Glass templates, image security settings, and automated releases ([6033461](https://github.com/the-luap/picpeak/commit/6033461be118ce78277ec568e1ef1ceeff7311c8))
* 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 CSS template system with custom gallery styling support ([0da45e6](https://github.com/the-luap/picpeak/commit/0da45e699ad998031aa56a92f2da5ee61a04e285))
* 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 event management, gallery customization, and release automationFeature/event rename ([40ee671](https://github.com/the-luap/picpeak/commit/40ee67171d41522037bf9d4e7675b62ec564346d))
* 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 multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9))
* 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))
* **admin:** refine header layout and logo placement ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
* allow admin email updates in UI ([#36](https://github.com/the-luap/picpeak/issues/36)) ([3c2a79a](https://github.com/the-luap/picpeak/commit/3c2a79a31a0f1a44c8ec4f9a87f6fbcea9be651c))
* beta/stable release channels with update notifications and bug fixes ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
* beta/stable release channels with update notifications and bug fixes ([#98](https://github.com/the-luap/picpeak/issues/98)) ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
* 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))
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
* **docker:** add PUID/PGID and user mapping to avoid bind mount permission issues; feat(setup): prompt for admin email interactively; docs: PUID/PGID in .env.example ([410a33f](https://github.com/the-luap/picpeak/commit/410a33fecf1693cc75816c53ac460ec20089e2a1))
* 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))
* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f))
* 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/filters:** add Rated and Commented filters (UI + backend).\n\n- UI: add star (Rated) and message (Commented) buttons to feedback filter bars (desktop + mobile)\n- Backend: support filter=rated, commented, and combinations via aggregate counts/queries ([b03760a](https://github.com/the-luap/picpeak/commit/b03760ab01e21feb3578f90d065945d437d03452))
* **gallery:** add quick Like/Favorite actions on thumbnails across layouts ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a))
* **gallery:** always-visible feedback indicators on grid tiles; fallback image rendering in lightbox/hero; auto-auth from shared-link token; fix external photo resolver\n\n- GridGallery: bottom-left icons for like/rated/comment on every tile\n- Hero layout grid: added same indicators (non-intrusive icons)\n- Lightbox/Hero: add fallbackSrc to display thumbnail if original fails\n- GalleryAuth: auto-store token from /gallery/:slug/:token and hydrate event\n- Backend gallery photo route: use resolvePhotoFilePath for external-media\n\nfix(admin): move photo feedback badges to bottom-right on admin grid tiles\n\nfix(dashboard): add missing i18n keys for activity types + fallback to formatter\n\nfix(admin/feedback): correct thumbnail URL base + robust date parsing\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([6948aaa](https://github.com/the-luap/picpeak/commit/6948aaa92afc29609f85cf7fd631095f3e32ad3f))
* **gallery:** compact vertical icon-only feedback filter in PhotoFilterBar; remove wide buttons to prevent overflow\n\n- Desktop: vertical icon stack (All/Grid, Likes, Favorites) outside scroll area\n- Mobile: vertical icon stack below categories\n- Keeps existing category bar layout and count\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([465f997](https://github.com/the-luap/picpeak/commit/465f997752fc930ac0a3ae530e9e57a378877d53))
* **i18n:** add translations for settings tabs ([c030e87](https://github.com/the-luap/picpeak/commit/c030e872135b39701ef1f4bbb2f28bcaf4ce7fae))
* implement 4 new features with bug fixes and refactoring plan ([77a4bfd](https://github.com/the-luap/picpeak/commit/77a4bfd49975551bf509354097f280cab3e48c7a))
* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03))
* 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))
* **lightbox:** keep feedback usable while navigating ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
* Multi-administrator RBAC, CSS templates & security hardening ([#78](https://github.com/the-luap/picpeak/issues/78)) ([16b3ab0](https://github.com/the-luap/picpeak/commit/16b3ab039ae95f5641dc15a4811eb2b503f1791c))
* 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:** auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin ([fb16b7b](https://github.com/the-luap/picpeak/commit/fb16b7bbb8225192160c08050f1b164c36c8dc74))
* **native:** build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs ([9fe10bc](https://github.com/the-luap/picpeak/commit/9fe10bcce2871a48f2409b4936d95c00249deb51))
* **native:** serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR) ([61ad2d6](https://github.com/the-luap/picpeak/commit/61ad2d61c137196c229817989f991e50fa389a6e))
* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120))
* 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))
* overhaul public landing page and backup tooling ([2a4d388](https://github.com/the-luap/picpeak/commit/2a4d38813f7ab64a6bbb3a666f3c98a29443488d))
* 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))
* register Russian locale and add to language selector ([6f95b8c](https://github.com/the-luap/picpeak/commit/6f95b8c26cd794525e15e45d478f9ead0ec22555))
* **select:** add per-tile checkbox selection in Admin grid and all gallery layouts; tile click opens viewer; checkbox toggles selection; auto-enable selection mode; add testids ([9fda54b](https://github.com/the-luap/picpeak/commit/9fda54bd06d37cd8f8f71056bf4f59e158cd8112))
* **setup/docker:** auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs ([0618b78](https://github.com/the-luap/picpeak/commit/0618b78725e85f97f0a4b4e834c17811c033c8f4))
* **setup:** remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands ([84d0f63](https://github.com/the-luap/picpeak/commit/84d0f63d36c68532fea83e7087b1afeaa9b82f39))
* 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 per-gallery password toggle ([5d6c061](https://github.com/the-luap/picpeak/commit/5d6c061f1c4fd20581b1e74fa114c96530b5de53))
* 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))
### Bug Fixes
* 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 settings translations and fix manual backup process ([#82](https://github.com/the-luap/picpeak/issues/82)) ([476fcce](https://github.com/the-luap/picpeak/commit/476fcce13f30f9f2d2f98a0c87c25fba09e9eebc))
* 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 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/feedback:** use correct event id when rendering photo thumbnails ([4c7b49a](https://github.com/the-luap/picpeak/commit/4c7b49a5f69a3fce4f9a0e837a082b56bb7e47d6)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
* **admin:** prevent category badge overlap in grid ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
* align backend port to 3000 across all configurations ([3a8d53f](https://github.com/the-luap/picpeak/commit/3a8d53f4927f577c4031c4bc3531e08191dc632a))
* Align nginx backend port for production Docker deployments (v2.2.2) ([#88](https://github.com/the-luap/picpeak/issues/88)) ([e0bd19a](https://github.com/the-luap/picpeak/commit/e0bd19a74dd81bdd45be2384820830bd96769e1c))
* 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))
* **backup:** add lastBackup alias and totalBackups for frontend compatibility ([749100c](https://github.com/the-luap/picpeak/commit/749100c92abd2bb123b137e3d3c6bb342b8f5f00))
* **backup:** allow manual backups when automated backups are disabled ([e6dd89e](https://github.com/the-luap/picpeak/commit/e6dd89e969fb7018633159155975bd2bd2fb0409))
* 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)
* CI workflow fixes for protected branches ([657c205](https://github.com/the-luap/picpeak/commit/657c205a4d8ca49070b69973f4c7a3d1418633af))
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
* **ci:** add QEMU setup for multi-arch builds and skip for PRs ([0d36a27](https://github.com/the-luap/picpeak/commit/0d36a273bb58ffd0172efacd828e7171d954b41c))
* clear notifications via API ([#35](https://github.com/the-luap/picpeak/issues/35)) ([013be18](https://github.com/the-luap/picpeak/commit/013be18d982986333e2ac24c7ede907de49690bc))
* 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))
* **cors:** scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native ([90bb21e](https://github.com/the-luap/picpeak/commit/90bb21e38bf1ba97e3fb8185b8d05f1296d745ee))
* database migration restart bug, lightbox loading spinner, and watermark cache invalidation ([7c58749](https://github.com/the-luap/picpeak/commit/7c5874980640ae8c3d1050ce24daeb0a2aeab7a3))
* **db:** improve PostgreSQL connection check in wait-for-db.sh ([e85a68a](https://github.com/the-luap/picpeak/commit/e85a68a386c72c276b4958599b5246e60dfac716))
* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db))
* 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 Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
* dynamic website title from branding settings ([4701edc](https://github.com/the-luap/picpeak/commit/4701edc12ecfab27cb2d1cfb0b4ed4fd53f56cc6))
* 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 without expiration date incorrectly shown as expired ([c4f16eb](https://github.com/the-luap/picpeak/commit/c4f16eb76c909158abdb63aa4cc22f817f274dc5))
* external media dimensions, theme race condition, email color customization ([dfae2c2](https://github.com/the-luap/picpeak/commit/dfae2c2bc6d86378c553cd847b439f7cb53a4f2a))
* **frontend:** add missing externalMedia service and mount admin external-media routes; verify Vite build ([ab324f1](https://github.com/the-luap/picpeak/commit/ab324f192859204a3ea3c129530ccfe8f5a36968))
* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4))
* **gallery/filters:** always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier ([526dcd8](https://github.com/the-luap/picpeak/commit/526dcd8dfc030d86143cee799a88a1004d96b116))
* **gallery/filters:** make feedback filters work globally when no guest_id is provided; remove guest_id from client photos query\n\n- Backend /api/gallery/:slug/photos: if filter present and guest_id missing, filter by like_count/favorite_count\n- Frontend useGalleryPhotos: stop passing random guestId (does not match server guest_identifier)\n\nThis makes Liked/Favorited filters reflect photos with aggregate feedback counts as expected. ([5b2561b](https://github.com/the-luap/picpeak/commit/5b2561b6f1da2665d6092ba954f8ff26df3959a4))
* **gallery/sidebar:** compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact ([ff89f96](https://github.com/the-luap/picpeak/commit/ff89f96e31130f75bcd7a406c5d895eac17b65de))
* **gallery:** feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) ([3a6d061](https://github.com/the-luap/picpeak/commit/3a6d06192a280ead8bd5d1fbfe06554e63f3346e))
* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
* harden gallery downloads and per-gallery auth ([fc1bf53](https://github.com/the-luap/picpeak/commit/fc1bf534129092ca3638e4a4bc47274cd297fa5f))
* 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))
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
* lightbox watermark loading, white label translations, and dynamic footer year ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef))
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
* lightbox watermark loading, white label translations, and dynamic footer year ([#108](https://github.com/the-luap/picpeak/issues/108)) ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef))
* 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))
* Multi-administrator RBAC, CSS templates & security hardening ([#80](https://github.com/the-luap/picpeak/issues/80)) ([37d4e1c](https://github.com/the-luap/picpeak/commit/37d4e1cb6132346699a90aebfbaec83d84f931f4))
* **native/http:** disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs ([24b4a31](https://github.com/the-luap/picpeak/commit/24b4a314a9e97b6c640ca29067e95028a23a8973))
* **native:** correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes ([b992b15](https://github.com/the-luap/picpeak/commit/b992b151d3ca6ccb4a9b2434d94edcdc90ada3b0))
* **native:** remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS ([f3604b4](https://github.com/the-luap/picpeak/commit/f3604b438b37e5f2bddf98e79f458bfa2367cb75))
* **nginx:** add Docker DNS resolver for Swarm/dynamic service discovery ([049837f](https://github.com/the-luap/picpeak/commit/049837f9d675ff5a4d93c02e5eb771bf65bc2616))
* **nginx:** Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3) ([cc1ddfd](https://github.com/the-luap/picpeak/commit/cc1ddfd42cccac07d5869fe2ee19c25a9ffa50e8))
* **photos:** category changes now persist and display correctly ([#77](https://github.com/the-luap/picpeak/issues/77)) ([d9da98c](https://github.com/the-luap/picpeak/commit/d9da98c355011c247c526b28e6f07b329a632b55))
* **photos:** resolve upload category selection and improve feedback buttons ([#77](https://github.com/the-luap/picpeak/issues/77)) ([856d533](https://github.com/the-luap/picpeak/commit/856d53343c6805706e1498892a29b120938f8547))
* 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))
* prefer admin token on admin routes ([#23](https://github.com/the-luap/picpeak/issues/23) [#28](https://github.com/the-luap/picpeak/issues/28)) ([d4404e3](https://github.com/the-luap/picpeak/commit/d4404e39bd7953649da02d3e300ffef46573ac97))
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
* prevent unnecessary image recompression and fix SQLite migration [#95](https://github.com/the-luap/picpeak/issues/95) ([3cdc0ea](https://github.com/the-luap/picpeak/commit/3cdc0ea7152e63cd72124a91394741a6e6904af3))
* 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 branding display issues and invitation parsing errors ([1931d73](https://github.com/the-luap/picpeak/commit/1931d73b60d3419203cc8b420841abbfc9e14d2d))
* Resolve branding display issues and invitation parsing errors (v2.2.1) ([#86](https://github.com/the-luap/picpeak/issues/86)) ([d7ecf83](https://github.com/the-luap/picpeak/commit/d7ecf83d32ec6608280b96e6cdee48e9a0ad0afa))
* 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))
* **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 ([7ca9631](https://github.com/the-luap/picpeak/commit/7ca96315e254eef58d8ecc505f95a5186d2fa2da))
* **security:** upgrade Alpine base image to fix libpng and c-ares CVEs ([b706eeb](https://github.com/the-luap/picpeak/commit/b706eeb5d332e9618706193976a7241aee53d879))
* 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))
* **setup/native:** correct repo URL, paths, and systemd for native install; support sqlite in production knex config ([87b8414](https://github.com/the-luap/picpeak/commit/87b8414e449802db6dc9f762453f7672616b83c9))
* **setup/native:** Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate ([dc482e6](https://github.com/the-luap/picpeak/commit/dc482e614a5fbac44c6570d812669511301a4403))
* **setup/native:** handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories ([3697344](https://github.com/the-luap/picpeak/commit/3697344cd0add28b4da71c3b33e2ccc0a96f50f9))
* **setup/update:** detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root ([adf576f](https://github.com/the-luap/picpeak/commit/adf576fbe17f40c13c1d77dd9751f2e9dbf523a1))
* 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)
* stabilize uploads and guest feedback filters ([aaaf598](https://github.com/the-luap/picpeak/commit/aaaf59817b3978635d2282c006853e183ab944d4))
* 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))
* 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))
* use Release Please extra-files instead of sync-versions job ([fe7d45d](https://github.com/the-luap/picpeak/commit/fe7d45dd122b2dca1b2a21ba5c86d32b9a193074))
* 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))
* watermark thumbnails, custom logo display, and German translations ([f843e4c](https://github.com/the-luap/picpeak/commit/f843e4c25cef02eef354fd3ee25824e20e4f8fc8))
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
* 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))
### Documentation
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
* add PUID/PGID note for Docker bind mounts to avoid permission issues ([0178e71](https://github.com/the-luap/picpeak/commit/0178e71c67f198c6013ece52b0a2da0e2f1a6b2a))
* 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))
* **readme:** reflect new External Media reference mode and update roadmap (gallery feedback status) ([ee13556](https://github.com/the-luap/picpeak/commit/ee13556c5cb4f24fe88e14fd00b821acf65b11cb))
## [3.26.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.1-beta.0...v3.26.2-beta.0) (2026-04-11)
### Bug Fixes
* 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))
* 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))
## [3.26.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.0-beta.0...v3.26.1-beta.0) (2026-04-09)
### Bug Fixes
* 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))
* 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))
* 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))
### Documentation
* 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))
## [3.26.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.25.0-beta.0...v3.26.0-beta.0) (2026-04-09)
### Features
* 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))
### Bug Fixes
* 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)
## [3.25.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.24.1-beta.0...v3.25.0-beta.0) (2026-04-08)
### Features
* 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))
## [3.24.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.24.0-beta.0...v3.24.1-beta.0) (2026-04-05)
### Bug Fixes
* 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))
## [3.24.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.23.0-beta.0...v3.24.0-beta.0) (2026-04-04)
### Features
* 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))
## [3.23.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.22.0-beta.0...v3.23.0-beta.0) (2026-04-04)
### Features
* 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))
### Bug Fixes
* 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))
## [3.22.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.21.1-beta.0...v3.22.0-beta.0) (2026-03-25)
### Features
* 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))
## [3.21.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.21.0-beta.0...v3.21.1-beta.0) (2026-03-22)
### Bug Fixes
* 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))
## [3.21.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.20.1-beta.0...v3.21.0-beta.0) (2026-03-18)
### Features
* 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))
### Bug Fixes
* 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))
## [3.20.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.20.0-beta.0...v3.20.1-beta.0) (2026-03-17)
### Bug Fixes
* 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))
## [3.20.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.2-beta.0...v3.20.0-beta.0) (2026-03-17)
### Features
* 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))
## [3.19.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.1-beta.0...v3.19.2-beta.0) (2026-03-16)
### Bug Fixes
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([f362239](https://github.com/the-luap/picpeak/commit/f3622396e77ce5d0b0741e439fc554a1dccaca50))
* **security:** token invalidation on password change, session timeout enforcement ([7ca9631](https://github.com/the-luap/picpeak/commit/7ca96315e254eef58d8ecc505f95a5186d2fa2da))
## [3.19.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.0-beta.0...v3.19.1-beta.0) (2026-03-16)
### Bug Fixes
* external media dimensions, theme race condition, email color customization ([dfae2c2](https://github.com/the-luap/picpeak/commit/dfae2c2bc6d86378c553cd847b439f7cb53a4f2a))
* resolve external media dimensions, gallery theme race condition, and add email color customization ([bbeedd1](https://github.com/the-luap/picpeak/commit/bbeedd1888561b6c57586b5f42bbfee3ffc69fd7))
## [3.19.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.2-beta.0...v3.19.0-beta.0) (2026-03-16)
### Features
* 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))
## [3.18.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.1-beta.0...v3.18.2-beta.0) (2026-03-16)
### Bug Fixes
* resolve code scanning security alerts (multer, tar, Node 22) ([85a07fc](https://github.com/the-luap/picpeak/commit/85a07fcca7ad935f4c0c300f5ffe2f3af8da1e5f))
* update dependencies to resolve code scanning security alerts ([1f524f2](https://github.com/the-luap/picpeak/commit/1f524f23580d2e2a21dbba28cb46aed76e85c475))
## [3.18.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.0-beta.0...v3.18.1-beta.0) (2026-03-16)
### Bug Fixes
* 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)
## [3.18.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.2-beta.0...v3.18.0-beta.0) (2026-03-16)
### Features
* add visual WYSIWYG email template editor ([#229](https://github.com/the-luap/picpeak/issues/229)) ([04a7ea8](https://github.com/the-luap/picpeak/commit/04a7ea80f95d6aeb474b145292e75f45fb85c66d))
* register Russian locale and add to language selector ([6f95b8c](https://github.com/the-luap/picpeak/commit/6f95b8c26cd794525e15e45d478f9ead0ec22555))
* visual WYSIWYG email template editor ([703c03f](https://github.com/the-luap/picpeak/commit/703c03fbee754a5291b57b885c5e82fbdd3e69e9))
### Bug Fixes
* shorten Save button label on email template editor ([7250c42](https://github.com/the-luap/picpeak/commit/7250c427b905ffa3e8696dff607450f5a0b801b8))
## [3.17.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.1-beta.0...v3.17.2-beta.0) (2026-03-11)
### Bug Fixes
* update security policy with private reporting channels ([308e086](https://github.com/the-luap/picpeak/commit/308e08626383bab213ce3eb5563608dff6168ef4))
* 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)
* 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))
## [3.17.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.0-beta.0...v3.17.1-beta.0) (2026-03-08)
@@ -931,3 +1663,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)
+10
View File
@@ -131,6 +131,16 @@ We welcome contributions — bug fixes, features, translations, documentation. S
- [Admin API Quickstart](docs/admin-api-quickstart.md) — Authentication and testing guide
- [Security Policy](SECURITY.md)
## Contributors
Thanks 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, 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.
If you've contributed and aren't listed here, please open a PR.
## License
MIT — use it for personal or commercial projects.
+23 -16
View File
@@ -219,29 +219,36 @@ location ~ ^/(photos|thumbnails|uploads) {
### Creating a Gallery
#### Method 1: Via Admin Panel (Recommended)
1. Login to admin panel
#### Via Admin Panel
1. Login to admin panel at `/admin`
2. Click "Create New Event"
3. Configure settings and upload photos
3. Configure settings (name, date, password, customer email)
4. Upload photos via drag & drop in the Photos tab
5. Publish the gallery when ready
#### Adding Photos via File System
> **Important:** You must first create the event in the admin panel. The file watcher only detects new photos for events that already exist in the database. You cannot create a gallery by copying files alone.
Once an event exists, you can add photos by copying them into the event's folder. PicPeak's built-in file watcher will automatically detect the new files, create database records, and generate thumbnails.
#### Method 2: File System
```bash
# Docker installation
mkdir -p ~/picpeak/storage/events/active/wedding-smith-2024
cp /path/to/photos/* ~/picpeak/storage/events/active/wedding-smith-2024/
# Docker installation — copy photos into an existing event's folder
cp /path/to/photos/*.jpg ~/picpeak/storage/events/active/<event-slug>/
# Native installation
sudo mkdir -p /opt/picpeak/events/active/wedding-smith-2024
sudo cp /path/to/photos/* /opt/picpeak/events/active/wedding-smith-2024/
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/wedding-smith-2024
sudo cp /path/to/photos/*.jpg /opt/picpeak/events/active/<event-slug>/
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/<event-slug>
```
The event slug is visible in the admin panel URL or share link (e.g. `wedding-smith-2024`). Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`. The file watcher has a 2-second stability delay before processing new files.
### Gallery Structure
```
wedding-smith-2024/
├── collages/ # Group photos
├── individual/ # Individual photos
└── thumbnails/ # Auto-generated thumbnails
<event-slug>/
├── collages/ # Group photos (optional subfolder)
├── individual/ # Individual photos (optional subfolder)
└── photo.jpg # Photos at root level also work
```
## 🔧 Service Management
@@ -461,8 +468,8 @@ sudo -u picpeak node scripts/reset-admin-password.js
- Installation: `/tmp/picpeak-setup-*.log`
2. **Documentation:**
- [Full Documentation](https://github.com/the-luap/picpeak)
- [Deployment Guide](./DEPLOYMENT_GUIDE.md)
- [Full Documentation](https://docs.picpeak.app)
- [Deployment Guide](https://docs.picpeak.app/deployment)
3. **Support:**
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
+28
View File
@@ -9,6 +9,34 @@ PORT=3001
# Generate with: openssl rand -base64 32
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
# auto - decide per request: Secure on HTTPS, not on HTTP
#
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
# (via a reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
# HTTP (e.g. LAN access at http://192.168.x.x:3001). The backend reads
# req.secure from Express, which respects the X-Forwarded-Proto header
# when the proxy is in the trust list.
#
# Requirements for auto mode:
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
# 192.168.x, link-local). Proxies outside those ranges need custom
# trust proxy configuration.
# COOKIE_SECURE=auto
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
# COOKIE_SAMESITE=Lax
# Cookie Domain — set this if serving auth cookies across subdomains.
# Leave unset for same-origin setups.
# COOKIE_DOMAIN=.example.com
# URLs (adjust for your domain)
ADMIN_URL=https://photos.example.com
FRONTEND_URL=https://photos.example.com
+12 -6
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS builder
FROM node:22-alpine AS builder
# Add build arguments
ARG CACHEBUST=1
@@ -23,18 +23,24 @@ RUN npm ci --omit=dev
COPY . .
# Production stage
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# Upgrade npm to latest to fix tar, minimatch, brace-expansion CVEs in npm's own deps
RUN npm install -g npm@latest
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
RUN npm install -g npm@10
# Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client
# 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
# `@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
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
+4 -2
View File
@@ -5,8 +5,10 @@ WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
RUN apk add --no-cache dumb-init ffmpeg
# Copy package files
COPY package*.json ./
+35 -17
View File
@@ -7,12 +7,15 @@ const crypto = require('crypto');
// Load services
const backupService = require('../../src/services/backupService');
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
const { db, initialize: initDb } = require('../../src/database/db');
const { db, initializeDatabase: initDb } = require('../../src/database/db');
const logger = require('../../src/utils/logger');
// Test configuration
// Defaults match the dev MinIO container in docker-compose.dev.yml (port 7104).
// Override via TEST_S3_ENDPOINT / TEST_S3_ACCESS_KEY / TEST_S3_SECRET_KEY when running
// against a different S3 endpoint (CI, hosted MinIO, real AWS, etc.).
const TEST_CONFIG = {
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:9000',
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
bucket: 'test-backup-bucket-' + Date.now(),
@@ -56,9 +59,17 @@ describe('S3 Backup Integration Tests', () => {
}
}
// Initialize database
await initDb();
await db.migrate.latest();
// Schema is expected to already be applied by `npm run migrate` against
// the dev database. db.migrate.latest() can't be used here because
// PicPeak's custom run-migrations.js tracks state in the `migrations`
// table (not knex's `knex_migrations`), so knex would try to re-apply
// every migration and crash on duplicate-table errors.
const ok = await db.schema.hasTable('events')
&& await db.schema.hasTable('app_settings')
&& await db.schema.hasTable('backup_runs');
if (!ok) {
throw new Error('Required tables missing — run `npm run migrate` against the dev DB first.');
}
// Create test storage directory
testStoragePath = path.join(__dirname, '../fixtures/test-storage');
@@ -69,10 +80,12 @@ describe('S3 Backup Integration Tests', () => {
await setupTestData();
// Mock logger to reduce noise
logger.info = jest.fn();
logger.debug = jest.fn();
logger.warn = jest.fn();
logger.error = jest.fn();
if (process.env.UNMOCK_LOGGER !== 'true') {
logger.info = jest.fn();
logger.debug = jest.fn();
logger.warn = jest.fn();
logger.error = jest.fn();
}
});
afterAll(async () => {
@@ -165,8 +178,9 @@ describe('S3 Backup Integration Tests', () => {
.first();
expect(backupRun.status).toBe('completed');
expect(backupRun.files_backed_up).toBeGreaterThan(0);
expect(backupRun.total_size_bytes).toBeGreaterThan(0);
// pg driver returns bigint columns as strings; coerce for the size assertion.
expect(Number(backupRun.files_backed_up)).toBeGreaterThan(0);
expect(Number(backupRun.total_size_bytes)).toBeGreaterThan(0);
// Verify files in S3
const s3Objects = await listS3Objects();
@@ -269,13 +283,16 @@ describe('S3 Backup Integration Tests', () => {
.first();
expect(secondRun.id).not.toBe(firstRun.id);
expect(secondRun.files_backed_up).toBe(1); // Only modified file
expect(Number(secondRun.files_backed_up)).toBe(1); // Only modified file
// Check manifest indicates incremental
// Check manifest indicates incremental. The current manifest schema
// groups counts under `incremental.changes.*` (added/modified/deleted/
// unchanged + size_difference) — see backupManifest.generateIncrementalManifest.
if (secondRun.manifest_path) {
const manifest = await backupService.getBackupManifest(secondRun.id);
expect(manifest.manifest.incremental).toBeDefined();
expect(manifest.manifest.incremental.modified_files_count).toBe(1);
expect(manifest.manifest.incremental.changes).toBeDefined();
expect(manifest.manifest.incremental.changes.modified_files_count).toBe(1);
}
});
@@ -468,15 +485,16 @@ describe('S3 Backup Integration Tests', () => {
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
];
// Schema drift: app_settings has no created_at column anymore and the
// unique constraint is on setting_key alone, not (setting_type, key).
for (const setting of settings) {
await db('app_settings')
.insert({
setting_type: 'backup',
...setting,
created_at: new Date(),
updated_at: new Date()
updated_at: new Date(),
})
.onConflict(['setting_type', 'setting_key'])
.onConflict('setting_key')
.merge();
}
}
@@ -0,0 +1,165 @@
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const os = require('os');
const crypto = require('crypto');
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const sharp = require('sharp');
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
const storageModule = require('../../src/services/storage');
// Stub out the DB so getThumbnailSettings falls into its catch and uses defaults.
jest.mock('../../src/database/db', () => ({
db: () => {
throw new Error('db disabled in this test');
},
}));
const TEST_S3 = {
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
region: 'us-east-1',
};
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
function backendCases() {
const cases = [
{
name: 'LocalFsStorage',
async setup() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-'));
const storage = new LocalFsStorage({ root });
await storage.init();
return { storage, cleanup: () => fs.rm(root, { recursive: true, force: true }) };
},
},
];
if (!skipS3) {
cases.push({
name: 'S3StorageBackend (MinIO)',
async setup() {
const bucket = `picpeak-imgproc-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
const s3Client = new S3Client({
endpoint: TEST_S3.endpoint,
region: TEST_S3.region,
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
forcePathStyle: true,
});
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
const storage = new S3StorageBackend({
bucket,
region: TEST_S3.region,
endpoint: TEST_S3.endpoint,
accessKeyId: TEST_S3.accessKeyId,
secretAccessKey: TEST_S3.secretAccessKey,
forcePathStyle: true,
sslEnabled: false,
});
await storage.init();
return {
storage,
async cleanup() {
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
if (list.Contents?.length) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
}));
}
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
},
};
},
});
}
return cases;
}
async function makeSourceJpeg(targetDir, name) {
const localPath = path.join(targetDir, name);
// 800x600 random RGB image so sharp has something realistic to thumbnail.
const width = 800;
const height = 600;
const buf = Buffer.alloc(width * height * 3);
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
await sharp(buf, { raw: { width, height, channels: 3 } })
.jpeg({ quality: 90 })
.toFile(localPath);
return localPath;
}
describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
let storage;
let cleanup;
let tmpDir;
let imageProcessor;
beforeAll(async () => {
({ storage, cleanup } = await setup());
storageModule.setStorageForTesting(storage);
// Require AFTER setStorageForTesting so the module sees our injection.
delete require.cache[require.resolve('../../src/services/imageProcessor')];
imageProcessor = require('../../src/services/imageProcessor');
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-src-'));
}, 30000);
afterAll(async () => {
storageModule.resetStorage();
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
if (cleanup) await cleanup();
});
test('generateThumbnail writes through storage and returns a relative key', async () => {
const src = await makeSourceJpeg(tmpDir, 'sample.jpg');
const key = await imageProcessor.generateThumbnail(src);
expect(key).toBe('thumbnails/thumb_sample.jpg');
expect(await storage.exists(key)).toBe(true);
const stat = await storage.stat(key);
expect(stat.size).toBeGreaterThan(100);
// Verify the bytes are a valid JPEG by re-parsing with sharp on local mode.
if (storage.kind() === 'local') {
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
expect(meta.format).toBe('jpeg');
expect(meta.width).toBeLessThanOrEqual(300);
}
});
test('generateHeroImage writes through storage and returns a relative key', async () => {
const src = await makeSourceJpeg(tmpDir, 'hero-source.jpg');
const key = await imageProcessor.generateHeroImage(src);
expect(key).toBe('heroes/hero_hero-source.jpg');
expect(await storage.exists(key)).toBe(true);
});
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);
expect(await imageProcessor.isThumbnailValid(key)).toBe(true);
expect(await imageProcessor.isThumbnailValid('thumbnails/does-not-exist.jpg')).toBe(false);
});
test('generateVideoPlaceholder writes a thumbnail entirely from buffer', async () => {
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4');
expect(key).toBe('thumbnails/thumb_demo.jpg');
expect(await storage.exists(key)).toBe(true);
});
test('withLocalCopy yields a usable local path on both backends', async () => {
const sourceKey = 'fixture/withlocal.jpg';
const src = await makeSourceJpeg(tmpDir, 'withlocal.jpg');
const buf = await fs.readFile(src);
await storage.put(sourceKey, buf, { contentType: 'image/jpeg' });
const seenSize = await imageProcessor.withLocalCopy(sourceKey, async (localPath) => {
const meta = await sharp(localPath).metadata();
return meta.width;
});
expect(seenSize).toBe(800);
});
});
@@ -0,0 +1,189 @@
const path = require('path');
const fs = require('fs');
const fsp = require('fs').promises;
const os = require('os');
const crypto = require('crypto');
const { Readable } = require('stream');
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
// MinIO defaults match docker-compose.dev.yml. Override via TEST_S3_* if needed.
const TEST_S3 = {
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
region: 'us-east-1',
};
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
// Build the matrix of backends to test. Local always runs; S3 runs against MinIO
// unless SKIP_S3_TESTS=true (CI default). The same suite runs against both so
// every consumer can rely on identical semantics.
function backendCases() {
const cases = [
{
name: 'LocalFsStorage',
async setup() {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-storage-'));
const storage = new LocalFsStorage({ root });
await storage.init();
return { storage, cleanup: () => fsp.rm(root, { recursive: true, force: true }) };
},
},
];
if (!skipS3) {
cases.push({
name: 'S3StorageBackend (MinIO)',
async setup() {
const bucket = `picpeak-test-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
const s3Client = new S3Client({
endpoint: TEST_S3.endpoint,
region: TEST_S3.region,
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
forcePathStyle: true,
});
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
const storage = new S3StorageBackend({
bucket,
region: TEST_S3.region,
endpoint: TEST_S3.endpoint,
accessKeyId: TEST_S3.accessKeyId,
secretAccessKey: TEST_S3.secretAccessKey,
forcePathStyle: true,
sslEnabled: false,
});
await storage.init();
return {
storage,
async cleanup() {
// Empty bucket then delete it.
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
if (list.Contents?.length) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
}));
}
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
},
};
},
});
}
return cases;
}
async function readToString(stream) {
const chunks = [];
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return Buffer.concat(chunks).toString('utf-8');
}
describe.each(backendCases())('StorageBackend contract: $name', ({ setup }) => {
let storage;
let cleanup;
beforeAll(async () => {
({ storage, cleanup } = await setup());
}, 30000);
afterAll(async () => {
if (cleanup) await cleanup();
});
test('put + get + exists + stat + delete round-trip with a buffer body', async () => {
const key = 'photos/event-a/IMG_0001.jpg';
const body = Buffer.from('hello picpeak');
await storage.put(key, body, { contentType: 'image/jpeg' });
expect(await storage.exists(key)).toBe(true);
const stat = await storage.stat(key);
expect(stat).not.toBeNull();
expect(stat.size).toBe(body.length);
const stream = await storage.get(key);
const text = await readToString(stream);
expect(text).toBe('hello picpeak');
await storage.delete(key);
expect(await storage.exists(key)).toBe(false);
expect(await storage.stat(key)).toBeNull();
});
test('put accepts a Readable stream body', async () => {
const key = 'photos/event-b/streamed.bin';
const body = Readable.from(Buffer.from('streamed payload'));
await storage.put(key, body);
const got = await readToString(await storage.get(key));
expect(got).toBe('streamed payload');
});
test('putFromFile + getToFile round-trip', async () => {
const tmpIn = path.join(os.tmpdir(), `in-${Date.now()}.txt`);
const tmpOut = path.join(os.tmpdir(), `out-${Date.now()}.txt`);
await fsp.writeFile(tmpIn, 'file payload');
const key = 'thumbnails/thumb_x.jpg';
await storage.putFromFile(key, tmpIn, { contentType: 'image/jpeg' });
await storage.getToFile(key, tmpOut);
const text = await fsp.readFile(tmpOut, 'utf-8');
expect(text).toBe('file payload');
await fsp.unlink(tmpIn).catch(() => {});
await fsp.unlink(tmpOut).catch(() => {});
});
test('list returns entries under a prefix with size + key', async () => {
await storage.put('events/active/a/photo1.jpg', Buffer.from('a1'));
await storage.put('events/active/a/photo2.jpg', Buffer.from('a22'));
await storage.put('events/active/b/photo3.jpg', Buffer.from('b333'));
const entries = await storage.list('events/active/a');
const keys = entries.map((e) => e.key).sort();
expect(keys).toEqual(['events/active/a/photo1.jpg', 'events/active/a/photo2.jpg']);
const sizes = Object.fromEntries(entries.map((e) => [e.key, e.size]));
expect(sizes['events/active/a/photo1.jpg']).toBe(2);
expect(sizes['events/active/a/photo2.jpg']).toBe(3);
});
test('rename moves an object from src to dst (atomic on local; copy+delete on s3)', async () => {
await storage.put('uploads/temp.jpg', Buffer.from('rename-me'));
await storage.rename('uploads/temp.jpg', 'uploads/final.jpg');
expect(await storage.exists('uploads/temp.jpg')).toBe(false);
expect(await storage.exists('uploads/final.jpg')).toBe(true);
const text = await readToString(await storage.get('uploads/final.jpg'));
expect(text).toBe('rename-me');
});
test('copy duplicates an object without removing the source', async () => {
await storage.put('events/source.jpg', Buffer.from('src'));
await storage.copy('events/source.jpg', 'events/copied.jpg');
expect(await storage.exists('events/source.jpg')).toBe(true);
expect(await storage.exists('events/copied.jpg')).toBe(true);
});
test('delete on a missing key is a no-op (does not throw)', async () => {
await expect(storage.delete('does/not/exist.jpg')).resolves.toBeUndefined();
});
test('stat on a missing key returns null', async () => {
expect(await storage.stat('still/not/here.jpg')).toBeNull();
});
test('rejects path traversal attempts', async () => {
await expect(storage.put('../escape.txt', Buffer.from('x'))).rejects.toThrow(/traversal/i);
await expect(storage.get('../escape.txt')).rejects.toThrow(/traversal/i);
});
});
@@ -0,0 +1,239 @@
// Worker reads WEBHOOK_ALLOW_PRIVATE_URLS at module-load. Set it BEFORE
// requiring the worker so the local-stub URLs (127.0.0.1:<random>) pass
// the SSRF check by default.
process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
const http = require('http');
const { db } = require('../../src/database/db');
const webhookService = require('../../src/services/webhookService');
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
// Local-only test stub: matches what dev/webhook-receiver/server.js does
// in the docker-compose flow but spun up inside the Jest process so the
// suite is self-contained.
function makeStub({ status = 200, delayMs = 0, bodyOverride = null } = {}) {
const requests = [];
const server = http.createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const body = Buffer.concat(chunks).toString('utf8');
requests.push({ method: req.method, url: req.url, headers: req.headers, body });
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
res.writeHead(status, { 'Content-Type': 'text/plain' });
res.end(bodyOverride !== null ? bodyOverride : (status >= 200 && status < 300 ? 'ok' : 'forced'));
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({ url: `http://127.0.0.1:${port}/`, requests, close: () => new Promise((r) => server.close(r)) });
});
});
}
async function insertWebhook(url, events = ['event.published'], extras = {}) {
// Tests need the WORKER to bypass SSRF on 127.0.0.1 stubs, but the
// route layer's allowlist check is bypassed here since we insert
// straight into the DB.
const { plaintext, preview } = webhookService.generateSecret();
const insert = await db('webhooks').insert({
name: extras.name || 'test',
url,
secret: plaintext,
secret_preview: preview,
events: JSON.stringify(events),
active: extras.active !== false,
created_by: 1,
}).returning('id');
const id = insert[0]?.id || insert[0];
return { id, secret: plaintext };
}
async function clearWebhooks() {
await db('webhook_deliveries').del();
await db('webhooks').del();
}
describe('webhook delivery worker (#327)', () => {
beforeAll(async () => {
// Schema is expected to already be applied by `npm run migrate`. We
// just verify the webhooks tables exist; if not, the test harness has
// missed running migration 082.
const ok = await db.schema.hasTable('webhooks');
if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first');
}, 30000);
afterAll(async () => {
stopWebhookDeliveryWorker();
await db.destroy();
});
beforeEach(async () => {
await clearWebhooks();
});
test('signs the body with HMAC-SHA256 and the receiver can verify', async () => {
const stub = await makeStub({ status: 200 });
try {
const { id, secret } = await insertWebhook(stub.url);
await webhookService.fire('event.published', { event: { id: 1, slug: 'sig-test' } });
await __test.tick();
expect(stub.requests).toHaveLength(1);
const got = stub.requests[0];
const sig = got.headers['x-picpeak-signature'];
expect(sig).toBeTruthy();
// Receiver-side verification using the SAME helper we ship in the README.
expect(webhookService.verifySignature(secret, got.body, sig)).toBe(true);
// Tampering must fail.
expect(webhookService.verifySignature(secret, got.body + 'x', sig)).toBe(false);
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('success');
expect(row.attempt_count).toBe(1);
expect(row.response_status).toBe(200);
expect(row.latency_ms).toBeGreaterThanOrEqual(0);
} finally {
await stub.close();
}
});
test('headers include event type and a unique delivery id', async () => {
const stub = await makeStub({ status: 200 });
try {
await insertWebhook(stub.url, ['photo.uploaded']);
await webhookService.fire('photo.uploaded', { photo: { id: 7 } });
await __test.tick();
const got = stub.requests[0];
expect(got.headers['x-picpeak-event']).toBe('photo.uploaded');
expect(got.headers['x-picpeak-delivery']).toBeTruthy();
expect(got.headers['user-agent']).toMatch(/PicPeak-Webhooks/);
} finally {
await stub.close();
}
});
test('on 5xx, schedules a retry with exponential backoff and stays pending', async () => {
const stub = await makeStub({ status: 500 });
try {
const { id } = await insertWebhook(stub.url);
await webhookService.fire('event.published', { event: { id: 2 } });
await __test.tick();
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('pending');
expect(row.attempt_count).toBe(1);
expect(row.response_status).toBe(500);
// BACKOFF_MS[0] = 60s; next_retry_at should be ~60s in the future.
const dueIn = new Date(row.next_retry_at).getTime() - Date.now();
expect(dueIn).toBeGreaterThan(50_000);
expect(dueIn).toBeLessThan(70_000);
} finally {
await stub.close();
}
});
test('after MAX_ATTEMPTS failures, status flips to failed and the row is closed', async () => {
const stub = await makeStub({ status: 500 });
try {
const { id } = await insertWebhook(stub.url);
// Pre-seed a delivery already at attempt_count = 4 so a single tick
// takes it to 5 → failed (avoids waiting through backoffs).
await db('webhook_deliveries').insert({
webhook_id: id,
event_type: 'event.published',
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 4,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
});
await __test.tick();
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('failed');
expect(row.attempt_count).toBe(5);
expect(row.completed_at).toBeTruthy();
expect(row.next_retry_at).toBeNull();
} finally {
await stub.close();
}
});
test('truncates response body to 1KB before storing', async () => {
const big = 'x'.repeat(5000);
const stub = await makeStub({ status: 200, bodyOverride: big });
try {
const { id } = await insertWebhook(stub.url);
await webhookService.fire('event.published', { event: {} });
await __test.tick();
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('success');
expect(Buffer.byteLength(row.response_body || '', 'utf8')).toBeLessThanOrEqual(1024);
} finally {
await stub.close();
}
});
test('does not deliver to disabled webhooks (post-mortem state captured)', async () => {
const stub = await makeStub({ status: 200 });
try {
const { id } = await insertWebhook(stub.url, ['event.published'], { active: false });
// fire enqueues regardless of active state at fire-time, but we
// disabled BEFORE firing so nothing is enqueued. Direct insert to
// exercise the worker's mid-flight disable check:
await db('webhook_deliveries').insert({
webhook_id: id,
event_type: 'event.published',
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 0,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
});
await __test.tick();
expect(stub.requests).toHaveLength(0);
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('failed');
expect(row.last_error).toMatch(/disabled/i);
} finally {
await stub.close();
}
});
test('rejects loopback URLs when WEBHOOK_ALLOW_PRIVATE_URLS=false', async () => {
__test.setAllowPrivateUrls(false);
try {
const { id } = await insertWebhook('http://127.0.0.1:9/');
await db('webhook_deliveries').insert({
webhook_id: id,
event_type: 'event.published',
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 0,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
});
await __test.tick();
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('failed');
expect(row.last_error).toMatch(/private|internal/i);
} finally {
__test.setAllowPrivateUrls(true);
}
});
test('worker can be started + stopped without leaking timers', async () => {
startWebhookDeliveryWorker();
startWebhookDeliveryWorker(); // idempotent
stopWebhookDeliveryWorker();
stopWebhookDeliveryWorker(); // idempotent
// If timers leaked the test runner would warn after force-exit; assertion
// is just "no throw".
expect(true).toBe(true);
});
});
@@ -0,0 +1,112 @@
/**
* Unit test for the non-mutating isSessionExpired() helper added to
* middleware/sessionTimeout.js. Used by GET /auth/session to mirror the
* timeout enforcement that sessionTimeoutMiddleware applies to /api/admin
* endpoints — closing the asymmetry that surfaced as the redirect-loop
* recurrence on v3.39.1-beta.0 (issue #350).
*
* The helper has two branches:
* 1. In-memory `lastActivity` exists for this token → expired iff
* now - lastActivity > timeout.
* 2. No in-memory entry (post-restart, or first request) → expired
* iff token's iat is older than the timeout (post-restart guard
* that the existing middleware already implements at line ~101).
*
* Both branches must NOT mutate the in-memory `sessions` Map — the
* middleware is the only place that tracks activity. We assert that.
*/
jest.mock('../../src/database/db', () => ({
db: () => ({
where: () => ({
first: () => ({
timeout: () => Promise.resolve(null),
}),
}),
}),
}));
// Speed up the cached-timeout reads. The module reads
// `security_session_timeout_minutes` from app_settings and falls back to
// DEFAULT_SESSION_TIMEOUT (60 min) when the row is null.
const SIXTY_MINUTES_MS = 60 * 60 * 1000;
const sessionTimeout = require('../../src/middleware/sessionTimeout');
const { isSessionExpired } = sessionTimeout;
function makeDecodedToken({ id = 1, iatSecondsAgo = 0 } = {}) {
return { id, iat: Math.floor((Date.now() - iatSecondsAgo * 1000) / 1000) };
}
describe('isSessionExpired (sessionTimeout helper)', () => {
it('returns false for a freshly-issued token with no in-memory record', async () => {
const decoded = makeDecodedToken({ id: 1, iatSecondsAgo: 60 });
expect(await isSessionExpired('fresh-token-1', decoded)).toBe(false);
});
it('returns true when iat is older than the timeout (post-restart guard)', async () => {
const decoded = makeDecodedToken({
id: 2,
// 90 minutes > 60 minute default timeout
iatSecondsAgo: 90 * 60,
});
expect(await isSessionExpired('stale-token-2', decoded)).toBe(true);
});
it('returns false / true based on lastActivity when one exists', async () => {
// Drive the in-memory map by running the actual middleware once to
// record activity for the token, then check the helper.
const decoded = makeDecodedToken({ id: 3 });
// Drive the actual middleware once with a real signed token so it
// records this token in the in-memory `sessions` Map. Then check the
// helper sees that recent activity and reports "not expired".
const res = { status: jest.fn(() => res), json: jest.fn() };
const jwt = require('jsonwebtoken');
process.env.JWT_SECRET = 'session-timeout-helper-test-secret';
const realToken = jwt.sign(decoded, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
});
const realReq = {
headers: { authorization: `Bearer ${realToken}` },
cookies: {},
};
await sessionTimeout.sessionTimeoutMiddleware(realReq, res, () => {});
const decodedReal = jwt.decode(realToken);
// Just-recorded → not expired
expect(await isSessionExpired(realToken, decodedReal)).toBe(false);
});
it('returns false when token / decoded is missing (defensive)', async () => {
expect(await isSessionExpired(null, { id: 1 })).toBe(false);
expect(await isSessionExpired('tok', null)).toBe(false);
expect(await isSessionExpired('tok', {})).toBe(false);
});
// Sanity: the helper must not poke the `sessions` Map. Indirectly check
// by counting active sessions before/after a call with a never-seen
// token — should not change.
it('does not mutate the in-memory sessions map', async () => {
const before = sessionTimeout.getActiveSessions();
await isSessionExpired('never-seen-token-99', makeDecodedToken({ id: 99 }));
const after = sessionTimeout.getActiveSessions();
expect(after).toBe(before);
});
it('uses the default 60-minute timeout when no DB setting exists', async () => {
// 59 minutes → not expired
const fresh = makeDecodedToken({ id: 4, iatSecondsAgo: 59 * 60 });
expect(await isSessionExpired('fresh-4', fresh)).toBe(false);
// 61 minutes → expired (just past the default)
const stale = makeDecodedToken({ id: 5, iatSecondsAgo: 61 * 60 });
expect(await isSessionExpired('stale-5', stale)).toBe(true);
});
// Document the constant the test relies on so a future timeout change
// makes this assertion explicit rather than mysterious.
it('default timeout is 60 minutes (constant under test)', () => {
expect(SIXTY_MINUTES_MS).toBe(60 * 60 * 1000);
});
});
@@ -0,0 +1,395 @@
/**
* Regression test for the /admin/login → /admin/dashboard → /admin/login
* redirect loop reported on v3.32.4-beta.0.
*
* Cause: GET /auth/session was less strict than the adminAuth middleware.
* The session endpoint accepted tokens that the protected endpoints
* subsequently rejected with 401, which the frontend's interceptor
* translated into a hard redirect to /admin/login. /auth/session then
* said "valid: true" again on the next page load and the cycle closed.
*
* /auth/session must reject the same admin tokens adminAuth would
* reject, specifically: deactivated admin user, deleted admin user,
* password changed since iat. Same for gallery: archived event.
*/
const express = require('express');
const request = require('supertest');
const jwt = require('jsonwebtoken');
process.env.JWT_SECRET = 'session-symmetry-test-secret';
const fakeDb = {
adminUsers: [],
events: [],
revokedTokens: [],
};
jest.mock('../../src/database/db', () => {
const formatBoolean = (v) => (v ? 1 : 0);
void formatBoolean;
function dbFn(table) {
if (table === 'admin_users') {
let rowFilter = () => true;
return {
where(criteria) {
rowFilter = (row) => {
return Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
return row[k] === v;
});
};
return this;
},
select(...cols) {
this._cols = cols;
return this;
},
async first() {
const row = fakeDb.adminUsers.find(rowFilter);
if (!row) return undefined;
if (!this._cols) return row;
const out = {};
for (const c of this._cols) out[c] = row[c];
return out;
},
};
}
if (table === 'events') {
let rowFilter = () => true;
return {
where(criteria) {
rowFilter = (row) =>
Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
return row[k] === v;
});
return this;
},
async first() {
return fakeDb.events.find(rowFilter);
},
};
}
throw new Error(`Unexpected table: ${table}`);
}
return { db: dbFn, formatBoolean: () => 1 };
});
jest.mock('../../src/utils/dbCompat', () => ({
formatBoolean: (v) => (v ? 1 : 0),
}));
jest.mock('../../src/utils/tokenRevocation', () => ({
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
revokeToken: jest.fn(),
}));
jest.mock('../../src/utils/tokenUtils', () => ({
getAdminTokenFromRequest: (req) => {
const auth = req.headers.authorization;
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
return null;
},
getGalleryTokenFromRequest: () => null,
setAdminAuthCookie: jest.fn(),
setGalleryAuthCookies: jest.fn(),
clearAdminAuthCookie: jest.fn(),
clearGalleryAuthCookies: jest.fn(),
buildCookieOptionsWithExpiry: () => ({}),
}));
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) }));
// Mock sessionTimeout's isSessionExpired so each test controls the return.
// Default: not expired (so existing tests keep passing without setup).
jest.mock('../../src/middleware/sessionTimeout', () => ({
endSession: jest.fn(),
isSessionExpired: jest.fn(() => Promise.resolve(false)),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));
const authRouter = require('../../src/routes/auth');
function makeApp() {
const app = express();
app.use(express.json());
app.use('/auth', authRouter);
return app;
}
function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
const issuedAt = iat ?? Math.floor(Date.now() / 1000);
// Note: do NOT pass noTimestamp:true here — that strips iat from the
// payload entirely, defeating the password-change comparison. Provide
// iat (and exp) via the payload directly instead.
return jwt.sign(
{ id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 },
process.env.JWT_SECRET,
{ issuer: 'picpeak-auth' }
);
}
function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) {
return jwt.sign(
{ eventId, eventSlug, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
}
describe('GET /auth/session — symmetry with protected middleware', () => {
beforeEach(() => {
fakeDb.adminUsers = [];
fakeDb.events = [];
fakeDb.revokedTokens = [];
});
it('returns valid:true for an active admin token', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: true,
password_changed_at: null,
});
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(res.body.type).toBe('admin');
});
it('returns valid:false when the admin user has been deactivated', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: false,
password_changed_at: null,
});
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false when the admin user no longer exists', async () => {
// adminUsers is empty
const token = signAdminToken({ id: 999 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false when password was changed after the token was issued', async () => {
// iat must be in the past, exp must be in the future so jwt.verify
// doesn't reject the token before /auth/session even gets to look
// at password_changed_at.
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
const tokenExp = tokenIssuedAt + 86400;
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: true,
password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat
});
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:true when password was changed BEFORE the token was issued', async () => {
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60;
const tokenExp = tokenIssuedAt + 86400;
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: true,
password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat
});
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
it('returns valid:false for a gallery token whose event is archived', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: true,
expires_at: null,
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false for a gallery token whose event is expired', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() - 86400_000),
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:true for an active gallery token', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() + 86400_000),
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
it('returns valid:false when the token is revoked', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
fakeDb.revokedTokens.push(1);
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(401);
expect(res.body.valid).toBe(false);
});
// Session-timeout symmetry — issue #350 recurrence on v3.39.1-beta.0.
// sessionTimeoutMiddleware (mounted on /api/admin) rejects idle/old-iat
// tokens with 401 SESSION_TIMEOUT, but /auth/session previously didn't.
// The new isSessionExpired helper closes that asymmetry.
describe('session-timeout symmetry', () => {
const { isSessionExpired } = require('../../src/middleware/sessionTimeout');
beforeEach(() => {
isSessionExpired.mockReset();
// Default to "active session" so the other admin checks above also
// pass when this branch runs.
isSessionExpired.mockResolvedValue(false);
});
it('returns valid:false when isSessionExpired reports the token has timed out', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
isSessionExpired.mockResolvedValue(true);
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
expect(res.body.error).toBe('Session expired');
});
it('returns valid:true for an active admin token (helper says not expired)', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
isSessionExpired.mockResolvedValue(false);
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(isSessionExpired).toHaveBeenCalledTimes(1);
});
it('does not call isSessionExpired for gallery tokens', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() + 86400_000),
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(isSessionExpired).not.toHaveBeenCalled();
});
it('falls through (treats as valid) if the helper itself throws', async () => {
// Defensive: the require() in auth.js is wrapped in try/catch so a
// missing/broken helper doesn't fail-closed during early bootstrap.
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
isSessionExpired.mockRejectedValue(new Error('boom'));
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
});
});
@@ -0,0 +1,109 @@
/**
* Unit tests for backgroundProcessor.claimNextPhoto.
*
* Mocks the db so we don't need a live postgres/sqlite — focuses on
* the claim contract: returns null when no rows, returns row + flips
* status to 'processing' when one is available, returns null when a
* race loses the UPDATE-with-guard.
*/
jest.mock('../../src/services/photoProcessor', () => ({
processPhoto: jest.fn(),
processUploadedPhotos: jest.fn(),
queueFilesForProcessing: jest.fn(),
}));
// Build a fake knex instance whose .transaction() takes a callback we can
// drive from the test, and whose query-builder records calls.
function makeFakeDb({ pendingRow = null, updateResult = 1, clientName = 'pg' } = {}) {
const queries = [];
const builder = () => {
const recorded = { wheres: [], updates: null, ordered: false, locked: false, skipped: false };
queries.push(recorded);
const chain = {
where: jest.fn(function (...args) {
recorded.wheres.push(args);
return chain;
}),
orderBy: jest.fn(function () {
recorded.ordered = true;
return chain;
}),
forUpdate: jest.fn(function () {
recorded.locked = true;
return chain;
}),
skipLocked: jest.fn(function () {
recorded.skipped = true;
return chain;
}),
first: jest.fn(async function () {
// Only the SELECT chain returns the pending row; the UPDATE chain
// never calls .first().
return pendingRow ? { ...pendingRow } : null;
}),
update: jest.fn(async function (data) {
recorded.updates = data;
return updateResult;
}),
};
return chain;
};
const trxFn = (table) => builder(table);
trxFn.client = { config: { client: clientName } };
trxFn.transaction = async (cb) => cb(trxFn);
// Top-level db('photos') returns same builder for the janitor test path.
const db = trxFn;
return { db, queries };
}
describe('backgroundProcessor.claimNextPhoto', () => {
function loadProcessor(db) {
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
return require('../../src/services/backgroundProcessor');
}
it('returns null when there are no pending photos (postgres path)', async () => {
const { db } = makeFakeDb({ pendingRow: null, clientName: 'pg' });
const bg = loadProcessor(db);
const result = await bg.claimNextPhoto();
expect(result).toBeNull();
});
it('returns the claimed row and flips status (postgres path)', async () => {
const pendingRow = { id: 42, processing_status: 'pending' };
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'pg' });
const bg = loadProcessor(db);
const result = await bg.claimNextPhoto();
expect(result).toEqual(pendingRow);
// The first query is the SELECT FOR UPDATE SKIP LOCKED.
expect(queries[0].locked).toBe(true);
expect(queries[0].skipped).toBe(true);
// The second query is the status update.
expect(queries[1].updates.processing_status).toBe('processing');
expect(queries[1].updates.processing_started_at).toBeInstanceOf(Date);
});
it('returns null when the SQLite UPDATE-with-guard loses the race', async () => {
const pendingRow = { id: 7 };
const { db } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 0 });
const bg = loadProcessor(db);
const result = await bg.claimNextPhoto();
expect(result).toBeNull();
});
it('returns the row when SQLite UPDATE-with-guard wins', async () => {
const pendingRow = { id: 7 };
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 1 });
const bg = loadProcessor(db);
const result = await bg.claimNextPhoto();
expect(result).toEqual(pendingRow);
// SQLite path: no FOR UPDATE / SKIP LOCKED.
expect(queries[0].locked).toBe(false);
expect(queries[0].skipped).toBe(false);
});
});
@@ -0,0 +1,75 @@
/**
* Unit tests for emailProcessor.htmlToText.
*
* Regression: when a template ships without a body_text, sendTemplateEmail
* used `htmlBody.replace(/<[^>]*>/g, '')` to derive the plain-text fallback.
* That regex strips angle-bracket tags but leaves the *contents* of <style>
* and <script> blocks intact — so any HTML wrapped by wrapEmailHtml() (which
* embeds a 100+ line <style> block) produced a "plain-text" email starting
* with `body { margin: 0; padding: 0; … }`. htmlToText fixes that.
*/
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
const { htmlToText } = require('../../src/services/emailProcessor');
describe('htmlToText', () => {
it('returns empty string for empty input', () => {
expect(htmlToText('')).toBe('');
expect(htmlToText(null)).toBe('');
expect(htmlToText(undefined)).toBe('');
});
it('strips <style> blocks and their contents', () => {
const html = '<html><head><style>body { margin: 0; color: red; }</style></head><body>Hello</body></html>';
const out = htmlToText(html);
expect(out).toBe('Hello');
expect(out).not.toMatch(/margin/);
expect(out).not.toMatch(/color/);
});
it('strips <script> blocks and their contents', () => {
const html = '<body><script>alert("x")</script>Hi</body>';
expect(htmlToText(html)).toBe('Hi');
});
it('converts <br> tags to newlines', () => {
expect(htmlToText('a<br>b<br />c<BR/>d')).toBe('a\nb\nc\nd');
});
it('keeps a paragraph break between adjacent <p> tags', () => {
expect(htmlToText('<p>one</p><p>two</p>')).toBe('one\n\ntwo');
});
it('decodes the common HTML entities', () => {
expect(htmlToText('Tom &amp; Jerry &lt;3 &quot;hi&quot;'))
.toBe('Tom & Jerry <3 "hi"');
});
it('handles a fully-wrapped email body without leaking CSS rules', () => {
// Shape mirrors what wrapEmailHtml() produces: a <style> block with many
// CSS rules followed by the actual content.
const wrapped = `
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; padding: 0; font-family: sans-serif; background-color: #f5f5f5; }
.email-container { max-width: 600px; }
.button { background-color: #5C8762; color: white !important; }
</style>
</head>
<body>
<h2>Galerie erfolgreich erstellt</h2>
<p>Liebe(r) Natalie,</p>
</body>
</html>`;
const out = htmlToText(wrapped);
expect(out).toContain('Galerie erfolgreich erstellt');
expect(out).toContain('Liebe(r) Natalie');
expect(out).not.toMatch(/margin/);
expect(out).not.toMatch(/font-family/);
expect(out).not.toMatch(/background-color/);
expect(out).not.toMatch(/\.button/);
});
});
@@ -0,0 +1,138 @@
/**
* Unit tests for emailProcessor.safeTemplateReplace.
*
* Covers the two regressions that hit picpeak.nothaft.cloud on the
* 3.32.x betas:
* - {{#if VAR}}…{{/if}} blocks rendered as literal text in the email
* because the renderer only handled {{var}} substitution and the
* shipped templates use Handlebars-style conditionals.
* - {{var}} substitution inside a kept conditional block.
*
* The publish-from-draft password localisation lives inside the wider
* processTemplate() pipeline (DB-backed), so it isn't covered here — the
* sentinel string '(set at creation)' is asserted only at the i18n-map
* level by integration in adminEvents.js.
*/
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
const { safeTemplateReplace } = require('../../src/services/emailProcessor');
describe('safeTemplateReplace', () => {
describe('flat variable substitution', () => {
it('replaces {{var}} with the variable value', () => {
expect(safeTemplateReplace('Hello {{name}}!', { name: 'Paul' }))
.toBe('Hello Paul!');
});
it('leaves unknown variables untouched', () => {
expect(safeTemplateReplace('Hello {{name}}!', {}))
.toBe('Hello {{name}}!');
});
it('coerces non-string values to string', () => {
expect(safeTemplateReplace('Count: {{n}}', { n: 42 }))
.toBe('Count: 42');
});
it('handles empty templates and missing variables map', () => {
expect(safeTemplateReplace('', { x: 1 })).toBe('');
expect(safeTemplateReplace('plain text', undefined)).toBe('plain text');
expect(safeTemplateReplace(null, {})).toBe(null);
});
});
describe('{{#if VAR}}…{{/if}} blocks', () => {
it('strips the block when the variable is missing', () => {
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
expect(safeTemplateReplace(tpl, {})).toBe('before after');
});
it('strips the block when the variable is an empty string', () => {
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
expect(safeTemplateReplace(tpl, { welcome: '' })).toBe('before after');
});
it('strips the block when the variable is null', () => {
const tpl = '{{#if x}}kept{{/if}}';
expect(safeTemplateReplace(tpl, { x: null })).toBe('');
});
it('keeps the block and substitutes inside it when truthy', () => {
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
expect(safeTemplateReplace(tpl, { welcome: 'world' }))
.toBe('before HELLO world after');
});
it('handles multi-line conditional blocks', () => {
const tpl = [
'Liebe(r) {{host_name}},',
'',
'{{#if welcome_message}}',
'Persönliche Nachricht:',
'{{welcome_message}}',
'{{/if}}',
'Galerie-Details:',
].join('\n');
const withMsg = safeTemplateReplace(tpl, {
host_name: 'Natalie',
welcome_message: 'Schön, dass ihr da seid!',
});
expect(withMsg).toContain('Persönliche Nachricht:');
expect(withMsg).toContain('Schön, dass ihr da seid!');
expect(withMsg).not.toContain('{{#if');
expect(withMsg).not.toContain('{{/if');
const withoutMsg = safeTemplateReplace(tpl, {
host_name: 'Natalie',
welcome_message: '',
});
expect(withoutMsg).not.toContain('Persönliche Nachricht');
expect(withoutMsg).not.toContain('{{#if');
expect(withoutMsg).not.toContain('{{/if');
expect(withoutMsg).toContain('Liebe(r) Natalie,');
expect(withoutMsg).toContain('Galerie-Details:');
});
it('handles multiple sibling conditionals independently', () => {
const tpl = '{{#if a}}A{{/if}}|{{#if b}}B{{/if}}|{{#if c}}C{{/if}}';
expect(safeTemplateReplace(tpl, { a: 1, c: 'yes' })).toBe('A||C');
});
it('treats numeric 0 as falsy', () => {
expect(safeTemplateReplace('{{#if n}}has-n{{/if}}', { n: 0 })).toBe('');
});
});
describe('HTML escaping (escapeHtml: true)', () => {
it('does not escape by default', () => {
const tpl = 'Welcome to {{event_name}}';
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>' }))
.toBe('Welcome to Test <script>');
});
it('escapes admin-supplied values when opted in', () => {
const tpl = 'Welcome to {{event_name}}';
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>alert(1)</script>' }, { escapeHtml: true }))
.toBe('Welcome to Test &lt;script&gt;alert(1)&lt;/script&gt;');
});
it('escapes both the < > and & characters and quotes', () => {
expect(safeTemplateReplace('{{x}}', { x: '<a href="evil">A & B\'s</a>' }, { escapeHtml: true }))
.toBe('&lt;a href=&quot;evil&quot;&gt;A &amp; B&#39;s&lt;/a&gt;');
});
it('passes welcome_message through unescaped (already HTML from formatWelcomeMessage)', () => {
const tpl = '<p>{{welcome_message}}</p>';
expect(safeTemplateReplace(tpl, { welcome_message: 'Hi<br />there' }, { escapeHtml: true }))
.toBe('<p>Hi<br />there</p>');
});
it('passes server-generated URLs through unescaped', () => {
const tpl = '<a href="{{gallery_link}}">link</a>';
expect(safeTemplateReplace(tpl, { gallery_link: 'https://example.com/g/abc?token=xyz&u=1' }, { escapeHtml: true }))
.toBe('<a href="https://example.com/g/abc?token=xyz&u=1">link</a>');
});
});
});
@@ -0,0 +1,320 @@
const fs = require('fs');
const fsPromises = fs.promises;
const os = require('os');
const path = require('path');
// Silence the logger so test output stays clean. Capture calls so the
// "warning logged" assertions can still verify behaviour.
jest.mock('../../src/utils/logger', () => ({
warn: jest.fn(),
info: jest.fn(),
error: jest.fn(),
debug: jest.fn()
}));
const logger = require('../../src/utils/logger');
// Required ONCE at module top so the jest.mock factory above applies to
// the logger reference that fontsService captures. A previous version
// re-required it inside beforeEach() with jest.resetModules() — that
// silently bypassed the mock (logger calls went to the real logger),
// so the "warning logged" assertions would resolve as 0 calls and
// silently pass-as-noop. Module-level state in fontsService is just
// the cache, which clearFontsCache() resets between tests.
const fontsService = require('../../src/services/fontsService');
// Probe at load time: is the host filesystem case-sensitive?
// macOS APFS and Windows NTFS treat "Inter" and "INTER" as the same
// directory entry, which means the "two folders, same lowercase key"
// dedup test below can't be set up via real folders on those platforms —
// the second mkdir is a no-op. Skip that one test conditionally.
const FS_IS_CASE_SENSITIVE = (() => {
const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fs-probe-'));
fs.writeFileSync(path.join(probeDir, 'casetest'), '');
let sensitive = true;
try {
fs.accessSync(path.join(probeDir, 'CASETEST'));
sensitive = false;
} catch { /* file not found → case-sensitive FS */ }
fs.rmSync(probeDir, { recursive: true, force: true });
return sensitive;
})();
const testCaseSensitiveFS = FS_IS_CASE_SENSITIVE ? test : test.skip;
let bundledRoot;
let userRoot;
/**
* Create a font family folder with the given weights (and optional meta.json).
* @param {string} root absolute path to the bundled or user root
* @param {string} folderName e.g. "Inter" or "Playfair-Display"
* @param {Array<number>|Array<string>} weights numeric weights (creates `<w>.woff2`)
* or filenames to create directly
* @param {Object|null} meta optional meta.json contents (object) or null
*/
async function makeFamily(root, folderName, weights, meta = null) {
const dir = path.join(root, folderName);
await fsPromises.mkdir(dir, { recursive: true });
for (const w of weights) {
const fname = typeof w === 'number' ? `${w}.woff2` : w;
await fsPromises.writeFile(path.join(dir, fname), Buffer.from([]));
}
if (meta !== null) {
await fsPromises.writeFile(
path.join(dir, 'meta.json'),
typeof meta === 'string' ? meta : JSON.stringify(meta)
);
}
return dir;
}
beforeEach(async () => {
jest.clearAllMocks();
bundledRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-bundled-'));
userRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-user-'));
process.env.PICPEAK_BUNDLED_FONTS_ROOT = bundledRoot;
// The user root resolves under STORAGE_PATH/fonts, so STORAGE_PATH must
// point at the parent of userRoot — we name the leaf "fonts" ourselves.
const storageParent = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-storage-'));
await fsPromises.rename(userRoot, path.join(storageParent, 'fonts'));
userRoot = path.join(storageParent, 'fonts');
process.env.STORAGE_PATH = storageParent;
// Reset the module-level cache so each test sees a fresh scan.
// (Both getBundledFontsRoot and getUserFontsRoot read process.env at
// call-time, so the env vars set above are picked up without needing
// to re-require the module — see fontsService.js getBundledFontsRoot /
// getUserFontsRoot.)
fontsService.clearFontsCache();
});
afterEach(async () => {
fontsService.clearFontsCache();
await fsPromises.rm(bundledRoot, { recursive: true, force: true }).catch(() => {});
// userRoot's parent is the actual mkdtemp; remove it.
await fsPromises.rm(path.dirname(userRoot), { recursive: true, force: true }).catch(() => {});
delete process.env.PICPEAK_BUNDLED_FONTS_ROOT;
delete process.env.STORAGE_PATH;
});
describe('fontsService.listFonts', () => {
describe('roots', () => {
test('empty bundled root + missing user root → []', async () => {
// delete user root so it triggers ENOENT
await fsPromises.rm(path.dirname(userRoot), { recursive: true, force: true });
const fonts = await fontsService.listFonts();
expect(fonts).toEqual([]);
});
test('missing bundled root (ENOENT) → [], does not throw', async () => {
await fsPromises.rm(bundledRoot, { recursive: true, force: true });
const fonts = await fontsService.listFonts();
expect(fonts).toEqual([]);
});
test('non-directory entries at the root are skipped', async () => {
await fsPromises.writeFile(path.join(bundledRoot, 'README.md'), 'hi');
await makeFamily(bundledRoot, 'Inter', [400, 700]);
const fonts = await fontsService.listFonts();
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
});
test('hidden folders are skipped', async () => {
await makeFamily(bundledRoot, '.git', [400]);
await makeFamily(bundledRoot, '.DS_Store', [400]);
await makeFamily(bundledRoot, 'Inter', [400]);
const fonts = await fontsService.listFonts();
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
});
});
describe('weight parsing', () => {
test('three weight files → sorted ascending', async () => {
await makeFamily(bundledRoot, 'Inter', [700, 400, 600]);
const [inter] = await fontsService.listFonts();
expect(inter.weights).toEqual([400, 600, 700]);
});
test('non-numeric filenames are ignored', async () => {
await makeFamily(bundledRoot, 'Inter', ['bold.woff2', 'regular.woff2', '400.woff2', '700.woff2']);
const [inter] = await fontsService.listFonts();
expect(inter.weights).toEqual([400, 700]);
});
test('non-.woff2 files are ignored', async () => {
await makeFamily(bundledRoot, 'Inter', ['400.ttf', '400.woff', '400.woff2', '700.otf']);
const [inter] = await fontsService.listFonts();
expect(inter.weights).toEqual([400]);
});
test('weight values out of range (sub-1 / over-1000) are ignored', async () => {
await makeFamily(bundledRoot, 'Inter', [0, 400, 1001, 700]);
const [inter] = await fontsService.listFonts();
expect(inter.weights).toEqual([400, 700]);
});
test('family folder with no usable .woff2 files is silently skipped', async () => {
await makeFamily(bundledRoot, 'NoWeights', ['readme.txt', 'bold.ttf']);
await makeFamily(bundledRoot, 'Inter', [400]);
const fonts = await fontsService.listFonts();
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Skipping NoWeights')
);
});
});
describe('folder name → display family', () => {
test('hyphens become spaces', async () => {
await makeFamily(bundledRoot, 'Playfair-Display', [400]);
const [pd] = await fontsService.listFonts();
expect(pd.family).toBe('Playfair Display');
});
test('case is preserved', async () => {
await makeFamily(bundledRoot, 'IBM-Plex-Sans', [400]);
const [ibm] = await fontsService.listFonts();
expect(ibm.family).toBe('IBM Plex Sans');
});
});
describe('user-overrides-bundled', () => {
test('user folder of the same family wins; weights come from user', async () => {
await makeFamily(bundledRoot, 'Inter', [400, 600, 700]);
await makeFamily(userRoot, 'Inter', [400, 900]); // different weights
const [inter] = await fontsService.listFonts();
expect(inter.weights).toEqual([400, 900]);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('overrides bundled default')
);
});
test('user-only family is included', async () => {
await makeFamily(userRoot, 'Lobster', [400]);
const fonts = await fontsService.listFonts();
expect(fonts.map((f) => f.family)).toEqual(['Lobster']);
});
testCaseSensitiveFS('case-insensitive duplicate within the same root → second skipped, warning', async () => {
// Two folder names whose lowercase keys collide. On a case-sensitive
// FS (Linux ext4) we can create both `Inter/` and `INTER/`; on a
// case-insensitive FS (macOS APFS, Windows NTFS) the second mkdir
// resolves to the same directory as the first and the dedup branch
// is unreachable from this test setup — see testCaseSensitiveFS above.
await makeFamily(bundledRoot, 'Inter', [400]);
await makeFamily(bundledRoot, 'INTER', [700]);
const fonts = await fontsService.listFonts();
expect(fonts).toHaveLength(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Duplicate family')
);
});
});
describe('meta.json — generic fallback', () => {
test('valid generic="serif"', async () => {
await makeFamily(bundledRoot, 'Playfair-Display', [400], { generic: 'serif' });
const [pd] = await fontsService.listFonts();
expect(pd.generic).toBe('serif');
});
test('valid generic="cursive"', async () => {
await makeFamily(bundledRoot, 'Comic-Neue', [400], { generic: 'cursive' });
const [cn] = await fontsService.listFonts();
expect(cn.generic).toBe('cursive');
});
test('valid generic="monospace"', async () => {
await makeFamily(bundledRoot, 'Fira-Mono', [400], { generic: 'monospace' });
const [fm] = await fontsService.listFonts();
expect(fm.generic).toBe('monospace');
});
test('missing meta.json → defaults to sans-serif (no warning)', async () => {
await makeFamily(bundledRoot, 'Inter', [400]);
const [inter] = await fontsService.listFonts();
expect(inter.generic).toBe('sans-serif');
// No warning for the missing-file case (it's the normal path).
const noisy = (logger.warn.mock.calls || []).filter((c) =>
String(c[0]).includes('meta.json')
);
expect(noisy).toEqual([]);
});
test('invalid generic value → defaults to sans-serif, warning logged', async () => {
await makeFamily(bundledRoot, 'Inter', [400], { generic: 'bogus' });
const [inter] = await fontsService.listFonts();
expect(inter.generic).toBe('sans-serif');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('invalid generic "bogus"')
);
});
test('malformed JSON → defaults to sans-serif, warning logged', async () => {
await makeFamily(bundledRoot, 'Inter', [400], '{ this is not json');
const [inter] = await fontsService.listFonts();
expect(inter.generic).toBe('sans-serif');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('not valid JSON')
);
});
});
describe('result shape', () => {
test('every family is { family, weights, generic }', async () => {
await makeFamily(bundledRoot, 'Inter', [400, 700]);
await makeFamily(bundledRoot, 'Playfair-Display', [400], { generic: 'serif' });
const fonts = await fontsService.listFonts();
for (const f of fonts) {
expect(f).toEqual({
family: expect.any(String),
weights: expect.any(Array),
generic: expect.stringMatching(/^(sans-serif|serif|cursive|monospace)$/)
});
expect(f.weights.length).toBeGreaterThan(0);
}
});
test('output sorted alphabetically by family', async () => {
await makeFamily(bundledRoot, 'Zilla-Slab', [400]);
await makeFamily(bundledRoot, 'Alpha-Sans', [400]);
await makeFamily(bundledRoot, 'Mid-Pack', [400]);
const fonts = await fontsService.listFonts();
expect(fonts.map((f) => f.family)).toEqual([
'Alpha Sans',
'Mid Pack',
'Zilla Slab'
]);
});
});
describe('cache', () => {
test('cache hit: second call within TTL does not re-readdir', async () => {
await makeFamily(bundledRoot, 'Inter', [400]);
const spy = jest.spyOn(fsPromises, 'readdir');
await fontsService.listFonts();
const callsAfterFirst = spy.mock.calls.length;
await fontsService.listFonts();
expect(spy.mock.calls.length).toBe(callsAfterFirst);
spy.mockRestore();
});
test('clearFontsCache forces a fresh scan on the next call', async () => {
await makeFamily(bundledRoot, 'Inter', [400]);
await fontsService.listFonts();
// Add a new family AFTER the cache was populated.
await makeFamily(bundledRoot, 'Roboto', [400]);
// Without clearing, listFonts returns the stale cache.
const stale = await fontsService.listFonts();
expect(stale.map((f) => f.family)).toEqual(['Inter']);
// After clear, the new family appears.
fontsService.clearFontsCache();
const fresh = await fontsService.listFonts();
expect(fresh.map((f) => f.family)).toEqual(['Inter', 'Roboto']);
});
});
});
@@ -0,0 +1,216 @@
/**
* Unit tests for photoProcessor.processPhoto — the worker-mode entry
* point that runs after a row has been claimed by the background
* processor. Mocks every external dependency and validates the
* happy-path DB updates and side-effect ordering.
*
* jest.mock factories are evaluated before any local variables exist,
* so collaborators are kept inside the mock factories themselves and
* the test reaches into them via require() once they're set up.
*/
const path = require('path');
jest.mock('../../src/database/db', () => {
const recorded = { whereCalls: [], updateCalls: [] };
let pendingWhere = null;
const photosState = { row: null };
const eventsState = { row: null };
function makePhotoQuery() {
return {
where(args) {
pendingWhere = args;
recorded.whereCalls.push(args);
return this;
},
async first() {
return photosState.row;
},
async update(data) {
recorded.updateCalls.push({ where: pendingWhere, data });
return 1;
},
};
}
function makeEventsQuery() {
return {
where() {
return this;
},
async first() {
return eventsState.row;
},
};
}
function dbFn(table) {
if (table === 'photos') return makePhotoQuery();
if (table === 'events') return makeEventsQuery();
throw new Error(`Unexpected table: ${table}`);
}
dbFn.client = { config: { client: 'pg' } };
return {
db: dbFn,
__setPhoto: (row) => { photosState.row = row; },
__setEvent: (row) => { eventsState.row = row; },
__reset: () => {
recorded.whereCalls = [];
recorded.updateCalls = [];
photosState.row = null;
eventsState.row = null;
},
__recorded: () => recorded,
};
});
jest.mock('../../src/services/imageProcessor', () => {
const mockGenerateThumbnail = jest.fn();
const mockExtractCaptureDate = jest.fn();
return {
generateThumbnail: mockGenerateThumbnail,
extractCaptureDate: mockExtractCaptureDate,
withLocalCopy: jest.fn(async (key, fn) =>
fn(`/tmp/local-copy-${require('path').basename(key)}`)
),
};
});
jest.mock('../../src/services/videoProcessor', () => ({
processUploadedVideo: jest.fn(),
isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'),
}));
jest.mock('../../src/services/storage', () => ({ getStorage: jest.fn() }));
jest.mock('../../src/services/photoResolver', () => ({
resolvePhotoStorageKey: jest.fn(
(event, photo) => `events/active/${event.slug}/${photo.filename}`
),
}));
jest.mock('../../src/utils/filenameSanitizer', () => ({
generatePhotoFilename: jest.fn(() => 'whatever.jpg'),
}));
jest.mock('../../src/services/watermarkGeneratorService', () => ({
generateForPhoto: jest.fn(() => Promise.resolve()),
}));
jest.mock('../../src/services/webhookService', () => ({
fire: jest.fn(() => Promise.resolve()),
}));
jest.mock('../../src/utils/logger', () => ({
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
// Stub sharp so we don't actually read any image off disk.
jest.mock('sharp', () => {
const mock = jest.fn(() => ({
metadata: jest.fn(async () => ({ width: 1920, height: 1080 })),
}));
return mock;
});
const dbModule = require('../../src/database/db');
const imageProcessor = require('../../src/services/imageProcessor');
const videoProcessor = require('../../src/services/videoProcessor');
const watermarkService = require('../../src/services/watermarkGeneratorService');
const webhookService = require('../../src/services/webhookService');
beforeEach(() => {
dbModule.__reset();
jest.clearAllMocks();
});
describe('photoProcessor.processPhoto', () => {
it('marks an image complete with thumbnail and dimensions', async () => {
dbModule.__setPhoto({
id: 101,
event_id: 5,
filename: 'wedding-001.jpg',
original_filename: 'IMG_0001.jpg',
mime_type: 'image/jpeg',
media_type: 'image',
size_bytes: 12345,
captured_at: null,
processing_status: 'processing',
});
dbModule.__setEvent({ id: 5, slug: 'wedding', event_name: 'Wedding' });
imageProcessor.extractCaptureDate.mockResolvedValueOnce('2026-04-25T12:00:00Z');
imageProcessor.generateThumbnail.mockResolvedValueOnce('thumbnails/thumb_wedding-001.jpg');
const { processPhoto } = require('../../src/services/photoProcessor');
await processPhoto(101);
const finalUpdate = dbModule.__recorded().updateCalls.pop();
expect(finalUpdate.data.processing_status).toBe('complete');
expect(finalUpdate.data.processing_error).toBeNull();
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-001.jpg');
expect(finalUpdate.data.width).toBe(1920);
expect(finalUpdate.data.height).toBe(1080);
expect(finalUpdate.data.captured_at).toBe('2026-04-25T12:00:00Z');
expect(watermarkService.generateForPhoto).toHaveBeenCalledWith(101);
expect(webhookService.fire).toHaveBeenCalledWith(
'photo.uploaded',
expect.objectContaining({
event: expect.objectContaining({ slug: 'wedding' }),
photo: expect.objectContaining({ id: 101, filename: 'wedding-001.jpg' }),
})
);
});
it('handles videos with ffmpeg metadata path', async () => {
dbModule.__setPhoto({
id: 202,
event_id: 9,
filename: 'wedding-video-001.mp4',
original_filename: 'movie.mp4',
mime_type: 'video/mp4',
media_type: 'video',
size_bytes: 99999,
captured_at: null,
});
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
videoProcessor.processUploadedVideo.mockResolvedValueOnce({
thumbnailKey: 'thumbnails/thumb_wedding-video-001.jpg',
metadata: {
duration: 12.5,
videoCodec: 'h264',
audioCodec: 'aac',
width: 1280,
height: 720,
},
});
const { processPhoto } = require('../../src/services/photoProcessor');
await processPhoto(202);
const finalUpdate = dbModule.__recorded().updateCalls.pop();
expect(finalUpdate.data.processing_status).toBe('complete');
expect(finalUpdate.data.duration).toBe(12.5);
expect(finalUpdate.data.video_codec).toBe('h264');
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-video-001.jpg');
// Watermark queue is image-only.
expect(watermarkService.generateForPhoto).not.toHaveBeenCalled();
});
it('throws when the photo row no longer exists', async () => {
dbModule.__setPhoto(null);
dbModule.__setEvent({ id: 1 });
const { processPhoto } = require('../../src/services/photoProcessor');
await expect(processPhoto(999)).rejects.toThrow(/Photo 999 not found/);
});
});
void path; // referenced indirectly via mocks
@@ -0,0 +1,66 @@
/**
* Unit tests for formatters.js — focused on the HTML-escape behaviour added
* so admin-supplied welcome messages can't inject markup into customer mail.
*/
const { escapeHtml, nl2br, formatWelcomeMessage } = require('../../src/utils/formatters');
describe('escapeHtml', () => {
it('escapes the five HTML metacharacters', () => {
expect(escapeHtml('& < > " \'')).toBe('&amp; &lt; &gt; &quot; &#39;');
});
it('returns empty string for null/undefined', () => {
expect(escapeHtml(null)).toBe('');
expect(escapeHtml(undefined)).toBe('');
});
it('coerces non-string values', () => {
expect(escapeHtml(42)).toBe('42');
});
it('escapes & before introducing new entities', () => {
expect(escapeHtml('<&>')).toBe('&lt;&amp;&gt;');
});
});
describe('nl2br', () => {
it('joins non-empty lines with <br />', () => {
expect(nl2br('a\nb\nc')).toBe('a<br />b<br />c');
});
it('normalises CRLF and CR', () => {
expect(nl2br('a\r\nb\rc')).toBe('a<br />b<br />c');
});
it('drops empty lines', () => {
expect(nl2br('a\n\n\nb')).toBe('a<br />b');
});
it('returns empty for empty input', () => {
expect(nl2br('')).toBe('');
expect(nl2br(null)).toBe('');
});
});
describe('formatWelcomeMessage', () => {
it('returns empty string for empty input', () => {
expect(formatWelcomeMessage('')).toBe('');
expect(formatWelcomeMessage(' ')).toBe('');
});
it('escapes HTML metacharacters before nl2br', () => {
expect(formatWelcomeMessage('Hello <b>world</b>'))
.toBe('Hello &lt;b&gt;world&lt;/b&gt;');
});
it('renders newlines as <br /> while keeping content escaped', () => {
expect(formatWelcomeMessage('line 1\n<script>x</script>\nline 3'))
.toBe('line 1<br />&lt;script&gt;x&lt;/script&gt;<br />line 3');
});
it('escapes ampersands and quotes that would otherwise break HTML', () => {
expect(formatWelcomeMessage('Tom & Jerry\'s "show"'))
.toBe('Tom &amp; Jerry&#39;s &quot;show&quot;');
});
});
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
{
"generic": "cursive"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+114
View File
@@ -0,0 +1,114 @@
This directory bundles the following typefaces, each licensed under the
SIL Open Font License v1.1.
------------------------------------------------------------
Per-font copyright notices
------------------------------------------------------------
Inter
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
Noto Sans
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
Poppins
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
Jost
Copyright 2020 The Jost Project Authors (https://github.com/indestructible-type/Jost)
Montserrat
Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat)
Playfair Display
Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair)
IBM Plex Sans
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
Comic Neue
Copyright (c) 2014 by Craig Rozynski. All rights reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
{
"generic": "serif"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 074_add_photo_cap');
await addColumnIfNotExists(knex, 'events', 'photo_cap', (table) => {
table.integer('photo_cap').nullable().defaultTo(null);
});
console.log('Migration 074_add_photo_cap completed');
};
exports.down = async function(knex) {
console.log('Rollback: 074_add_photo_cap');
};
@@ -0,0 +1,28 @@
const { addColumnIfNotExists, createIndexIfNotExists } = require('../helpers');
exports.up = async function(knex) {
// Add visibility column to photos table
await addColumnIfNotExists(knex, 'photos', 'visibility', (table) => {
table.string('visibility', 20).defaultTo('visible').notNullable();
});
// Add client access columns to events table
await addColumnIfNotExists(knex, 'events', 'client_access_enabled', (table) => {
table.boolean('client_access_enabled').defaultTo(false);
});
await addColumnIfNotExists(knex, 'events', 'client_password_hash', (table) => {
table.string('client_password_hash', 255).nullable();
});
await addColumnIfNotExists(knex, 'events', 'client_share_token', (table) => {
table.string('client_share_token', 64).nullable().unique();
});
// Index for filtering photos by visibility
await createIndexIfNotExists(knex, 'photos', ['event_id', 'visibility'], 'idx_photos_event_visibility');
};
exports.down = async function(knex) {
// Safe rollback - intentionally no-op to avoid data loss
};
@@ -0,0 +1,281 @@
/**
* Migration to create email_template_translations table
* Moves from per-column language support (subject_en, subject_de) to a
* normalized translations table where each language is a row.
* This allows adding new languages without schema changes.
*/
exports.up = async function(knex) {
// 1. Create the email_template_translations table
await knex.schema.createTable('email_template_translations', (table) => {
table.increments('id').primary();
table.integer('template_id').unsigned().notNullable()
.references('id').inTable('email_templates').onDelete('CASCADE');
table.string('language', 10).notNullable();
table.text('subject');
table.text('body_html');
table.text('body_text');
table.datetime('created_at').defaultTo(knex.fn.now());
table.datetime('updated_at').defaultTo(knex.fn.now());
table.unique(['template_id', 'language']);
});
console.log('Created email_template_translations table');
// 2. Migrate existing data from email_templates columns into rows
const templates = await knex('email_templates').select('*');
const columnInfo = await knex('email_templates').columnInfo();
const hasLangColumns = !!columnInfo.subject_en;
for (const template of templates) {
// Extract EN translation
const enSubject = hasLangColumns
? (template.subject_en || template.subject || '')
: (template.subject || '');
const enHtml = hasLangColumns
? (template.body_html_en || template.body_html || '')
: (template.body_html || '');
const enText = hasLangColumns
? (template.body_text_en || template.body_text || '')
: (template.body_text || '');
// Insert EN translation
if (enSubject || enHtml) {
await knex('email_template_translations').insert({
template_id: template.id,
language: 'en',
subject: enSubject,
body_html: enHtml,
body_text: enText,
created_at: new Date(),
updated_at: new Date(),
});
}
// Extract DE translation (only if lang columns exist)
if (hasLangColumns) {
const deSubject = template.subject_de || '';
const deHtml = template.body_html_de || '';
const deText = template.body_text_de || '';
// Only insert if DE content differs from EN or has content
if (deSubject || deHtml) {
await knex('email_template_translations').insert({
template_id: template.id,
language: 'de',
subject: deSubject,
body_html: deHtml,
body_text: deText,
created_at: new Date(),
updated_at: new Date(),
});
}
}
}
console.log(`Migrated ${templates.length} templates to translations table`);
// 3. Seed NL, PT, RU translations for customer-facing templates
// Look up template IDs
const customerTemplates = await knex('email_templates')
.whereIn('template_key', [
'gallery_created', 'expiration_warning', 'gallery_expired', 'archive_complete'
])
.select('id', 'template_key');
const templateMap = {};
customerTemplates.forEach(t => { templateMap[t.template_key] = t.id; });
const seedTranslations = [];
// --- gallery_created ---
if (templateMap.gallery_created) {
const id = templateMap.gallery_created;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Uw fotogalerij is klaar!',
body_html: `<h2>Galerij succesvol aangemaakt</h2>
<p>Beste {{host_name}},</p>
<p>Uw fotogalerij "{{event_name}}" is succesvol aangemaakt!</p>
<p><strong>Galerij details:</strong></p>
<ul>
<li>Evenementdatum: {{event_date}}</li>
<li>Galerij link: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Wachtwoord: {{gallery_password}}</li>
<li>Verloopt op: {{expiry_date}}</li>
</ul>
<p>Deel deze link en het wachtwoord met uw gasten zodat zij de foto's kunnen bekijken en downloaden.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Galerij succesvol aangemaakt\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is succesvol aangemaakt!\n\nGalerij link: {{gallery_link}}\nWachtwoord: {{gallery_password}}\nVerloopt op: {{expiry_date}}`,
},
{
template_id: id, language: 'pt',
subject: 'Sua galeria de fotos está pronta!',
body_html: `<h2>Galeria criada com sucesso</h2>
<p>Prezado(a) {{host_name}},</p>
<p>Sua galeria de fotos "{{event_name}}" foi criada com sucesso!</p>
<p><strong>Detalhes da galeria:</strong></p>
<ul>
<li>Data do evento: {{event_date}}</li>
<li>Link da galeria: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Senha: {{gallery_password}}</li>
<li>Expira em: {{expiry_date}}</li>
</ul>
<p>Compartilhe este link e senha com seus convidados para que possam visualizar e baixar as fotos.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Galeria criada com sucesso\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" foi criada com sucesso!\n\nLink da galeria: {{gallery_link}}\nSenha: {{gallery_password}}\nExpira em: {{expiry_date}}`,
},
{
template_id: id, language: 'ru',
subject: 'Ваша фотогалерея готова!',
body_html: `<h2>Галерея успешно создана</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Ваша фотогалерея "{{event_name}}" была успешно создана!</p>
<p><strong>Детали галереи:</strong></p>
<ul>
<li>Дата события: {{event_date}}</li>
<li>Ссылка на галерею: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Пароль: {{gallery_password}}</li>
<li>Срок действия: {{expiry_date}}</li>
</ul>
<p>Поделитесь этой ссылкой и паролем с вашими гостями, чтобы они могли просматривать и скачивать фотографии.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Галерея успешно создана\n\nУважаемый(ая) {{host_name}},\n\nВаша фотогалерея "{{event_name}}" была успешно создана!\n\nСсылка: {{gallery_link}}\nПароль: {{gallery_password}}\nСрок действия: {{expiry_date}}`,
},
);
}
// --- expiration_warning ---
if (templateMap.expiration_warning) {
const id = templateMap.expiration_warning;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Uw fotogalerij verloopt binnenkort',
body_html: `<h2>Galerij verloopt binnenkort</h2>
<p>Beste {{host_name}},</p>
<p>Uw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.</p>
<p>Na het verlopen wordt de galerij gearchiveerd en is niet meer toegankelijk voor gasten.</p>
<p><a href="{{gallery_link}}">Galerij bezoeken</a></p>`,
body_text: `Galerij verloopt binnenkort\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.\n\nGalerij: {{gallery_link}}`,
},
{
template_id: id, language: 'pt',
subject: 'Sua galeria de fotos expira em breve',
body_html: `<h2>Galeria expirando em breve</h2>
<p>Prezado(a) {{host_name}},</p>
<p>Sua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.</p>
<p>Após a expiração, a galeria será arquivada e não estará mais acessível aos convidados.</p>
<p><a href="{{gallery_link}}">Visitar galeria</a></p>`,
body_text: `Galeria expirando em breve\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.\n\nGaleria: {{gallery_link}}`,
},
{
template_id: id, language: 'ru',
subject: 'Срок действия вашей фотогалереи скоро истекает',
body_html: `<h2>Срок действия галереи истекает</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Срок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.</p>
<p>После истечения срока галерея будет архивирована и станет недоступна для гостей.</p>
<p><a href="{{gallery_link}}">Перейти в галерею</a></p>`,
body_text: `Срок действия галереи истекает\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.\n\nГалерея: {{gallery_link}}`,
},
);
}
// --- gallery_expired ---
if (templateMap.gallery_expired) {
const id = templateMap.gallery_expired;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Uw fotogalerij {{event_name}} is verlopen',
body_html: `<h2>Galerij verlopen</h2>
<p>Beste {{host_name}},</p>
<p>Uw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.</p>
<p>De foto's zijn gearchiveerd. Als u toegang nodig heeft, neem dan contact op met de beheerder via {{admin_email}}.</p>`,
body_text: `Galerij verlopen\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.\n\nNeem contact op met: {{admin_email}}`,
},
{
template_id: id, language: 'pt',
subject: 'Sua galeria de fotos {{event_name}} expirou',
body_html: `<h2>Galeria expirada</h2>
<p>Prezado(a) {{host_name}},</p>
<p>Sua galeria de fotos "{{event_name}}" expirou e não está mais acessível.</p>
<p>As fotos foram arquivadas. Se precisar de acesso, entre em contato com o administrador em {{admin_email}}.</p>`,
body_text: `Galeria expirada\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirou e não está mais acessível.\n\nContato: {{admin_email}}`,
},
{
template_id: id, language: 'ru',
subject: 'Срок действия фотогалереи {{event_name}} истёк',
body_html: `<h2>Срок действия галереи истёк</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Срок действия вашей фотогалереи "{{event_name}}" истёк, и она больше недоступна.</p>
<p>Фотографии были архивированы. Если вам нужен доступ, свяжитесь с администратором: {{admin_email}}.</p>`,
body_text: `Срок действия галереи истёк\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истёк.\n\nКонтакт: {{admin_email}}`,
},
);
}
// --- archive_complete ---
if (templateMap.archive_complete) {
const id = templateMap.archive_complete;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Archivering voltooid: {{event_name}}',
body_html: `<h2>Archivering voltooid</h2>
<p>Beste {{host_name}},</p>
<p>De fotogalerij "{{event_name}}" is succesvol gearchiveerd.</p>
<p><strong>Archief details:</strong></p>
<ul>
<li>Aantal foto's: {{photo_count}}</li>
<li>Archiefgrootte: {{archive_size}}</li>
<li>Archiefdatum: {{archive_date}}</li>
</ul>`,
body_text: `Archivering voltooid\n\nBeste {{host_name}},\n\nDe fotogalerij "{{event_name}}" is succesvol gearchiveerd.\n\nAantal foto's: {{photo_count}}\nGrootte: {{archive_size}}`,
},
{
template_id: id, language: 'pt',
subject: 'Arquivamento concluído: {{event_name}}',
body_html: `<h2>Arquivamento concluído</h2>
<p>Prezado(a) {{host_name}},</p>
<p>A galeria de fotos "{{event_name}}" foi arquivada com sucesso.</p>
<p><strong>Detalhes do arquivo:</strong></p>
<ul>
<li>Número de fotos: {{photo_count}}</li>
<li>Tamanho do arquivo: {{archive_size}}</li>
<li>Data do arquivamento: {{archive_date}}</li>
</ul>`,
body_text: `Arquivamento concluído\n\nPrezado(a) {{host_name}},\n\nA galeria de fotos "{{event_name}}" foi arquivada com sucesso.\n\nFotos: {{photo_count}}\nTamanho: {{archive_size}}`,
},
{
template_id: id, language: 'ru',
subject: 'Архивация завершена: {{event_name}}',
body_html: `<h2>Архивация завершена</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Фотогалерея "{{event_name}}" была успешно архивирована.</p>
<p><strong>Детали архива:</strong></p>
<ul>
<li>Количество фото: {{photo_count}}</li>
<li>Размер архива: {{archive_size}}</li>
<li>Дата архивации: {{archive_date}}</li>
</ul>`,
body_text: `Архивация завершена\n\nУважаемый(ая) {{host_name}},\n\nФотогалерея "{{event_name}}" была успешно архивирована.\n\nФото: {{photo_count}}\nРазмер: {{archive_size}}`,
},
);
}
// Insert all seed translations
const now = new Date();
for (const trans of seedTranslations) {
trans.created_at = now;
trans.updated_at = now;
await knex('email_template_translations').insert(trans);
}
console.log(`Seeded ${seedTranslations.length} translations for customer-facing templates`);
};
exports.down = async function(knex) {
await knex.schema.dropTableIfExists('email_template_translations');
};
@@ -0,0 +1,21 @@
/**
* Migration to add is_draft column to events table.
* Draft events are not visible to gallery visitors until published.
*/
exports.up = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'is_draft');
if (!hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.boolean('is_draft').defaultTo(false);
});
}
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'is_draft');
if (hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('is_draft');
});
}
};
@@ -0,0 +1,17 @@
exports.up = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'default_photo_sort');
if (!hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.string('default_photo_sort', 50).defaultTo('upload_date_desc');
});
}
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'default_photo_sort');
if (hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('default_photo_sort');
});
}
};
@@ -0,0 +1,120 @@
/**
* Add guest identity layer for per-person photo selections (issue #292).
*
* Adds:
* - gallery_guests — persistent guest profiles per event
* - guest_invites — pre-minted invite tokens (Phase 3.3)
* - guest_verification_codes — email-based identity recovery (Phase 3.2)
* - event_feedback_settings.identity_mode ('simple' | 'guest', default 'simple')
* - photo_feedback.guest_id FK — links feedback to gallery_guests (nullable)
*
* All changes are additive. Existing events default to 'simple' mode so behavior
* is unchanged. Legacy photo_feedback rows keep NULL guest_id.
*/
exports.up = async function(knex) {
// 1. gallery_guests — persistent per-person identity within an event.
const hasGalleryGuests = await knex.schema.hasTable('gallery_guests');
if (!hasGalleryGuests) {
await knex.schema.createTable('gallery_guests', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
table.string('name', 100).notNullable();
table.string('email', 255);
table.string('identifier', 64).notNullable(); // UUIDv4 issued server-side
table.string('ip_address_last', 45);
table.text('user_agent_last');
table.timestamp('email_verified_at');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('last_seen_at').defaultTo(knex.fn.now());
table.boolean('is_deleted').defaultTo(false);
table.unique(['event_id', 'identifier']);
table.index(['event_id']);
table.index(['event_id', 'email']);
});
}
// 2. guest_invites — pre-minted one-time-use tokens for invited guests.
const hasGuestInvites = await knex.schema.hasTable('guest_invites');
if (!hasGuestInvites) {
await knex.schema.createTable('guest_invites', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
table.integer('guest_id').notNullable().references('id').inTable('gallery_guests').onDelete('CASCADE');
table.string('token', 64).notNullable().unique();
table.integer('created_by_admin_id').references('id').inTable('admin_users');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('redeemed_at');
table.timestamp('revoked_at');
table.index(['event_id']);
table.index(['guest_id']);
});
}
// 3. guest_verification_codes — short-lived codes for email-based recovery.
const hasGuestVerificationCodes = await knex.schema.hasTable('guest_verification_codes');
if (!hasGuestVerificationCodes) {
await knex.schema.createTable('guest_verification_codes', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
table.string('email', 255).notNullable();
table.string('code_hash', 128).notNullable(); // bcrypt hash of 6-digit code
table.integer('attempts').defaultTo(0);
table.timestamp('expires_at').notNullable();
table.timestamp('consumed_at');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index(['event_id', 'email']);
table.index(['expires_at']);
});
}
// 4. event_feedback_settings.identity_mode
const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode');
if (!hasIdentityMode) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.string('identity_mode', 16).notNullable().defaultTo('simple');
});
if (knex.client.config.client === 'pg') {
await knex.raw(`
ALTER TABLE event_feedback_settings
ADD CONSTRAINT event_feedback_settings_identity_mode_check
CHECK (identity_mode IN ('simple','guest'))
`);
}
}
// 5. photo_feedback.guest_id FK
const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id');
if (!hasGuestIdColumn) {
await knex.schema.alterTable('photo_feedback', (table) => {
table.integer('guest_id').references('id').inTable('gallery_guests').onDelete('SET NULL');
table.index(['guest_id']);
});
}
};
exports.down = async function(knex) {
const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id');
if (hasGuestIdColumn) {
await knex.schema.alterTable('photo_feedback', (table) => {
table.dropColumn('guest_id');
});
}
if (knex.client.config.client === 'pg') {
await knex.raw('ALTER TABLE event_feedback_settings DROP CONSTRAINT IF EXISTS event_feedback_settings_identity_mode_check');
}
const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode');
if (hasIdentityMode) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.dropColumn('identity_mode');
});
}
await knex.schema.dropTableIfExists('guest_verification_codes');
await knex.schema.dropTableIfExists('guest_invites');
await knex.schema.dropTableIfExists('gallery_guests');
};
@@ -0,0 +1,26 @@
/**
* Add download ZIP cache columns to events table.
*
* Enables pre-generated ZIP files for "Download All" so guests get
* instant downloads with Content-Length instead of on-the-fly streaming.
*/
exports.up = async function(knex) {
const hasZipPath = await knex.schema.hasColumn('events', 'download_zip_path');
if (!hasZipPath) {
await knex.schema.alterTable('events', (table) => {
table.text('download_zip_path').nullable().defaultTo(null);
table.datetime('download_zip_generated_at').nullable().defaultTo(null);
});
}
};
exports.down = async function(knex) {
const hasZipPath = await knex.schema.hasColumn('events', 'download_zip_path');
if (hasZipPath) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('download_zip_path');
table.dropColumn('download_zip_generated_at');
});
}
};
@@ -0,0 +1,33 @@
const { addColumnIfNotExists } = require('../helpers');
/**
* #322 — optional phone-number field on events. Off by default; surfaced
* only when the global `event_phone_field_enabled` app setting is true,
* so existing deployments see no UI change unless the admin opts in.
*/
exports.up = async function up(knex) {
await addColumnIfNotExists(knex, 'events', 'customer_phone', (table) => {
table.string('customer_phone', 32).nullable();
});
// Seed the global enable flag (default false).
const exists = await knex('app_settings')
.where('setting_key', 'event_phone_field_enabled')
.first();
if (!exists) {
await knex('app_settings').insert({
setting_key: 'event_phone_field_enabled',
setting_value: JSON.stringify(false),
setting_type: 'boolean'
});
}
};
exports.down = async function down(knex) {
if (await knex.schema.hasColumn('events', 'customer_phone')) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('customer_phone');
});
}
await knex('app_settings').where('setting_key', 'event_phone_field_enabled').delete();
};
@@ -0,0 +1,41 @@
/**
* #322 — long-lived API tokens for programmatic access (n8n, custom
* integrations, external apps). Each token belongs to an admin user; the
* token's effective permissions are the *intersection* of the user's
* role permissions and the token's own scope flags. That way revoking
* the user revokes the token, and scope flags let an admin issue a
* read-only token even if their account is super_admin.
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('api_tokens'))) {
await knex.schema.createTable('api_tokens', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable();
// SHA-256 of the full token string (`pp_live_<random>`). Lookup
// hashes the incoming Authorization header and queries by this.
table.string('hashed_token', 64).notNullable().unique();
// Scope flags — comma-separated subset of: read, write, admin.
// 'read' allows GETs; 'write' adds POST/PATCH/DELETE on
// event/photo data; 'admin' allows creating/deleting events and
// anything else gated by admin.* permissions.
table.string('scopes', 64).notNullable().defaultTo('read');
table.integer('created_by').notNullable()
.references('id').inTable('admin_users').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('expires_at').nullable();
table.timestamp('last_used_at').nullable();
table.timestamp('revoked_at').nullable();
// Cosmetic for the admin UI: first 8 chars of the plaintext
// token (after the prefix) so admins can identify which token is
// which without seeing the secret half.
table.string('preview', 16).nullable();
});
}
};
exports.down = async function down(knex) {
if (await knex.schema.hasTable('api_tokens')) {
await knex.schema.dropTable('api_tokens');
}
};
@@ -0,0 +1,80 @@
/**
* #327 — outbound webhooks (push API) for the event/photo lifecycle.
*
* Two tables:
* webhooks — admin-managed subscriptions (URL + events + secret)
* webhook_deliveries — single source of truth for the delivery worker
* (audit log + retry queue in one).
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('webhooks'))) {
await knex.schema.createTable('webhooks', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable();
// Validated via networkValidation.validateExternalUrl on create + per
// delivery (DNS-rebinding mitigation).
table.string('url', 2048).notNullable();
// Plaintext signing secret (`whsec_<random>`). Stored unencrypted
// because we need to recompute HMAC-SHA256 over every outbound body
// — a hash would make the secret unrecoverable. Same posture as
// SMTP passwords stored in app_settings; protect the DB. The
// plaintext is also returned to the admin once on create so they can
// configure the receiver to verify signatures.
table.string('secret', 100).notNullable();
// First 8 chars of the secret for the admin UI so operators can
// tell which webhook is which without revealing the full secret.
table.string('secret_preview', 16).nullable();
// JSON array of subscribed event types
// (e.g. ["event.published","photo.uploaded"]).
table.jsonb('events').notNullable().defaultTo('[]');
table.boolean('active').notNullable().defaultTo(true);
table.integer('created_by').notNullable()
.references('id').inTable('admin_users').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.timestamp('last_success_at').nullable();
table.timestamp('last_failure_at').nullable();
// Index for the delivery worker's "find subscriptions for this event"
// query — small set, but keeps the lookup constant-time as it grows.
table.index('active', 'webhooks_active_idx');
});
}
if (!(await knex.schema.hasTable('webhook_deliveries'))) {
await knex.schema.createTable('webhook_deliveries', (table) => {
table.increments('id').primary();
table.integer('webhook_id').notNullable()
.references('id').inTable('webhooks').onDelete('CASCADE');
table.string('event_type', 64).notNullable();
// Full signed payload (the JSON body that was POSTed).
table.jsonb('payload').notNullable();
table.integer('attempt_count').notNullable().defaultTo(0);
// pending → success | failed. pending rows with next_retry_at <= NOW()
// are picked up by the worker.
table.string('status', 16).notNullable().defaultTo('pending');
table.integer('response_status').nullable();
// Truncated to 1KB before storage so a verbose receiver can't blow
// up the row size.
table.text('response_body').nullable();
table.text('last_error').nullable();
table.integer('latency_ms').nullable();
table.timestamp('next_retry_at').nullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('completed_at').nullable();
// Worker hot-path query: WHERE status='pending' AND next_retry_at <= NOW()
// ORDER BY next_retry_at LIMIT N. This composite index serves it directly.
table.index(['status', 'next_retry_at'], 'webhook_deliveries_status_retry_idx');
table.index('webhook_id', 'webhook_deliveries_webhook_idx');
});
}
};
exports.down = async function down(knex) {
if (await knex.schema.hasTable('webhook_deliveries')) {
await knex.schema.dropTable('webhook_deliveries');
}
if (await knex.schema.hasTable('webhooks')) {
await knex.schema.dropTable('webhooks');
}
};
@@ -0,0 +1,49 @@
/**
* Adds:
* - events.allow_presigned_download — per-event opt-in for the
* presigned-URL "Download All" path (#328 follow-up). Off by default
* because it bypasses watermarks; admins flip it knowingly.
* - webhooks.filter — JSONB predicate evaluated against the payload at
* fire time (#327 follow-up). Empty object = no filter, fire always.
* - webhooks.template — optional ${dot.path} string template applied
* to the request body before signing. NULL = use the default JSON
* envelope (back-compat).
*/
exports.up = async function up(knex) {
if (await knex.schema.hasTable('events')) {
const hasCol = await knex.schema.hasColumn('events', 'allow_presigned_download');
if (!hasCol) {
await knex.schema.alterTable('events', (table) => {
table.boolean('allow_presigned_download').notNullable().defaultTo(false);
});
}
}
if (await knex.schema.hasTable('webhooks')) {
const hasFilter = await knex.schema.hasColumn('webhooks', 'filter');
if (!hasFilter) {
await knex.schema.alterTable('webhooks', (table) => {
table.jsonb('filter').notNullable().defaultTo('{}');
});
}
const hasTemplate = await knex.schema.hasColumn('webhooks', 'template');
if (!hasTemplate) {
await knex.schema.alterTable('webhooks', (table) => {
table.text('template').nullable();
});
}
}
};
exports.down = async function down(knex) {
if (await knex.schema.hasColumn('webhooks', 'template')) {
await knex.schema.alterTable('webhooks', (t) => t.dropColumn('template'));
}
if (await knex.schema.hasColumn('webhooks', 'filter')) {
await knex.schema.alterTable('webhooks', (t) => t.dropColumn('filter'));
}
if (await knex.schema.hasColumn('events', 'allow_presigned_download')) {
await knex.schema.alterTable('events', (t) => t.dropColumn('allow_presigned_download'));
}
};
@@ -0,0 +1,21 @@
/**
* Heal events whose hero_logo_position contains a branding-style value
* (left/right) caused by a prior bug in adminEvents.js getBrandingDefaults
* that mapped branding_logo_position (left/center/right) onto
* hero_logo_position (top/center/bottom). Any non-canonical value is
* reset to 'top' so subsequent PUTs no longer fail validation.
*/
exports.up = async function up(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
if (!hasColumn) return;
await knex('events')
.whereNotIn('hero_logo_position', ['top', 'center', 'bottom'])
.update({ hero_logo_position: 'top' });
};
exports.down = async function down() {
// Data correction is not reversible — the original (incorrect) values
// are not preserved.
};
@@ -0,0 +1,72 @@
/**
* Async photo-processing infrastructure.
*
* Adds:
* - photos.processing_status — enum: pending | processing | complete | failed
* - photos.processing_error — text, populated on 'failed'
* - photos.processing_started_at — claim timestamp for janitor recovery
* - photos.upload_id — groups all photos from one upload request
* so the frontend can poll/stream by group
*
* All existing rows default to 'complete' (they were processed synchronously
* before this migration and there's nothing pending). New uploads insert
* with 'pending' and a background worker (services/backgroundProcessor.js)
* picks them up.
*
* Partial-style indexes keep lookups fast as the queue drains. We use plain
* indexes here instead of postgres-specific WHERE clauses so the migration
* works on SQLite too; the workload (only-pending rows) keeps the index small.
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('photos'))) return;
const hasStatus = await knex.schema.hasColumn('photos', 'processing_status');
if (!hasStatus) {
await knex.schema.alterTable('photos', (table) => {
table.string('processing_status', 16).notNullable().defaultTo('complete');
table.text('processing_error').nullable();
table.timestamp('processing_started_at').nullable();
table.string('upload_id', 64).nullable();
});
}
// Indexes — wrap in try/catch so re-running the migration on a partially
// applied schema is a no-op rather than an error.
try {
await knex.schema.alterTable('photos', (table) => {
table.index(['processing_status'], 'idx_photos_processing_status');
});
} catch (_) { /* already exists */ }
try {
await knex.schema.alterTable('photos', (table) => {
table.index(['upload_id'], 'idx_photos_upload_id');
});
} catch (_) { /* already exists */ }
};
exports.down = async function down(knex) {
if (!(await knex.schema.hasTable('photos'))) return;
// Drop indexes first (best-effort)
try {
await knex.schema.alterTable('photos', (t) => t.dropIndex([], 'idx_photos_upload_id'));
} catch (_) { /* not present */ }
try {
await knex.schema.alterTable('photos', (t) => t.dropIndex([], 'idx_photos_processing_status'));
} catch (_) { /* not present */ }
if (await knex.schema.hasColumn('photos', 'upload_id')) {
await knex.schema.alterTable('photos', (t) => t.dropColumn('upload_id'));
}
if (await knex.schema.hasColumn('photos', 'processing_started_at')) {
await knex.schema.alterTable('photos', (t) => t.dropColumn('processing_started_at'));
}
if (await knex.schema.hasColumn('photos', 'processing_error')) {
await knex.schema.alterTable('photos', (t) => t.dropColumn('processing_error'));
}
if (await knex.schema.hasColumn('photos', 'processing_status')) {
await knex.schema.alterTable('photos', (t) => t.dropColumn('processing_status'));
}
};
@@ -0,0 +1,125 @@
/**
* Migration: Move existing non-grid + 'standard' events to the new 'banner'
* header style so their visual appearance is preserved.
*
* Until now, GalleryLayout.tsx coupled the colored hero banner to non-grid
* layouts whenever headerStyle was 'standard'. The 'standard' look has been
* decoupled from layout (it now means: compact inline header, no banner) and
* a new 'banner' option has been added that adds the colored banner above
* the standard header.
*
* In the same release, GalleryView.tsx stopped deriving controlsStyle from
* the layout — non-grid events used to default to the sidebar drawer via
* `theme.galleryLayout !== 'grid' || isHeroHeader`, and now require an
* explicit `theme.controlsStyle === 'sidebar'`. To keep affected events
* pixel-identical post-upgrade, this migration also sets controlsStyle to
* 'sidebar' when it was previously unset on every event we flip to banner.
*
* To keep current galleries looking the same, every event whose effective
* config was non-grid + standard gets migrated to non-grid + banner, and
* its controlsStyle is pinned to whatever the runtime would have used
* before the decoupling.
*/
const NON_GRID_LAYOUTS = ['masonry', 'carousel', 'timeline', 'mosaic'];
exports.up = async function(knex) {
console.log('[Migration 086] Migrating non-grid standard headers to banner');
const events = await knex('events')
.where('header_style', 'standard')
.whereNotNull('color_theme')
.select('id', 'color_theme');
let migratedCount = 0;
let controlsPinnedCount = 0;
for (const event of events) {
try {
if (!event.color_theme || !event.color_theme.startsWith('{')) {
continue;
}
const theme = JSON.parse(event.color_theme);
if (!NON_GRID_LAYOUTS.includes(theme.galleryLayout)) {
continue;
}
const updatedTheme = { ...theme, headerStyle: 'banner' };
// Preserve previous filter placement: before this release, non-grid
// layouts implicitly rendered the sidebar drawer when controlsStyle
// was unset. Pin it to 'sidebar' so the visual stays identical. Don't
// overwrite explicit values the user may have set deliberately.
if (!theme.controlsStyle) {
updatedTheme.controlsStyle = 'sidebar';
controlsPinnedCount++;
}
await knex('events')
.where('id', event.id)
.update({
color_theme: JSON.stringify(updatedTheme),
header_style: 'banner'
});
migratedCount++;
} catch (err) {
console.warn(`[Migration 086] Could not parse color_theme for event ${event.id}: ${err.message}`);
}
}
console.log(`[Migration 086] Migrated ${migratedCount} events from standard to banner`);
console.log(`[Migration 086] Pinned controlsStyle='sidebar' on ${controlsPinnedCount} events`);
};
exports.down = async function(knex) {
console.log('[Migration 086] Reverting non-grid banner headers to standard');
const events = await knex('events')
.where('header_style', 'banner')
.whereNotNull('color_theme')
.select('id', 'color_theme');
let revertedCount = 0;
for (const event of events) {
try {
if (!event.color_theme || !event.color_theme.startsWith('{')) {
continue;
}
const theme = JSON.parse(event.color_theme);
// Only revert rows we would have migrated (non-grid + banner). Leaves
// any banner events that were intentionally created on grid alone.
if (!NON_GRID_LAYOUTS.includes(theme.galleryLayout)) {
continue;
}
const revertedTheme = { ...theme, headerStyle: 'standard' };
// Symmetric with up: if controlsStyle is currently 'sidebar', drop it
// so the runtime falls back to whatever the older code would compute.
// Cannot perfectly distinguish "we set this" from "user agreed", but
// the scope is narrow (non-grid + banner) and rolling back is
// intentionally restoring pre-migration state.
if (revertedTheme.controlsStyle === 'sidebar') {
delete revertedTheme.controlsStyle;
}
await knex('events')
.where('id', event.id)
.update({
color_theme: JSON.stringify(revertedTheme),
header_style: 'standard'
});
revertedCount++;
} catch (err) {
console.warn(`[Migration 086] Could not revert color_theme for event ${event.id}: ${err.message}`);
}
}
console.log(`[Migration 086] Reverted ${revertedCount} events from banner to standard`);
};
+217 -143
View File
@@ -1,17 +1,16 @@
{
"name": "picpeak-backend",
"version": "2.6.3",
"version": "3.42.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "2.6.3",
"version": "3.42.1",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "1.14.0",
@@ -45,6 +44,8 @@
"sanitize-html": "^2.17.0",
"sharp": "0.34.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0",
"winston": "^3.8.2",
"zxcvbn": "^4.4.2"
@@ -57,6 +58,50 @@
"supertest": "^6.3.3"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
"license": "MIT",
"dependencies": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.6",
"call-me-maybe": "^1.0.1",
"js-yaml": "^4.1.0"
}
},
"node_modules/@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^9.0.6",
"@apidevtools/openapi-schemas": "^2.0.4",
"@apidevtools/swagger-methods": "^3.0.2",
"@jsdevtools/ono": "^7.1.3",
"call-me-maybe": "^1.0.1",
"z-schema": "^5.0.1"
},
"peerDependencies": {
"openapi-types": ">=7"
}
},
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
@@ -1561,132 +1606,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@ffmpeg-installer/darwin-arm64": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz",
"integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==",
"cpu": [
"arm64"
],
"hasInstallScript": true,
"license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ffmpeg-installer/darwin-x64": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz",
"integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==",
"cpu": [
"x64"
],
"hasInstallScript": true,
"license": "LGPL-2.1",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ffmpeg-installer/ffmpeg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz",
"integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==",
"license": "LGPL-2.1",
"optionalDependencies": {
"@ffmpeg-installer/darwin-arm64": "4.1.5",
"@ffmpeg-installer/darwin-x64": "4.1.0",
"@ffmpeg-installer/linux-arm": "4.1.3",
"@ffmpeg-installer/linux-arm64": "4.1.4",
"@ffmpeg-installer/linux-ia32": "4.1.0",
"@ffmpeg-installer/linux-x64": "4.1.0",
"@ffmpeg-installer/win32-ia32": "4.1.0",
"@ffmpeg-installer/win32-x64": "4.1.0"
}
},
"node_modules/@ffmpeg-installer/linux-arm": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz",
"integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==",
"cpu": [
"arm"
],
"hasInstallScript": true,
"license": "GPLv3",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ffmpeg-installer/linux-arm64": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz",
"integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==",
"cpu": [
"arm64"
],
"hasInstallScript": true,
"license": "GPLv3",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ffmpeg-installer/linux-ia32": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz",
"integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==",
"cpu": [
"ia32"
],
"hasInstallScript": true,
"license": "GPLv3",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ffmpeg-installer/linux-x64": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz",
"integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==",
"cpu": [
"x64"
],
"hasInstallScript": true,
"license": "GPLv3",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ffmpeg-installer/win32-ia32": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz",
"integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==",
"cpu": [
"ia32"
],
"license": "GPLv3",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@ffmpeg-installer/win32-x64": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz",
"integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==",
"cpu": [
"x64"
],
"license": "GPLv3",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -2656,6 +2575,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT"
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -2769,6 +2694,13 @@
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -3697,6 +3629,12 @@
"@types/istanbul-lib-report": "*"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
@@ -4515,6 +4453,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"license": "MIT"
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -5143,7 +5087,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
@@ -5570,7 +5513,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
@@ -5671,6 +5613,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -5788,6 +5731,12 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/express/node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/express/node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
@@ -5990,9 +5939,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"dev": true,
"license": "ISC"
},
@@ -8017,6 +7966,13 @@
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
"license": "MIT"
},
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -8029,6 +7985,13 @@
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
@@ -8060,6 +8023,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
@@ -8575,9 +8544,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -9023,6 +8992,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT",
"peer": true
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -9217,12 +9193,6 @@
"node": "20 || >=22"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
@@ -10794,6 +10764,71 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swagger-jsdoc": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
"license": "MIT",
"dependencies": {
"commander": "6.2.0",
"doctrine": "3.0.0",
"glob": "7.1.6",
"lodash.mergewith": "^4.6.2",
"swagger-parser": "^10.0.3",
"yaml": "2.0.0-1"
},
"bin": {
"swagger-jsdoc": "bin/swagger-jsdoc.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/swagger-jsdoc/node_modules/commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
"license": "MIT",
"dependencies": {
"@apidevtools/swagger-parser": "10.0.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.32.5",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.5.tgz",
"integrity": "sha512-7/FQfWe9A4qoyYFdAwy0chD0uDYidDp/ZT9VQ9LZlgD4AnnHJk8/+ytAA1HkJYOPySmK6helPDdJQMlcumt7HA==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/swagger-ui-express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
"license": "MIT",
"dependencies": {
"swagger-ui-dist": ">=5.0.0"
},
"engines": {
"node": ">= v0.10.32"
},
"peerDependencies": {
"express": ">=4.0.0 || >=5.0.0-beta"
}
},
"node_modules/tar": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
@@ -11507,6 +11542,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.0.0-1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -11571,6 +11615,36 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/z-schema": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
"license": "MIT",
"dependencies": {
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"validator": "^13.7.0"
},
"bin": {
"z-schema": "bin/z-schema"
},
"engines": {
"node": ">=8.0.0"
},
"optionalDependencies": {
"commander": "^9.4.1"
}
},
"node_modules/z-schema/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/zip-stream": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "2.6.5",
"version": "3.42.1",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -10,13 +10,13 @@
"migrate:safe": "node migrations/run-migrations-safe.js",
"generate:watermarks": "node scripts/generate-watermarks.js",
"test": "jest",
"test:s3": "SKIP_S3_TESTS=false jest __tests__/integration/backup-s3",
"lint": "eslint src/"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "1.14.0",
@@ -50,6 +50,8 @@
"sanitize-html": "^2.17.0",
"sharp": "0.34.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0",
"winston": "^3.8.2",
"zxcvbn": "^4.4.2"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
/**
* Generate the OpenAPI spec from JSDoc annotations in src/routes/v1/* and
* write it as YAML + JSON to ../docs/. Used by scripts/sync-api-docs.sh
* to keep the picpeak-docs site in lockstep with the running API.
*/
const fs = require('fs');
const path = require('path');
// Need yaml — runtime require so the script fails clearly with an
// install hint instead of an opaque MODULE_NOT_FOUND.
let yaml;
try {
yaml = require('js-yaml');
} catch {
console.error('generate-openapi: missing dependency `js-yaml`. Run `npm install --save-dev js-yaml` in /backend.');
process.exit(2);
}
const { getOpenApiSpec } = require('../src/openapi/spec');
const outDir = path.resolve(__dirname, '../../docs');
fs.mkdirSync(outDir, { recursive: true });
const spec = getOpenApiSpec();
fs.writeFileSync(path.join(outDir, 'openapi.json'), JSON.stringify(spec, null, 2));
fs.writeFileSync(path.join(outDir, 'openapi.yaml'), yaml.dump(spec, { lineWidth: 100 }));
console.log(`Wrote openapi.json + openapi.yaml to ${outDir}`);
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env node
/**
* migrate-storage.js
*
* One-shot migration tool to copy every PicPeak content file from the local
* filesystem (the legacy STORAGE_PATH) to a configured S3-compatible bucket.
*
* Reads the relative path of each known asset from the database:
* photos.path
* photos.thumbnail_path
* photos.hero_path
* photos.watermark_path
* events.archive_path
* events.download_zip_path
*
* For each, streams from local fs → S3, skipping files whose sha256 already
* matches a previously uploaded object (idempotent — safe to re-run).
*
* Does NOT flip STORAGE_BACKEND. After the migration completes clean, the
* operator updates their environment + restarts the backend explicitly.
*
* Usage:
* node backend/scripts/migrate-storage.js # live migration
* node backend/scripts/migrate-storage.js --dry-run # report only, no uploads
* node backend/scripts/migrate-storage.js --failures-csv=/path/to/failures.csv
* node backend/scripts/migrate-storage.js --concurrency=4
*
* Required env (S3 destination — same vars the backend reads with STORAGE_BACKEND=s3):
* STORAGE_S3_BUCKET, STORAGE_S3_REGION, STORAGE_S3_ACCESS_KEY, STORAGE_S3_SECRET_KEY
* STORAGE_S3_ENDPOINT (optional — for MinIO/R2/etc.)
* STORAGE_S3_PREFIX (optional)
*
* STORAGE_PATH must point at the live local storage root. Postgres connection
* uses the same DB env vars the backend uses.
*/
require('dotenv').config();
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const { db } = require('../src/database/db');
const LocalFsStorage = require('../src/services/storage/LocalFsStorage');
const S3StorageBackend = require('../src/services/storage/S3StorageBackend');
const logger = require('../src/utils/logger');
function parseArgs(argv) {
const args = { dryRun: false, concurrency: 4, failuresCsv: '/tmp/migrate-storage-failures.csv' };
for (const arg of argv) {
if (arg === '--dry-run') args.dryRun = true;
else if (arg.startsWith('--concurrency=')) args.concurrency = Math.max(1, parseInt(arg.split('=')[1], 10) || 4);
else if (arg.startsWith('--failures-csv=')) args.failuresCsv = arg.split('=')[1];
else if (arg === '--help' || arg === '-h') {
console.log('Usage: node migrate-storage.js [--dry-run] [--concurrency=N] [--failures-csv=PATH]');
process.exit(0);
}
}
return args;
}
function buildLocalSource() {
const root = process.env.STORAGE_PATH;
if (!root) {
throw new Error('STORAGE_PATH must be set to the local storage root.');
}
return new LocalFsStorage({ root });
}
function buildS3Destination() {
const required = ['STORAGE_S3_BUCKET', 'STORAGE_S3_ACCESS_KEY', 'STORAGE_S3_SECRET_KEY'];
const missing = required.filter((v) => !process.env[v]);
if (missing.length) {
throw new Error(`Missing S3 env vars: ${missing.join(', ')}`);
}
return new S3StorageBackend({
bucket: process.env.STORAGE_S3_BUCKET,
region: process.env.STORAGE_S3_REGION || 'us-east-1',
endpoint: process.env.STORAGE_S3_ENDPOINT,
accessKeyId: process.env.STORAGE_S3_ACCESS_KEY,
secretAccessKey: process.env.STORAGE_S3_SECRET_KEY,
prefix: process.env.STORAGE_S3_PREFIX,
forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true' ? true : undefined,
sslEnabled: process.env.STORAGE_S3_SSL !== 'false',
});
}
async function sha256OfFile(localPath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(localPath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
async function collectKeys() {
const keys = new Map(); // key -> { source, contentType }
const addKey = (key, source) => {
if (!key) return;
const normalized = key.replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized) return;
if (!keys.has(normalized)) keys.set(normalized, { source });
};
// photos: path (events/active/{slug}/{filename}), thumbnail_path, hero_path, watermark_path
const photoBatch = await db('photos').select('id', 'path', 'thumbnail_path', 'hero_path', 'watermark_path');
for (const p of photoBatch) {
if (p.path) {
const photoKey = p.path.startsWith('events/active/') ? p.path : path.posix.join('events/active', p.path);
addKey(photoKey, `photos.path[${p.id}]`);
}
addKey(p.thumbnail_path, `photos.thumbnail_path[${p.id}]`);
addKey(p.hero_path, `photos.hero_path[${p.id}]`);
addKey(p.watermark_path, `photos.watermark_path[${p.id}]`);
}
// events: archive_path, download_zip_path
const eventBatch = await db('events').select('id', 'archive_path', 'download_zip_path');
for (const e of eventBatch) {
addKey(e.archive_path, `events.archive_path[${e.id}]`);
addKey(e.download_zip_path, `events.download_zip_path[${e.id}]`);
}
return keys;
}
async function migrateOne(key, meta, { source, dest, dryRun }) {
// Source must exist on local disk.
const localPath = source.resolveLocalPath(key);
let localStat;
try {
localStat = await fsp.stat(localPath);
} catch (err) {
if (err.code === 'ENOENT') {
return { key, status: 'missing-locally', source: meta.source };
}
throw err;
}
// Idempotent skip: if S3 already has matching size + sha256.
const remoteStat = await dest.stat(key);
if (remoteStat && remoteStat.size === localStat.size) {
// sha256 match check via metadata is expensive; we trust size match for now.
// Operators paranoid about content drift can `rm` the bucket and re-run.
return { key, status: 'already-uploaded', source: meta.source };
}
if (dryRun) {
return { key, status: 'would-upload', source: meta.source, size: localStat.size };
}
await dest.putFromFile(key, localPath);
const verify = await dest.stat(key);
if (!verify || verify.size !== localStat.size) {
return { key, status: 'size-mismatch-after-upload', source: meta.source, expected: localStat.size, got: verify?.size };
}
return { key, status: 'uploaded', source: meta.source, size: localStat.size };
}
async function processWithConcurrency(items, concurrency, fn) {
const results = [];
let i = 0;
const workers = Array.from({ length: concurrency }, async () => {
while (true) {
const idx = i++;
if (idx >= items.length) return;
const [key, meta] = items[idx];
try {
const r = await fn(key, meta);
results.push(r);
} catch (err) {
results.push({ key, status: 'error', source: meta.source, error: err.message });
}
}
});
await Promise.all(workers);
return results;
}
function formatCsvCell(v) {
if (v == null) return '';
const s = String(v);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
async function writeFailuresCsv(filePath, failures) {
if (failures.length === 0) {
// Touch an empty file with header so callers see a deterministic outcome.
await fsp.writeFile(filePath, 'key,source,status,error\n');
return;
}
const lines = ['key,source,status,error'];
for (const f of failures) {
lines.push([f.key, f.source, f.status, f.error || ''].map(formatCsvCell).join(','));
}
await fsp.writeFile(filePath, lines.join('\n') + '\n');
}
async function main() {
const args = parseArgs(process.argv.slice(2));
logger.info(`migrate-storage starting (dry-run=${args.dryRun}, concurrency=${args.concurrency})`);
const source = buildLocalSource();
await source.init();
const dest = buildS3Destination();
await dest.init();
logger.info('collecting key list from database…');
const keys = await collectKeys();
logger.info(`found ${keys.size} unique keys to process`);
const items = Array.from(keys.entries());
const results = await processWithConcurrency(items, args.concurrency, (key, meta) =>
migrateOne(key, meta, { source, dest, dryRun: args.dryRun })
);
const counts = results.reduce((acc, r) => {
acc[r.status] = (acc[r.status] || 0) + 1;
return acc;
}, {});
console.log('\n=== migrate-storage summary ===');
for (const [status, count] of Object.entries(counts).sort()) {
console.log(` ${status.padEnd(28)} ${count}`);
}
const failureStatuses = new Set(['error', 'missing-locally', 'size-mismatch-after-upload']);
const failures = results.filter((r) => failureStatuses.has(r.status));
await writeFailuresCsv(args.failuresCsv, failures);
if (failures.length > 0) {
console.log(`\nWrote ${failures.length} failures to ${args.failuresCsv}`);
console.log('Re-run with --dry-run to triage; fix sources or remove DB rows that point at missing files.');
process.exitCode = 1;
} else if (args.dryRun) {
console.log(`\nDry-run complete. Re-run without --dry-run to perform the migration.`);
console.log(`(Empty failures CSV written to ${args.failuresCsv}.)`);
} else {
console.log(`\nMigration complete. Update STORAGE_BACKEND=s3 + restart the backend to switch over.`);
}
await db.destroy();
}
main().catch(async (err) => {
console.error('migrate-storage failed:', err);
try { await db.destroy(); } catch (_) { /* ignore */ }
process.exit(2);
});
+132 -16
View File
@@ -23,6 +23,7 @@ const { startExpirationChecker } = require('./src/services/expirationChecker');
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
const { startBackupService } = require('./src/services/backupService');
const { startScheduledBackups } = require('./src/services/databaseBackup');
const backgroundProcessor = require('./src/services/backgroundProcessor');
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler');
@@ -194,13 +195,23 @@ function composeInlineStyles(payload) {
return cssSegments.join('\n\n');
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function renderBrandHeader(branding) {
const displayName = branding.companyName || 'PicPeak';
const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png';
const displayName = escapeHtml(branding.companyName || 'PicPeak');
const logoSrc = encodeURI(branding.logoUrl || '/picpeak-logo-transparent.png');
const logo = `<img src="${logoSrc}" alt="${displayName}" class="brand-logo" loading="lazy" decoding="async" />`;
const tagline = branding.companyTagline
? `<p class="brand-tagline">${branding.companyTagline}</p>`
? `<p class="brand-tagline">${escapeHtml(branding.companyTagline)}</p>`
: '';
return `<header class="site-header">
@@ -224,13 +235,14 @@ function renderBrandHeader(branding) {
}
function renderBrandFooter(branding) {
const displayName = branding.companyName || 'PicPeak';
const displayName = escapeHtml(branding.companyName || 'PicPeak');
const footerNote = branding.footerText
? `<p>${branding.footerText}</p>`
? `<p>${escapeHtml(branding.footerText)}</p>`
: '<p>Powered by PicPeak to keep every celebration beautifully organised.</p>';
const supportLink = branding.supportEmail
? `<a href="mailto:${branding.supportEmail}">Support</a>`
const supportEmail = escapeHtml(branding.supportEmail || '');
const supportLink = supportEmail
? `<a href="mailto:${supportEmail}">Support</a>`
: '';
const legalLinks = `
@@ -282,7 +294,7 @@ function buildPublicSiteDocument(payload) {
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${payload.title}</title>
<title>${escapeHtml(payload.title)}</title>
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
${seoMeta}
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -362,6 +374,20 @@ async function initializeRateLimiters() {
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// CSRF protection: require JSON Content-Type on mutating API requests
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
app.use('/api', (req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const contentType = req.headers['content-type'] || '';
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
// Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
}
}
next();
});
// Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => {
try {
@@ -423,6 +449,34 @@ app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, se
// Static file serving for uploads (public - logos, favicons)
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
// Static file serving for self-hosted webfonts (public — gallery visitors
// load these via @font-face). Replaces the previous Google Fonts CDN
// dependency, which leaked visitor IPs to a third party (LG München 2022
// GDPR ruling).
//
// Two mounts in priority order:
// 1. STORAGE_PATH/fonts/ — runtime user additions (drop a folder, restart)
// 2. backend/assets/fonts/ — bundled defaults baked into the image
// Express evaluates handlers in order, so user-supplied files win on overlap.
//
// We deliberately do NOT set `immutable` on these responses. The filenames
// are stable (e.g. Inter/400.woff2), so an admin replacing the file on disk
// must be able to roll out the change to clients. With max-age + Last-Modified
// (set by express.static from file mtime), browsers send If-Modified-Since
// after expiry and pick up the new version automatically. See docs/fonts.md
// "Replacing an existing font" for the documented rollout strategy.
const fontStaticOpts = { maxAge: '7d' };
app.use(
'/fonts',
setCorsHeaders,
secureStatic(path.join(storagePath, 'fonts'), fontStaticOpts)
);
app.use(
'/fonts',
setCorsHeaders,
secureStatic(path.resolve(__dirname, 'assets/fonts'), fontStaticOpts)
);
// Debug endpoint to check IP detection (only in development)
if (process.env.NODE_ENV === 'development') {
app.get('/api/debug/ip', (req, res) => {
@@ -445,6 +499,13 @@ if (process.env.NODE_ENV === 'development') {
});
}
// OG/Twitter-card preview endpoint for gallery share URLs. Crawlers (WhatsApp,
// Slack, Facebook, etc.) don't execute JS, so the SPA's client-side meta tags
// never reach them. nginx routes UA-detected crawlers from /gallery/:slug to
// here; humans still get the SPA via try_files.
const { isSocialCrawler, handleGalleryOgRequest } = require('./src/services/galleryOgService');
app.get('/og/gallery/:slug', handleGalleryOgRequest);
// robots.txt endpoint (dynamic, served from DB settings)
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
app.get('/robots.txt', async (req, res) => {
@@ -461,21 +522,24 @@ app.get('/robots.txt', async (req, res) => {
}
});
// Health check endpoint
// Health check endpoint. `pid` + `uptime` let monitors (and the local E2E
// watchdog) detect a silent process restart between two checks.
app.get('/health', async (req, res) => {
try {
// Check database connectivity
await db.raw('SELECT 1');
res.json({
status: 'ok',
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
pid: process.pid,
uptime: process.uptime()
});
} catch (error) {
logger.error('Health check failed:', error);
res.status(503).json({
status: 'error',
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
pid: process.pid,
uptime: process.uptime()
});
}
});
@@ -487,12 +551,14 @@ app.use('/api/auth', authRoutes);
// Gallery routes - main routes first, then feedback routes
app.use('/api/gallery', galleryRoutes);
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
app.use('/api/gallery', require('./src/routes/galleryGuests'));
app.use('/api/admin', adminRoutes);
app.use('/api/admin/auth', adminAuthRoutes);
app.use('/api/admin/system', require('./src/routes/adminSystem'));
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
app.use('/api/admin', require('./src/routes/adminGuests'));
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
app.use('/api/admin/photos', require('./src/routes/adminPhotoDimensions'));
@@ -502,8 +568,30 @@ app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
// Public v1 API for n8n / external integrations (#322). Mounted under
// /api/v1; auth handled per-route via apiTokenAuth (Bearer tokens).
app.use('/api/v1', require('./src/routes/v1/events'));
// Swagger UI for the v1 API. Admin-gated since it lists endpoint shapes
// that should not be enumerable to anonymous users (a common reduce-info-leak hardening).
{
const swaggerUi = require('swagger-ui-express');
const { adminAuth } = require('./src/middleware/auth');
const { getOpenApiSpec } = require('./src/openapi/spec');
app.get('/api/openapi.json', adminAuth, (_req, res) => res.json(getOpenApiSpec()));
app.use(
'/api/docs',
adminAuth,
swaggerUi.serve,
swaggerUi.setup(getOpenApiSpec(), { customSiteTitle: 'PicPeak API · v1' })
);
}
app.use('/api/invite', require('./src/routes/acceptInvite'));
app.use('/api/public/settings', require('./src/routes/publicSettings'));
app.use('/api/public/fonts', require('./src/routes/publicFonts'));
app.use('/api/public', require('./src/routes/publicCMS'));
app.use('/api/images', require('./src/routes/protectedImages'));
app.use('/api/secure-images', secureImagesRoutes);
@@ -525,7 +613,16 @@ try {
res.sendFile(indexPath);
});
// SPA fallback for admin + gallery routes
// SPA fallback for admin + gallery routes. For gallery URLs we intercept
// social-crawler User-Agents and serve OG/Twitter-card metadata so link
// previews show the event name + branding instead of the SPA stub.
app.get('/gallery/:slug/:token?', (req, res, next) => {
if (isSocialCrawler(req.get('user-agent'))) {
return handleGalleryOgRequest(req, res);
}
return next();
}, (req, res) => res.sendFile(indexPath));
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
res.sendFile(indexPath);
});
@@ -551,6 +648,10 @@ async function startServer() {
// Initialize database
await initializeDatabase();
// Initialize storage backend (local fs or S3) — fail fast on misconfig
const { initStorage } = require('./src/services/storage');
await initStorage();
// Initialize rate limiters after database is ready
await initializeRateLimiters();
logger.info('Rate limiters initialized with database configuration');
@@ -577,12 +678,27 @@ async function startServer() {
await initializeTransporter();
startEmailQueueProcessor();
// Start webhook delivery worker (#327)
const { startWebhookDeliveryWorker } = require('./src/services/webhookDeliveryWorker');
startWebhookDeliveryWorker();
// Start S3 auto-importer (#328 follow-up). No-op when STORAGE_AUTO_IMPORT
// is unset OR STORAGE_BACKEND=local — replaces the chokidar watcher
// for S3-mode deployments that drop files into the bucket directly.
const { startS3AutoImporter } = require('./src/services/s3AutoImporter');
startS3AutoImporter();
// Start backup service
await startBackupService();
// Start database backup service
await startScheduledBackups();
// Start the async photo-processing worker pool. Picks up
// photos in 'pending' state (from POST /upload) and runs the
// sharp/ffmpeg/EXIF pipeline off the request thread.
backgroundProcessor.start();
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
@@ -104,4 +104,59 @@ describe('publicSiteService', () => {
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
expect(payload.branding.colors.primary).toBe('#5C8762');
});
it('exposes the 8-token CI palette through branding.colors', async () => {
const publicSiteRows = buildPublicSiteRows({});
const brandingRows = buildBrandingRows({
themeConfig: {
// LBM CI palette (charcoal + teal).
primaryColor: '#014E4E',
accentColor: '#017C7C',
accentDarkColor: '#014E4E',
backgroundColor: '#0D0D0D',
surfaceColor: '#111414',
elevatedColor: '#182222',
surfaceBorderColor: '#1E2E2E',
textColor: '#EBEBEB',
mutedTextColor: '#4A6060'
}
});
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
// Legacy 4 colors still mapped.
expect(payload.branding.colors.primary).toBe('#014E4E');
expect(payload.branding.colors.accent).toBe('#017C7C');
expect(payload.branding.colors.background).toBe('#0D0D0D');
expect(payload.branding.colors.text).toBe('#EBEBEB');
// 8-token CI palette additions.
expect(payload.branding.colors.accentDark).toBe('#014E4E');
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('#4A6060');
});
it('falls back accentDark to legacy primaryColor when the new key is absent', async () => {
const publicSiteRows = buildPublicSiteRows({});
const brandingRows = buildBrandingRows({
themeConfig: {
primaryColor: '#5C8762',
accentColor: '#22c55e',
backgroundColor: '#fafafa',
textColor: '#171717'
// accentDarkColor intentionally omitted to simulate a legacy theme.
}
});
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.branding.colors.accentDark).toBe('#5C8762');
});
});
+41
View File
@@ -463,8 +463,31 @@ async function ensureGlobalCategories() {
table.text('title_de');
table.text('content_en');
table.text('content_de');
table.string('logo_url').nullable();
table.boolean('use_external_url').notNullable().defaultTo(false);
table.string('external_url').nullable();
table.timestamp('updated_at').defaultTo(db.fn.now());
});
} else {
if (!(await db.schema.hasColumn('cms_pages', 'logo_url'))) {
// Online migration for existing deployments — see issue #324, per-page
// logo override for admin-customisable error pages.
await db.schema.alterTable('cms_pages', (table) => {
table.string('logo_url').nullable();
});
}
if (!(await db.schema.hasColumn('cms_pages', 'use_external_url'))) {
// Per-page toggle to redirect visitors to an external imprint /
// privacy-policy URL instead of rendering the internal CMS content.
await db.schema.alterTable('cms_pages', (table) => {
table.boolean('use_external_url').notNullable().defaultTo(false);
});
}
if (!(await db.schema.hasColumn('cms_pages', 'external_url'))) {
await db.schema.alterTable('cms_pages', (table) => {
table.string('external_url').nullable();
});
}
}
const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
@@ -501,6 +524,24 @@ async function ensureGlobalCategories() {
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date(),
},
// Customisable error pages — issue #324. Generic copy by default;
// admins can edit text + logo per page in the CMS Pages tab.
{
slug: 'not-found',
title_en: 'Page Not Found',
title_de: 'Seite nicht gefunden',
content_en: '<h2>Page Not Found</h2><p>The page you are looking for does not exist or has been moved.</p>',
content_de: '<h2>Seite nicht gefunden</h2><p>Die gesuchte Seite existiert nicht oder wurde verschoben.</p>',
updated_at: new Date(),
},
{
slug: 'gallery-not-found',
title_en: 'Gallery Not Found',
title_de: 'Galerie nicht gefunden',
content_en: '<h2>Gallery Not Found</h2><p>This gallery could not be found. The link may be incorrect, or the gallery may have expired or been archived. Please contact the organiser if you believe this is a mistake.</p>',
content_de: '<h2>Galerie nicht gefunden</h2><p>Diese Galerie konnte nicht gefunden werden. Der Link ist möglicherweise nicht korrekt, oder die Galerie ist abgelaufen oder wurde archiviert. Bitte kontaktieren Sie den Veranstalter, falls Sie glauben, dass dies ein Fehler ist.</p>',
updated_at: new Date(),
},
];
for (const page of defaultPages) {
+123
View File
@@ -0,0 +1,123 @@
const crypto = require('crypto');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const TOKEN_PREFIX = 'pp_live_';
const VALID_SCOPES = ['read', 'write', 'admin'];
function hashToken(plaintext) {
return crypto.createHash('sha256').update(plaintext).digest('hex');
}
/**
* Generate a new API token. Returns the plaintext (return once, never
* stored) plus the row payload to insert. Caller persists.
*/
function generateApiToken() {
const random = crypto.randomBytes(24).toString('base64url'); // 32 chars
const plaintext = `${TOKEN_PREFIX}${random}`;
return {
plaintext,
hashed: hashToken(plaintext),
preview: random.slice(0, 8)
};
}
function parseScopes(raw) {
if (!raw) return [];
return String(raw)
.split(',')
.map((s) => s.trim().toLowerCase())
.filter((s) => VALID_SCOPES.includes(s));
}
/**
* Middleware: authenticate via API token. Maps the token to its owner
* admin user, attaches { req.admin, req.apiToken }, then defers to the
* regular permission machinery on top.
*
* Mount this *instead* of `adminAuth` on /api/v1/* routes. Existing
* permission decorators (`requirePermission('events.create')`) still
* work because they read `req.admin.id`.
*/
async function apiTokenAuth(req, res, next) {
try {
const header = req.headers?.authorization || '';
if (!header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing API token', code: 'NO_TOKEN' });
}
const token = header.slice(7).trim();
if (!token.startsWith(TOKEN_PREFIX)) {
return res.status(401).json({ error: 'Invalid token format', code: 'INVALID_TOKEN' });
}
const hashed = hashToken(token);
const row = await db('api_tokens').where({ hashed_token: hashed }).first();
if (!row) {
return res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' });
}
if (row.revoked_at) {
return res.status(401).json({ error: 'Token revoked', code: 'TOKEN_REVOKED' });
}
if (row.expires_at && new Date(row.expires_at) <= new Date()) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
const admin = await db('admin_users')
.where({ id: row.created_by, is_active: true })
.select('id', 'username', 'email', 'role_id')
.first();
if (!admin) {
return res.status(401).json({ error: 'Token owner unavailable', code: 'OWNER_INACTIVE' });
}
// Touch last_used_at — async, don't block the request.
db('api_tokens').where({ id: row.id }).update({ last_used_at: new Date() })
.catch((err) => logger.debug('api_tokens last_used update failed', { err: err.message }));
req.admin = admin;
req.apiToken = {
id: row.id,
name: row.name,
scopes: parseScopes(row.scopes)
};
return next();
} catch (error) {
logger.error('apiTokenAuth error', { error: error.message });
return res.status(500).json({ error: 'Authentication error' });
}
}
/**
* Middleware factory: require a specific scope on the API token. Use
* after apiTokenAuth `requireApiScope('write')` rejects read-only
* tokens trying to mutate.
*/
function requireApiScope(scope) {
return (req, res, next) => {
const have = req.apiToken?.scopes || [];
// 'admin' implies write/read; 'write' implies read.
const expanded = new Set(have);
if (have.includes('admin')) ['write', 'read'].forEach((s) => expanded.add(s));
if (have.includes('write')) expanded.add('read');
if (!expanded.has(scope)) {
return res.status(403).json({
error: `Token lacks required scope: ${scope}`,
code: 'INSUFFICIENT_SCOPE',
required: scope,
granted: have
});
}
next();
};
}
module.exports = {
apiTokenAuth,
requireApiScope,
generateApiToken,
hashToken,
parseScopes,
TOKEN_PREFIX,
VALID_SCOPES
};
+9 -3
View File
@@ -91,10 +91,16 @@ async function adminAuth(req, res, next) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if password was changed after token was issued
// Check if password was changed after token was issued. JWT `iat` has
// 1-second resolution; `password_changed_at` is sub-second. Floor the
// comparison so a token issued in the *same* second as the password
// change isn't incorrectly rejected — that race used to bite anyone
// logging in immediately after a password reset/change.
if (admin.password_changed_at) {
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000;
if (decoded.iat < passwordChangedTime) {
const passwordChangedSeconds = Math.floor(
new Date(admin.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
logger.warn('Token used after password change', { userId: decoded.id });
return res.status(401).json({
error: 'Token invalid due to password change',
+12 -1
View File
@@ -3,9 +3,20 @@ const { db } = require('../database/db');
const logger = require('../utils/logger');
/**
* Generate a unique identifier for the guest
* Generate a unique identifier for the guest.
*
* In guest identity mode, `req.guest.identifier` is a server-issued UUID
* unique per person per event (set by the resolveGuest middleware). When
* present it takes precedence, so rate limits and deduplication become
* per-person instead of per-device.
*
* In simple (legacy) mode, the identifier falls back to a hash of IP + UA,
* matching prior behavior.
*/
function generateGuestIdentifier(req) {
if (req.guest && req.guest.identifier) {
return req.guest.identifier;
}
const ip = req.ip || req.connection.remoteAddress || 'unknown';
const userAgent = req.headers['user-agent'] || 'unknown';
return crypto
+41 -18
View File
@@ -4,6 +4,18 @@ const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
// Check if the request carries a valid admin preview token (Feature 3)
function isAdminPreview(req) {
const previewToken = req.query?.preview;
if (!previewToken) return false;
try {
const decoded = jwt.verify(previewToken, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
return decoded.type === 'admin';
} catch {
return false;
}
}
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
try {
@@ -16,15 +28,18 @@ async function verifyGalleryAccess(req, res, next) {
return res.status(401).json({ error: 'No token provided' });
}
const adminPreview = isAdminPreview(req);
event = await withRetry(async () => {
return await db('events')
.where({
const q = db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
if (!adminPreview) {
q.where({ is_draft: formatBoolean(false) });
}
return await q.select('*').first();
});
if (!event) {
@@ -66,15 +81,18 @@ async function verifyGalleryAccess(req, res, next) {
// If we have a slug in the URL params or from pre-middleware, verify it matches
if (requestedSlug) {
// Verify by slug and ensure it matches the token's event
const adminPreviewToken = isAdminPreview(req);
event = await withRetry(async () => {
return await db('events')
.where({
const q = db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
if (!adminPreviewToken) {
q.where({ is_draft: formatBoolean(false) });
}
return await q.select('*').first();
});
// Verify the token's eventId matches
@@ -83,15 +101,18 @@ async function verifyGalleryAccess(req, res, next) {
}
} else {
// Fallback to using eventId from token
const adminPreviewFallback = isAdminPreview(req);
event = await withRetry(async () => {
return await db('events')
.where({
id: decoded.eventId,
const q = db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
if (!adminPreviewFallback) {
q.where({ is_draft: formatBoolean(false) });
}
return await q.select('*').first();
});
}
@@ -102,8 +123,9 @@ async function verifyGalleryAccess(req, res, next) {
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
req.event = event;
req.accessLevel = decoded.accessLevel || 'guest';
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
// Create client info for logging (similar to secureImageMiddleware but simpler)
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
@@ -121,5 +143,6 @@ async function verifyGalleryAccess(req, res, next) {
}
module.exports = {
verifyGalleryAccess
verifyGalleryAccess,
isAdminPreview
};
+105
View File
@@ -0,0 +1,105 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getGuestTokenFromRequest } = require('../utils/tokenUtils');
/**
* Non-blocking middleware. Reads an optional guest token from the request and,
* if present and valid, populates req.guest with { id, identifier, name, eventId }.
*
* If the token is missing, malformed, or expired req.guest = null and the
* request continues. Downstream handlers (e.g. feedback submission) enforce
* presence explicitly based on event feedback settings (identity_mode).
*/
async function resolveGuest(req, res, next) {
try {
const slug = req.params?.slug;
const token = getGuestTokenFromRequest(req, slug);
if (!token) {
req.guest = null;
return next();
}
let decoded;
try {
const verified = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true,
});
decoded = verified.payload;
} catch (err) {
// Invalid or expired guest tokens are silently ignored so that public
// gallery browsing continues to work even if the token is stale.
logger.debug('Invalid guest token', { reason: err.message });
req.guest = null;
return next();
}
if (decoded.type !== 'guest') {
req.guest = null;
return next();
}
// Verify the guest row still exists and is not soft-deleted.
const guest = await db('gallery_guests')
.where({ id: decoded.guestId, event_id: decoded.eventId, is_deleted: false })
.first();
if (!guest) {
req.guest = null;
return next();
}
req.guest = {
id: guest.id,
eventId: guest.event_id,
identifier: guest.identifier,
name: guest.name,
email: guest.email || null,
};
return next();
} catch (error) {
logger.error('resolveGuest middleware error', { error: error.message });
req.guest = null;
return next();
}
}
/**
* Blocking middleware that 401s if no guest identity was resolved.
* Use this on endpoints that require a valid guest session.
*/
function requireGuest(req, res, next) {
if (!req.guest) {
return res.status(401).json({ error: 'Guest identity required' });
}
return next();
}
/**
* Sign a new guest JWT. Scoped to a specific event and guest row.
* Expiry matches the gallery token default (24h).
*/
function signGuestToken({ guestId, eventId, identifier, name }, expiresIn = '24h') {
return jwt.sign(
{
type: 'guest',
guestId,
eventId,
identifier,
name,
},
process.env.JWT_SECRET,
{
issuer: 'picpeak-auth',
expiresIn,
}
);
}
module.exports = {
resolveGuest,
requireGuest,
signGuestToken,
};
+35
View File
@@ -0,0 +1,35 @@
const { db } = require('../database/db');
/**
* Middleware to enforce event ownership for non-super_admin users.
* Super admins bypass the check. Other admins can only access events they created.
*/
function requireEventOwnership(req, res, next) {
if (req.admin.roleName === 'super_admin') {
return next();
}
const eventId = req.params.eventId || req.params.id;
if (!eventId) {
return res.status(400).json({ error: 'Event ID is required' });
}
db('events')
.where('id', eventId)
.first()
.then((event) => {
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Allow access if: event has no owner (legacy/system), or admin owns it
if (event.created_by && event.created_by !== req.admin.id) {
return res.status(403).json({ error: 'Access denied' });
}
next();
})
.catch((err) => {
res.status(500).json({ error: 'Failed to verify ownership' });
});
}
module.exports = { requireEventOwnership };
+32 -3
View File
@@ -134,6 +134,34 @@ async function sessionTimeoutMiddleware(req, res, next) {
}
}
// Non-mutating timeout check used by /auth/session (auth.js) so the session
// endpoint enforces the same timeout the protected /api/admin endpoints
// already enforce via sessionTimeoutMiddleware. Without this, /auth/session
// returns valid:true for a token that protected endpoints reject with
// 401 SESSION_TIMEOUT, producing the /admin/login → /admin/dashboard →
// /admin/login redirect loop reported on v3.39.1-beta.0 (issue #350).
//
// Mirrors the middleware's logic exactly:
// - If we have an in-memory lastActivity for this token, return whether
// the gap exceeds the timeout.
// - Otherwise (post-restart, or first request with this token), return
// whether the token's iat is older than the timeout — same post-restart
// guard the middleware uses.
//
// Does NOT update the in-memory map. The middleware is the only place that
// tracks activity; /auth/session is read-only by design.
async function isSessionExpired(token, decoded) {
if (!token || !decoded || !decoded.id) return false;
const now = Date.now();
const timeout = await getSessionTimeout();
const lastActivity = sessions.get(token);
if (lastActivity) {
return (now - lastActivity) > timeout;
}
const tokenIssuedAt = (decoded.iat || 0) * 1000;
return (now - tokenIssuedAt) > timeout;
}
// Function to end a session
function endSession(token) {
sessions.delete(token);
@@ -153,8 +181,9 @@ function getActiveSessions() {
return active;
}
module.exports = {
sessionTimeoutMiddleware,
module.exports = {
sessionTimeoutMiddleware,
isSessionExpired,
endSession,
getActiveSessions
getActiveSessions
};
+70
View File
@@ -0,0 +1,70 @@
/**
* OpenAPI 3.1 spec for /api/v1/* (#322). Source of truth for the
* picpeak-docs reference page. Built from JSDoc `@openapi` blocks
* scattered through src/routes/v1 those stay co-located with the
* routes they describe so the spec can't drift in isolation.
*/
const swaggerJSDoc = require('swagger-jsdoc');
const path = require('path');
const baseDoc = {
openapi: '3.0.3',
info: {
title: 'PicPeak API',
version: 'v1',
description:
'Public REST API for PicPeak — create gallery events, upload photos, fetch share links. ' +
'Authenticate with a Bearer token issued via the admin **Settings → API Tokens** tab.'
},
servers: [
{ url: '/api/v1', description: 'Same-origin (production)' }
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'pp_live_*',
description:
'Long-lived API token. Issue via Settings → API Tokens. ' +
'Token format: `pp_live_<random>`. Scopes: `read`, `write`, `admin`.'
}
},
schemas: {
EventSummary: {
type: 'object',
properties: {
id: { type: 'integer' },
slug: { type: 'string' },
event_name: { type: 'string' },
event_type: { type: 'string' },
event_date: { type: 'string', format: 'date', nullable: true },
expires_at: { type: 'string', format: 'date-time', nullable: true },
is_active: { type: 'boolean' },
is_archived: { type: 'boolean' },
is_draft: { type: 'boolean' },
created_at: { type: 'string', format: 'date-time' }
}
}
}
},
security: [{ bearerAuth: [] }]
};
const options = {
definition: baseDoc,
// Pull @openapi blocks from every v1 route file.
apis: [path.join(__dirname, '../routes/v1/**/*.js')]
};
let cached = null;
function getOpenApiSpec() {
if (!cached) {
cached = swaggerJSDoc(options);
}
return cached;
}
module.exports = { getOpenApiSpec };
+117
View File
@@ -0,0 +1,117 @@
/**
* Admin endpoints for managing API tokens (#322). Tokens are issued to
* an admin user; subsequent /api/v1/* calls authenticate via the token
* and act as the user that minted it (intersected with the token's
* scope set). Plaintext tokens are returned ONCE on creation.
*/
const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('./../middleware/auth');
const { requirePermission } = require('./../middleware/permissions');
const { generateApiToken, VALID_SCOPES } = require('./../middleware/apiTokenAuth');
const logger = require('../utils/logger');
const router = express.Router();
// List tokens for the current admin (or all, if super_admin) — without
// the plaintext, never recoverable after creation.
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const tokens = await db('api_tokens')
.leftJoin('admin_users', 'admin_users.id', 'api_tokens.created_by')
.select(
'api_tokens.id',
'api_tokens.name',
'api_tokens.scopes',
'api_tokens.preview',
'api_tokens.created_at',
'api_tokens.expires_at',
'api_tokens.last_used_at',
'api_tokens.revoked_at',
'admin_users.username as owner_username'
)
.orderBy('api_tokens.created_at', 'desc');
res.json(tokens);
} catch (error) {
logger.error('Failed to list API tokens', { error: error.message });
res.status(500).json({ error: 'Failed to list tokens' });
}
});
// Create a token. Returns plaintext exactly once.
router.post(
'/',
adminAuth,
requirePermission('settings.edit'),
[
body('name').isString().trim().isLength({ min: 1, max: 100 }),
body('scopes').isArray({ min: 1 }).custom((arr) => {
const ok = arr.every((s) => VALID_SCOPES.includes(s));
if (!ok) throw new Error(`Scopes must be a subset of: ${VALID_SCOPES.join(', ')}`);
return true;
}),
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601()
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { name, scopes, expires_at } = req.body;
const { plaintext, hashed, preview } = generateApiToken();
const insertResult = await db('api_tokens').insert({
name,
hashed_token: hashed,
scopes: scopes.join(','),
preview,
created_by: req.admin.id,
expires_at: expires_at || null
}).returning('id');
const id = insertResult[0]?.id || insertResult[0];
await logActivity('api_token_created', { name, scopes }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username
});
// Return the plaintext exactly once.
res.status(201).json({
id,
name,
scopes,
token: plaintext,
preview,
expires_at: expires_at || null,
created_at: new Date().toISOString(),
notice: 'Save this token now — it will not be shown again.'
});
} catch (error) {
logger.error('Failed to create API token', { error: error.message });
res.status(500).json({ error: 'Failed to create token' });
}
}
);
// Revoke a token (soft-delete; lookups still find it but reject).
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { id } = req.params;
const row = await db('api_tokens').where({ id }).first();
if (!row) return res.status(404).json({ error: 'Token not found' });
if (row.revoked_at) return res.status(400).json({ error: 'Token already revoked' });
await db('api_tokens').where({ id }).update({ revoked_at: new Date() });
await logActivity('api_token_revoked', { name: row.name }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username
});
res.json({ id: Number(id), revoked: true });
} catch (error) {
logger.error('Failed to revoke API token', { error: error.message });
res.status(500).json({ error: 'Failed to revoke token' });
}
});
module.exports = router;
+5 -4
View File
@@ -7,6 +7,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
const AdmZip = require('adm-zip');
const { requireEventOwnership } = require('../middleware/ownership');
const router = express.Router();
// Get all archived events
@@ -82,7 +83,7 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
});
// Get single archive details
router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => {
router.get('/:id', adminAuth, requirePermission('archives.view'), requireEventOwnership, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -138,7 +139,7 @@ router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, re
});
// Restore archive
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => {
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), requireEventOwnership, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -301,7 +302,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), as
});
// Download archive
router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => {
router.get('/:id/download', adminAuth, requirePermission('archives.download'), requireEventOwnership, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -350,7 +351,7 @@ router.get('/:id/download', adminAuth, requirePermission('archives.download'), a
});
// Delete archive permanently
router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('archives.delete'), requireEventOwnership, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
+21
View File
@@ -1,5 +1,6 @@
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
@@ -7,6 +8,7 @@ const { endSession } = require('../middleware/sessionTimeout');
const { validatePasswordStrength } = require('../utils/passwordGenerator');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
const router = express.Router();
// Get admin profile
@@ -133,6 +135,25 @@ router.post('/change-password', [
updated_at: now
});
// Issue a new token so the session remains valid after password_changed_at invalidated the old one.
// Set iat to 1 second after password_changed_at to guarantee the token passes the
// "iat < password_changed_at" check in auth middleware (password_changed_at has ms precision
// but JWT iat is floored to seconds, which can cause the new token to be rejected).
const iatAfterPasswordChange = Math.floor(now.getTime() / 1000) + 1;
const newToken = jwt.sign({
id: user.id,
username: user.username,
type: 'admin',
role: user.role_name,
iat: iatAfterPasswordChange,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setAdminAuthCookie(res, newToken);
// Log activity
await logActivity('password_changed',
{ admin_id: userId },
+30 -9
View File
@@ -258,6 +258,13 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
break;
}
// SSRF protection: block connections to private/internal addresses
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(host)) {
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
break;
}
// Validate username format if provided
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
res.json({ success: false, message: 'Invalid username format' });
@@ -355,12 +362,17 @@ router.get('/manifest/:backupRunId', adminAuth, requirePermission('backup.view')
router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { manifestPath } = req.body;
if (!manifestPath) {
return res.status(400).json({ error: 'manifestPath is required' });
}
const result = await validateBackupManifest(manifestPath);
// Prevent path traversal — manifest must be within backup directory
const backupBasePath = process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
const { safePathJoin } = require('../utils/fileSecurityUtils');
const safePath = safePathJoin(backupBasePath, manifestPath);
const result = await validateBackupManifest(safePath);
res.json({
valid: result.valid,
@@ -456,19 +468,24 @@ router.get('/manifests/:backupId/download', adminAuth, requirePermission('backup
router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { manifestPath, manifestData } = req.body;
if (!manifestPath && !manifestData) {
return res.status(400).json({ error: 'Either manifestPath or manifestData is required' });
}
if (manifestData) {
// Validate provided manifest data directly
const validationResult = await validateManifestData(manifestData);
return res.json(validationResult);
}
// Prevent path traversal — manifest must be within backup directory
const backupBasePath = process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
const { safePathJoin } = require('../utils/fileSecurityUtils');
const safePath = safePathJoin(backupBasePath, manifestPath);
// Use existing validation function for path
const result = await validateBackupManifest(manifestPath);
const result = await validateBackupManifest(safePath);
res.json({
valid: result.valid,
@@ -757,10 +774,14 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req
try {
const { path: targetPath = '', recursive = true } = req.query;
const checksums = {};
// Get storage path
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const basePath = targetPath ? path.join(storagePath, targetPath) : storagePath;
let basePath = storagePath;
if (targetPath) {
const { safePathJoin } = require('../utils/fileSecurityUtils');
basePath = safePathJoin(storagePath, targetPath);
}
// Calculate checksums for files
async function calculateDirChecksums(dirPath, relative = '') {
+151 -22
View File
@@ -1,10 +1,42 @@
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const multer = require('multer');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { validateFileType } = require('../utils/fileSecurityUtils');
const router = express.Router();
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Multer config for per-page logo uploads. Stores into the same
// /uploads/logos directory the global branding logo uses, with a
// per-slug filename so a page swap doesn't fight an unrelated upload.
const pageLogoStorage = multer.diskStorage({
destination: async (_req, _file, cb) => {
const dir = path.join(getStoragePath(), 'uploads/logos');
await fs.mkdir(dir, { recursive: true });
cb(null, dir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
const safeSlug = (req.params.slug || 'page').replace(/[^a-z0-9-]/gi, '');
cb(null, `cms-${safeSlug}-${Date.now()}${ext}`);
}
});
const pageLogoUpload = multer({
storage: pageLogoStorage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
if (validateFileType(file.originalname, file.mimetype, allowed)) cb(null, true);
else cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
}
});
// Get all CMS pages
router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => {
try {
@@ -21,11 +53,11 @@ router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req,
try {
const { slug } = req.params;
const page = await db('cms_pages').where('slug', slug).first();
if (!page) {
return res.status(404).json({ error: 'Page not found' });
}
res.json(page);
} catch (error) {
console.error('Error fetching CMS page:', error);
@@ -38,42 +70,74 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
body('title_en').optional().isString(),
body('title_de').optional().isString(),
body('content_en').optional().isString(),
body('content_de').optional().isString()
body('content_de').optional().isString(),
body('logo_url').optional({ nullable: true }).isString(),
body('use_external_url').optional().isBoolean(),
body('external_url').optional({ nullable: true }).isString()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug } = req.params;
const { title_en, title_de, content_en, content_de } = req.body;
const { title_en, title_de, content_en, content_de, logo_url, use_external_url, external_url } = req.body;
// When the external-URL toggle is on, the URL must parse and use https://.
// express-validator's isURL() is too permissive (allows http:, ftp:, etc.) —
// an explicit protocol check is the security-relevant gate.
if (use_external_url === true) {
const candidate = typeof external_url === 'string' ? external_url.trim() : '';
if (!candidate) {
return res.status(400).json({ error: 'external_url is required when use_external_url is true' });
}
let parsed;
try {
parsed = new URL(candidate);
} catch (_err) {
return res.status(400).json({ error: 'external_url must be a valid URL' });
}
if (parsed.protocol !== 'https:') {
return res.status(400).json({ error: 'external_url must use https://' });
}
}
const page = await db('cms_pages').where('slug', slug).first();
if (!page) {
return res.status(404).json({ error: 'Page not found' });
}
// Update the page
await db('cms_pages')
.where('slug', slug)
.update({
title_en,
title_de,
content_en,
content_de,
updated_at: new Date()
});
const updateFields = {
title_en,
title_de,
content_en,
content_de,
updated_at: new Date()
};
// Only touch logo_url when explicitly present so partial updates
// (e.g. text-only edits) don't accidentally clear the upload.
if (Object.prototype.hasOwnProperty.call(req.body, 'logo_url')) {
updateFields.logo_url = logo_url || null;
}
if (Object.prototype.hasOwnProperty.call(req.body, 'use_external_url')) {
updateFields.use_external_url = !!use_external_url;
}
if (Object.prototype.hasOwnProperty.call(req.body, 'external_url')) {
const trimmed = typeof external_url === 'string' ? external_url.trim() : '';
updateFields.external_url = trimmed || null;
}
await db('cms_pages').where('slug', slug).update(updateFields);
const updated = await db('cms_pages').where('slug', slug).first();
// Log activity
await logActivity('cms_page_updated',
{ page: slug },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
console.error('Error updating CMS page:', error);
@@ -81,4 +145,69 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
}
});
module.exports = router;
// Upload a per-page logo (#324). Persists the URL to cms_pages.logo_url
// and returns it so the client can re-render without a refetch.
router.post(
'/pages/:slug/logo',
adminAuth,
requirePermission('cms.edit'),
pageLogoUpload.single('logo'),
async (req, res) => {
try {
const { slug } = req.params;
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const page = await db('cms_pages').where('slug', slug).first();
if (!page) {
// Best-effort cleanup of the orphaned upload before erroring.
await fs.unlink(req.file.path).catch(() => {});
return res.status(404).json({ error: 'Page not found' });
}
const logoUrl = `/uploads/logos/${path.basename(req.file.path)}`;
await db('cms_pages').where('slug', slug).update({
logo_url: logoUrl,
updated_at: new Date()
});
await logActivity('cms_page_logo_uploaded',
{ page: slug },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ logo_url: logoUrl });
} catch (error) {
console.error('Error uploading CMS page logo:', error);
res.status(500).json({ error: 'Failed to upload logo' });
}
}
);
// Clear a per-page logo override (revert to global branding logo).
router.delete(
'/pages/:slug/logo',
adminAuth,
requirePermission('cms.edit'),
async (req, res) => {
try {
const { slug } = req.params;
const page = await db('cms_pages').where('slug', slug).first();
if (!page) return res.status(404).json({ error: 'Page not found' });
await db('cms_pages').where('slug', slug).update({
logo_url: null,
updated_at: new Date()
});
res.json({ logo_url: null });
} catch (error) {
console.error('Error clearing CMS page logo:', error);
res.status(500).json({ error: 'Failed to clear logo' });
}
}
);
module.exports = router;
+9 -1
View File
@@ -62,6 +62,13 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
.count('id as count')
.first();
// Get total events count (all events regardless of status) — used by the
// events list page to render accurate "All (N)" / Total Events counters
// when the table is server-paginated (#346).
const totalEvents = await db('events')
.count('id as count')
.first();
// Calculate trends (compare with previous 30 days)
const sixtyDaysAgo = new Date();
sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60);
@@ -98,7 +105,8 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
totalDownloads: totalDownloads.count || 0,
viewsTrend: Math.round(viewsTrend * 10) / 10,
downloadsTrend: Math.round(downloadsTrend * 10) / 10,
archivedEvents: archivedEvents.count || 0
archivedEvents: archivedEvents.count || 0,
totalEvents: totalEvents.count || 0
});
} catch (error) {
console.error('Dashboard stats error:', error);
+198 -148
View File
@@ -4,6 +4,7 @@ const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { wrapEmailHtml } = require('../services/emailProcessor');
const router = express.Router();
// Get email configuration
@@ -60,6 +61,12 @@ router.post('/config', [
tls_reject_unauthorized
} = req.body;
// Validate SMTP host is not a private/internal address (SSRF protection)
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(smtp_host)) {
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
}
// Check if config exists
const existingConfig = await db('email_configs').first();
@@ -159,22 +166,26 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
const transporter = nodemailer.createTransport(transportConfig);
// Send test email
// Send test email with the same wrapper used for all other emails
const subject = 'Test Email - Photo Sharing Platform';
const testHtmlBody = `
<h2>Test Email Successful!</h2>
<p>This is a test email from your Photo Sharing platform.</p>
<p>If you're seeing this, your email configuration is working correctly.</p>
<hr>
<p style="color: #666; font-size: 12px;">
Sent from: ${config.from_email}<br>
SMTP Host: ${config.smtp_host}<br>
Time: ${new Date().toISOString()}
</p>
`;
const wrappedHtml = await wrapEmailHtml(testHtmlBody, subject);
await transporter.sendMail({
from: `${config.from_name} <${config.from_email}>`,
to: test_email,
subject: 'Test Email - Photo Sharing Platform',
html: `
<h2>Test Email Successful!</h2>
<p>This is a test email from your Photo Sharing platform.</p>
<p>If you're seeing this, your email configuration is working correctly.</p>
<hr>
<p style="color: #666; font-size: 12px;">
Sent from: ${config.from_email}<br>
SMTP Host: ${config.smtp_host}<br>
Time: ${new Date().toISOString()}
</p>
`,
subject,
html: wrappedHtml,
text: 'Test Email Successful! Your email configuration is working correctly.'
});
@@ -237,6 +248,58 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
}
});
// Helper: parse variables JSON safely
function parseVariables(template) {
try {
if (!template.variables) return [];
if (typeof template.variables === 'object') return template.variables;
return JSON.parse(template.variables);
} catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message);
return [];
}
}
// Helper: get translations for a template, with legacy column fallback
async function getTemplateTranslations(templateId, template) {
const translations = {};
try {
const rows = await db('email_template_translations')
.where('template_id', templateId)
.select('language', 'subject', 'body_html', 'body_text');
rows.forEach(row => {
translations[row.language] = {
subject: row.subject || '',
body_html: row.body_html || '',
body_text: row.body_text || '',
};
});
} catch (error) {
// Translations table might not exist yet (pre-migration)
// Fall back to legacy columns
if (template.subject_en !== undefined) {
translations.en = {
subject: template.subject_en || '',
body_html: template.body_html_en || '',
body_text: template.body_text_en || '',
};
translations.de = {
subject: template.subject_de || '',
body_html: template.body_html_de || '',
body_text: template.body_text_de || '',
};
} else {
translations.en = {
subject: template.subject || '',
body_html: template.body_html || '',
body_text: template.body_text || '',
};
}
}
return translations;
}
// Get email templates
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
@@ -244,45 +307,17 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req,
.select('*')
.orderBy('template_key');
// Parse variables JSON and format for multi-language support
const formattedTemplates = templates.map(template => {
const result = {
const formattedTemplates = [];
for (const template of templates) {
const translations = await getTemplateTranslations(template.id, template);
formattedTemplates.push({
id: template.id,
template_key: template.template_key,
variables: (() => {
try {
if (!template.variables) return [];
if (typeof template.variables === 'object') return template.variables;
return JSON.parse(template.variables);
} catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message);
return [];
}
})(),
updated_at: template.updated_at
};
// Handle both old and new schema formats
if (template.subject_en !== undefined) {
// New schema with language columns
result.subject_en = template.subject_en;
result.body_html_en = template.body_html_en;
result.body_text_en = template.body_text_en;
result.subject_de = template.subject_de;
result.body_html_de = template.body_html_de;
result.body_text_de = template.body_text_de;
} else {
// Old schema - use basic columns for both languages
result.subject_en = template.subject;
result.body_html_en = template.body_html;
result.body_text_en = template.body_text;
result.subject_de = template.subject;
result.body_html_de = template.body_html;
result.body_text_de = template.body_text;
}
return result;
});
variables: parseVariables(template),
translations,
updated_at: template.updated_at,
});
}
res.json(formattedTemplates);
} catch (error) {
@@ -302,119 +337,99 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
return res.status(404).json({ error: 'Template not found' });
}
// Handle both old and new schema formats
const response = {
const translations = await getTemplateTranslations(template.id, template);
res.json({
id: template.id,
template_key: template.template_key,
variables: (() => {
try {
if (!template.variables) return [];
if (typeof template.variables === 'object') return template.variables;
return JSON.parse(template.variables);
} catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message);
return [];
}
})(),
updated_at: template.updated_at
};
// Check which columns exist and use them appropriately
if (template.subject_en !== undefined) {
// New schema with language columns
response.subject_en = template.subject_en;
response.body_html_en = template.body_html_en;
response.body_text_en = template.body_text_en;
response.subject_de = template.subject_de;
response.body_html_de = template.body_html_de;
response.body_text_de = template.body_text_de;
} else {
// Old schema - use basic columns for both languages
response.subject_en = template.subject;
response.body_html_en = template.body_html;
response.body_text_en = template.body_text;
response.subject_de = template.subject;
response.body_html_de = template.body_html;
response.body_text_de = template.body_text;
}
res.json(response);
variables: parseVariables(template),
translations,
updated_at: template.updated_at,
});
} catch (error) {
console.error('Email template fetch error:', error);
res.status(500).json({ error: 'Failed to fetch email template' });
}
});
// Update email template
// Update email template translations
router.put('/templates/:key', [
adminAuth,
requirePermission('email.edit'),
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
body('body_html_de').optional().notEmpty().withMessage('German HTML body cannot be empty')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
subject_en, subject_de,
body_html_en, body_html_de,
body_text_en, body_text_de
} = req.body;
const updateData = {
updated_at: new Date()
};
// Check which columns exist in the database
const template = await db('email_templates')
.where('template_key', req.params.key)
.first();
if (!template) {
return res.status(404).json({ error: 'Template not found' });
}
// Determine schema type and update accordingly
if (template.subject_en !== undefined) {
// New schema with language columns
if (subject_en !== undefined) updateData.subject_en = subject_en;
if (subject_de !== undefined) updateData.subject_de = subject_de;
if (body_html_en !== undefined) updateData.body_html_en = body_html_en;
if (body_html_de !== undefined) updateData.body_html_de = body_html_de;
if (body_text_en !== undefined) updateData.body_text_en = body_text_en || '';
if (body_text_de !== undefined) updateData.body_text_de = body_text_de || '';
// Also update basic columns if they exist
if (template.subject !== undefined) {
updateData.subject = subject_en || updateData.subject_en;
updateData.body_html = body_html_en || updateData.body_html_en;
updateData.body_text = body_text_en || updateData.body_text_en || '';
}
} else {
// Old schema - only update basic columns
if (subject_en !== undefined) {
updateData.subject = subject_en;
updateData.body_html = body_html_en;
updateData.body_text = body_text_en || '';
const { translations } = req.body;
if (!translations || typeof translations !== 'object') {
return res.status(400).json({ error: 'translations object is required' });
}
// Upsert each language translation
for (const [language, data] of Object.entries(translations)) {
if (!data || typeof data !== 'object') continue;
const existing = await db('email_template_translations')
.where({ template_id: template.id, language })
.first();
const row = {
subject: data.subject || '',
body_html: data.body_html || '',
body_text: data.body_text || '',
updated_at: new Date(),
};
if (existing) {
await db('email_template_translations')
.where({ template_id: template.id, language })
.update(row);
} else {
await db('email_template_translations').insert({
template_id: template.id,
language,
...row,
created_at: new Date(),
});
}
}
const updated = await db('email_templates')
.where('template_key', req.params.key)
.update(updateData);
// Update timestamp on parent template
await db('email_templates')
.where('id', template.id)
.update({ updated_at: new Date() });
if (!updated) {
return res.status(404).json({ error: 'Template not found' });
// Also sync legacy columns for backward compatibility
const enData = translations.en;
const deData = translations.de;
const legacyUpdate = { updated_at: new Date() };
const columnInfo = await db('email_templates').columnInfo();
if (enData && columnInfo.subject_en) {
legacyUpdate.subject_en = enData.subject || '';
legacyUpdate.body_html_en = enData.body_html || '';
legacyUpdate.body_text_en = enData.body_text || '';
}
if (deData && columnInfo.subject_de) {
legacyUpdate.subject_de = deData.subject || '';
legacyUpdate.body_html_de = deData.body_html || '';
legacyUpdate.body_text_de = deData.body_text || '';
}
await db('email_templates')
.where('id', template.id)
.update(legacyUpdate);
// Log activity
await logActivity('email_template_updated',
{ template_key: req.params.key },
{ template_key: req.params.key, languages: Object.keys(translations) },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
@@ -438,29 +453,64 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
}
const { preview_data, language = 'en' } = req.body;
// Get the appropriate language version
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
// Handle backward compatibility
let htmlContent = template[htmlField] || template.body_html || '';
let textContent = template[textField] || template.body_text || '';
let subject = template[subjectField] || template.subject || '';
// Get translation from translations table with fallback
let translation = null;
try {
translation = await db('email_template_translations')
.where({ template_id: template.id, language })
.first();
if (!translation && language !== 'en') {
translation = await db('email_template_translations')
.where({ template_id: template.id, language: 'en' })
.first();
}
} catch (e) {
// Fallback to legacy columns
}
let subject = '';
let htmlContent = '';
let textContent = '';
if (translation) {
subject = translation.subject || '';
htmlContent = translation.body_html || '';
textContent = translation.body_text || '';
} else {
// Legacy column fallback
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
subject = template[subjectField] || template.subject || '';
htmlContent = template[htmlField] || template.body_html || '';
textContent = template[textField] || template.body_text || '';
}
if (preview_data) {
const escapeHtml = (str) => String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
Object.keys(preview_data).forEach(key => {
const regex = new RegExp(`{{${key}}}`, 'g');
htmlContent = htmlContent.replace(regex, preview_data[key]);
const escapedValue = escapeHtml(preview_data[key]);
htmlContent = htmlContent.replace(regex, escapedValue);
textContent = textContent.replace(regex, preview_data[key]);
subject = subject.replace(regex, preview_data[key]);
subject = subject.replace(regex, escapeHtml(preview_data[key]));
});
}
// Wrap in the full styled email template with header/footer/logo
const wrappedHtml = await wrapEmailHtml(htmlContent, subject, language);
res.json({
subject,
body_html: htmlContent,
body_html: wrappedHtml,
body_text: textContent,
language
});
+4 -2
View File
@@ -42,7 +42,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
color_theme = null,
expiration_days = 30,
allow_user_uploads = false,
upload_category_id = null
upload_category_id = null,
photo_cap = null
} = req.body;
// Validate password strength for gallery
@@ -105,7 +106,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
allow_user_uploads,
upload_category_id
upload_category_id,
photo_cap: photo_cap || null
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
+588 -125
View File
@@ -20,6 +20,9 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { validateFileType } = require('../utils/fileSecurityUtils');
const { requireEventOwnership } = require('../middleware/ownership');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const downloadZipService = require('../services/downloadZipService');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (value) => {
@@ -110,9 +113,100 @@ const getEventFieldRequirements = async () => {
}
};
// Helper to read app_settings booleans by key, used to inherit per-setting
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
// so callers can fall back to a legacy default.
const readBooleanSetting = async (key) => {
try {
const setting = await db('app_settings').where('setting_key', key).first();
if (!setting) return undefined;
let value = setting.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return typeof value === 'boolean' ? value : undefined;
} catch (error) {
logger.error('Failed to read app setting', { key, error: error.message });
return undefined;
}
};
// Helper to read the global "enable_devtools_protection" admin setting so
// new events inherit it instead of always falling back to the DB column default
// (#317 — admin disabled it globally but new events still got it ON).
const getDownloadProtectionDefaults = async () => {
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
};
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
//
// Note: `branding_logo_position` (header bar — left/center/right) is a
// different concept from `hero_logo_position` (hero block — top/center/
// bottom) and must NOT be mapped here. A previous version copied the
// branding value over, which wrote 'left'/'right' into per-event
// hero_logo_position columns and broke any subsequent PUT validation
// (#357). Migration 084 heals existing rows.
const getBrandingDefaults = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'branding_logo_display_hero',
'branding_logo_size'
])
.select('setting_key', 'setting_value');
const defaults = {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
}
if (s.setting_key === 'branding_logo_display_hero') {
defaults.hero_logo_visible = value !== false;
}
if (s.setting_key === 'branding_logo_size' && value) {
defaults.hero_logo_size = value;
}
});
return defaults;
} catch (error) {
logger.error('Failed to get branding defaults', { error: error.message });
return {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
}
};
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
// Whether the global "phone field" toggle (#322) is enabled. Cached for
// the request via a module-level read; drift is acceptable since this
// only governs whether to persist the field, not security boundaries.
const isPhoneFieldEnabled = async () => {
try {
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
if (!row) return false;
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return value === true;
} catch (error) {
logger.debug('Failed to read event_phone_field_enabled', { error: error.message });
return false;
}
};
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
@@ -124,13 +218,17 @@ const mapEventForApi = (event) => {
host_email,
customer_name,
customer_email,
customer_phone,
password_hash: _ph,
client_password_hash: _cph,
...rest
} = event;
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null
customer_email: customer_email ?? host_email ?? null,
customer_phone: customer_phone ?? null
};
};
@@ -152,6 +250,78 @@ const hasCustomerContactColumns = async () => {
}
};
// Cascade-delete a single event: photos, audit/access logs, queued emails,
// the event row itself (in one transaction), then the on-disk folder /
// archive zip / hero logo (best-effort — file failures don't unwind the DB
// changes since the source of truth is the database). Used by both the
// per-event DELETE /:id route and the bulk-delete route to avoid drift.
//
// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the
// bulk-delete loop can report it as a per-id failure without aborting the
// whole batch. Any other error propagates and is the caller's problem.
async function deleteEventCascade(eventId, adminContext) {
const event = await db('events').where('id', eventId).first();
if (!event) {
const err = new Error('Event not found');
err.code = 'EVENT_NOT_FOUND';
throw err;
}
await db.transaction(async (trx) => {
// 1. Delete activity logs (audit trail)
await trx('activity_logs').where('event_id', eventId).del();
// 2. Delete access logs
await trx('access_logs').where('event_id', eventId).del();
// 3. Delete email queue entries
await trx('email_queue').where('event_id', eventId).del();
// 4. Delete photos (also handles hero_photo_id foreign key)
await trx('photos').where('event_id', eventId).del();
// 5. Finally delete the event row
await trx('events').where('id', eventId).del();
// Best-effort filesystem cleanup. Failures are logged but don't unwind
// the transaction — the canonical state lives in the DB; orphan files
// are recoverable noise, a half-deleted DB row is a permanent mess.
if (event.folder_path) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path);
try {
await fs.rm(eventFolderPath, { recursive: true, force: true });
} catch (fsErr) {
logger.warn('Failed to delete event folder during cascade delete', { eventId, path: eventFolderPath, error: fsErr.message });
}
}
if (event.archive_path) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const archiveFile = path.join(storagePath, event.archive_path);
try {
await fs.unlink(archiveFile);
} catch (fsErr) {
logger.warn('Failed to delete archive file during cascade delete', { eventId, path: archiveFile, error: fsErr.message });
}
}
if (event.hero_logo_path) {
try {
await fs.unlink(event.hero_logo_path);
} catch (fsErr) {
logger.warn('Failed to delete event logo during cascade delete', { eventId, path: event.hero_logo_path, error: fsErr.message });
}
}
});
// Audit trail (outside the transaction so a logging failure can't undo
// the actual delete).
await logActivity('event_deleted',
{ event_name: event.event_name },
null,
{ type: 'admin', id: adminContext.id, name: adminContext.username }
);
return { id: event.id, name: event.event_name };
}
// Create new event
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').notEmpty().trim().custom(async (value) => {
@@ -165,6 +335,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
body('event_date').optional({ values: 'falsy' }).isDate(),
body('customer_name').optional().trim(),
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
body('customer_phone').optional({ nullable: true, checkFalsy: true })
.isString().trim()
.isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'),
body('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
@@ -197,18 +370,30 @@ router.post('/', adminAuth, requirePermission('events.create'), [
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
body('allow_downloads').optional().isBoolean(),
body('disable_right_click').optional().isBoolean(),
body('enable_devtools_protection').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(),
// #328 follow-up: per-event opt-in for presigned-URL "Download All".
// Bypasses watermarks; admin must enable knowingly.
body('allow_presigned_download').optional().isBoolean(),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
// Hero image anchor position (#162) accepts legacy keywords or "X% Y%" focal point
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
// Client access settings (#172)
body('client_access_enabled').optional().isBoolean(),
body('client_password').optional().isString(),
body('default_photo_sort').optional().isIn([
'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc'
])
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
@@ -234,9 +419,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
upload_category_id = null,
allow_downloads = true,
disable_right_click = false,
enable_devtools_protection: enableDevtoolsProtectionInput,
watermark_downloads = false,
watermark_text = null,
require_password: requirePasswordInput = true,
allow_presigned_download = false,
require_password: requirePasswordInput,
// Feedback settings
feedback_enabled = false,
allow_ratings = true,
@@ -256,11 +443,25 @@ router.post('/', adminAuth, requirePermission('events.create'), [
header_style = 'standard',
hero_divider_style = 'wave',
// Hero image anchor position (#162)
hero_image_anchor = 'center'
hero_image_anchor = 'center',
// Photo cap
photo_cap = null,
// Client access settings (#172)
client_access_enabled = false,
client_password = null,
// Draft mode
is_draft = true,
// Default photo sort
default_photo_sort = 'upload_date_desc'
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
const customerEmail = getCustomerEmailFromPayload(req.body);
// Phone field is opt-in via the global setting (#322). If disabled,
// ignore whatever the client posted — defence in depth against form
// bypass.
const phoneEnabled = await isPhoneFieldEnabled();
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
const customerColumnsAvailable = await hasCustomerContactColumns();
@@ -283,7 +484,14 @@ router.post('/', adminAuth, requirePermission('events.create'), [
return res.status(400).json({ errors: validationErrors });
}
const requirePassword = parseBooleanInput(requirePasswordInput, true);
// Default require_password from global "event_default_require_password"
// setting when the body omits it (#317 — admins want to flip the default).
let requirePasswordFallback = true;
if (requirePasswordInput === undefined) {
const setting = await readBooleanSetting('event_default_require_password');
if (setting !== undefined) requirePasswordFallback = setting;
}
const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
// Debug logging
logger.debug('Download control values', {
@@ -385,6 +593,23 @@ router.post('/', adminAuth, requirePermission('events.create'), [
}
}
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
const brandingDefaults = await getBrandingDefaults();
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible;
const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size;
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
// Inherit "Detect dev tools" from the global Image Security setting unless
// the request explicitly overrides it (#317 — admin disabled it globally
// but new events still got it ON because the column default is true).
const protectionDefaults = await getDownloadProtectionDefaults();
const effectiveEnableDevtoolsProtection =
enableDevtoolsProtectionInput !== undefined
? enableDevtoolsProtectionInput
: protectionDefaults.enable_devtools_protection !== undefined
? protectionDefaults.enable_devtools_protection
: true;
// Insert into database
const insertResult = await db('events').insert({
slug,
@@ -392,6 +617,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
event_name,
event_date: event_date || null,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName || null,
host_email: customerEmail || null,
admin_email: admin_email || null,
@@ -407,16 +633,27 @@ router.post('/', adminAuth, requirePermission('events.create'), [
upload_category_id,
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection),
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text,
allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'),
require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null,
hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true),
hero_logo_size: hero_logo_size || 'medium',
hero_logo_position: hero_logo_position || 'top',
hero_logo_visible: formatBoolean(effectiveHeroLogoVisible),
hero_logo_size: effectiveHeroLogoSize,
hero_logo_position: effectiveHeroLogoPosition,
header_style: effectiveHeaderStyle || 'standard',
hero_divider_style: effectiveDividerStyle || 'wave',
hero_image_anchor: hero_image_anchor || 'center'
hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null,
is_draft: formatBoolean(parseBooleanInput(is_draft, true)),
default_photo_sort: default_photo_sort || 'upload_date_desc',
// Client access (#172)
client_access_enabled: formatBoolean(client_access_enabled),
...(client_access_enabled && client_password ? {
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
client_share_token: crypto.randomBytes(32).toString('hex')
} : {})
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -440,37 +677,97 @@ router.post('/', adminAuth, requirePermission('events.create'), [
}
// Log activity
await logActivity('event_created',
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
eventId,
await logActivity('event_created',
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Queue creation email (only if there is a recipient)
// Language detection is handled by email processor
if (customerEmail) {
// Fire event.created webhook (#327). If the event is being published
// immediately (not a draft), event.published also fires below.
// Payload uses canonical event subject (#341) so receivers always see
// the same shape (id/slug/event_name + customer contact + share_*).
try {
const webhookService = require('../services/webhookService');
await webhookService.fire('event.created', {
event: {
...webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
}),
is_draft: parseBooleanInput(is_draft, true),
},
});
} catch (e) { /* webhookService.fire never throws but be defensive */ }
// Queue creation email (only if there is a recipient and event is not a draft)
// Language detection is handled by email processor
const isDraft = parseBooleanInput(is_draft, true);
if (customerEmail && !isDraft) {
// Build email data with optional client access info
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
};
// Include client access info in email when enabled (#172)
if (client_access_enabled && client_password) {
const createdEvent = await db('events').where('id', eventId).first();
const frontendUrl = process.env.FRONTEND_URL || process.env.APP_URL || '';
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
emailData.client_password = client_password;
}
await db('email_queue').insert({
event_id: eventId,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify({
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
}),
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
// scheduled_at will use default value
});
}
// Fire event.published when the event is created NOT as a draft. The
// separate /publish endpoint fires it for the draft → live transition;
// this covers the "create-and-publish in one shot" path.
if (!isDraft) {
try {
const webhookService = require('../services/webhookService');
await webhookService.fire('event.published', {
event: webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
}),
});
} catch (e) { /* non-fatal */ }
}
res.json({
id: eventId,
slug,
@@ -479,6 +776,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
photo_cap: photo_cap || null,
is_draft: isDraft,
share_link: shareUrl,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
@@ -515,6 +814,7 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
query = query.where((builder) => {
builder.where('event_name', 'like', `%${escapedSearch}%`)
.orWhere('admin_email', 'like', `%${escapedSearch}%`)
.orWhere('customer_email', 'like', `%${escapedSearch}%`)
.orWhere('slug', 'like', `%${escapedSearch}%`);
});
}
@@ -526,6 +826,8 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
query = query.where('is_archived', formatBoolean(true));
} else if (status === 'inactive') {
query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false));
} else if (status === 'draft') {
query = query.where('is_draft', formatBoolean(true));
} else if (status === 'expiring') {
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
@@ -650,8 +952,88 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
}
});
// Publish a draft event (set is_draft=false and queue creation email)
router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (!parseBooleanInput(event.is_draft, false)) {
return res.status(400).json({ error: 'Event is already published' });
}
// Set is_draft to false
await db('events').where('id', id).update({ is_draft: formatBoolean(false) });
// Queue creation email
const customerEmail = event.customer_email || event.host_email;
const customerName = event.customer_name || event.host_name;
if (customerEmail) {
const frontendBase = await getFrontendBaseUrl();
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name: event.event_name,
event_date: event.event_date,
gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`,
gallery_password: parseBooleanInput(event.require_password, true) ? '(set at creation)' : 'No password required',
expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
welcome_message: event.welcome_message || ''
};
await db('email_queue').insert({
event_id: id,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
});
}
await logActivity('event_published',
{ event_name: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Fire event.published webhook (#327) — draft → live transition.
// Canonical payload (#341): includes customer contact + share_token.
try {
const webhookService = require('../services/webhookService');
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
await webhookService.fire('event.published', {
event: webhookService.buildEventSubject({
id: parseInt(id, 10),
slug: event.slug,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
share_url: shareUrl,
share_token: event.share_token,
customer_name: event.customer_name || event.host_name,
customer_email: event.customer_email || event.host_email,
customer_phone: event.customer_phone,
}),
});
} catch (e) { /* non-fatal */ }
res.json({ message: 'Event published successfully', is_draft: false });
} catch (error) {
logger.error('Error publishing event:', { error: error.message });
res.status(500).json({ error: 'Failed to publish event' });
}
});
// Update event
router.put('/:id', adminAuth, requirePermission('events.edit'), [
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('event_name').optional().trim().notEmpty(),
body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(),
@@ -661,6 +1043,9 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('allow_user_uploads').optional().isBoolean(),
body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('customer_phone').optional({ nullable: true, checkFalsy: true })
.isString().trim()
.isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'),
body('upload_category_id').optional().custom((value) => {
// Accept null, undefined, or integer values
if (value === null || value === undefined) return true;
@@ -677,6 +1062,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('disable_right_click').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(),
body('allow_presigned_download').optional().isBoolean(),
body('source_mode').optional().isIn(['managed', 'reference']),
body('external_path').optional({ nullable: true }).isString().trim(),
body('require_password').optional().isBoolean(),
@@ -702,10 +1088,19 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
// Hero image anchor position (#162) accepts legacy keywords or "X% Y%" focal point
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
// Client access settings (#172)
body('client_access_enabled').optional().isBoolean(),
body('client_password').optional().isString(),
body('regenerate_client_token').optional().isBoolean(),
body('default_photo_sort').optional().isIn([
'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc'
])
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -750,6 +1145,19 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
}
}
// Phone is gated on the global toggle (#322). Strip from the update
// unconditionally if disabled — even null/clear is rejected so an
// admin can't accidentally write to a field they've turned off.
if (Object.prototype.hasOwnProperty.call(updates, 'customer_phone')) {
const phoneEnabled = await isPhoneFieldEnabled();
if (!phoneEnabled) {
delete updates.customer_phone;
} else {
const nextPhone = getCustomerPhoneFromPayload(updates);
updates.customer_phone = nextPhone || null;
}
}
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
let requirePasswordUpdate;
if (hasRequirePasswordUpdate) {
@@ -784,6 +1192,25 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
}
// Handle client access fields (#172)
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
// Auto-generate client share token when first enabling
if (parseBooleanInput(updates.client_access_enabled, false) && !event.client_share_token) {
updates.client_share_token = crypto.randomBytes(32).toString('hex');
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) {
updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds());
delete updates.client_password;
} else {
delete updates.client_password;
}
if (updates.regenerate_client_token) {
updates.client_share_token = crypto.randomBytes(32).toString('hex');
}
delete updates.regenerate_client_token;
// Log the update request for debugging
logger.debug('Update event request', {
id,
@@ -866,6 +1293,12 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Invalidate download zip if watermark settings changed
const changeKeys = Object.keys(req.body);
if (changeKeys.includes('watermark_downloads') || changeKeys.includes('watermark_text')) {
downloadZipService.invalidate(parseInt(id));
}
res.json({ message: 'Event updated successfully' });
} catch (error) {
console.error('Error updating event:', error);
@@ -874,98 +1307,27 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
});
// Delete event
router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
// Check if event exists
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Start a transaction to ensure all deletions succeed or fail together
await db.transaction(async (trx) => {
// 1. Delete activity logs (audit trail)
await trx('activity_logs').where('event_id', id).del();
// 2. Delete access logs
await trx('access_logs').where('event_id', id).del();
// 3. Delete email queue entries
await trx('email_queue').where('event_id', id).del();
// 4. Delete photos (this will also handle hero_photo_id foreign key)
await trx('photos').where('event_id', id).del();
// 5. Finally delete the event
await trx('events').where('id', id).del();
// Delete event folder from storage if it exists
if (event.folder_path) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path);
try {
const fsPromises = require('fs').promises;
await fsPromises.rm(eventFolderPath, { recursive: true, force: true });
} catch (err) {
console.error('Failed to delete event folder:', err);
// Don't fail the transaction if folder deletion fails
}
}
// Delete archive if exists
if (event.archive_path) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const archivePath = path.join(storagePath, event.archive_path);
try {
const fsPromises = require('fs').promises;
await fsPromises.unlink(archivePath);
} catch (err) {
console.error('Failed to delete archive file:', err);
// Don't fail the transaction if file deletion fails
}
}
// Delete custom event logo if exists
if (event.hero_logo_path) {
try {
const fsPromises = require('fs').promises;
await fsPromises.unlink(event.hero_logo_path);
} catch (err) {
logger.warn('Failed to delete event logo file during event deletion', { path: event.hero_logo_path, error: err.message });
}
}
});
// Log activity (outside transaction)
await logActivity('event_deleted',
{ event_name: event.event_name },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
await deleteEventCascade(id, { id: req.admin.id, username: req.admin.username });
res.json({ message: 'Event deleted successfully' });
} catch (error) {
console.error('Error deleting event:', error);
// Provide more specific error messages
if (error.code === 'EVENT_NOT_FOUND') {
return res.status(404).json({ error: 'Event not found' });
}
logger.error('Error deleting event', { eventId: req.params.id, error: error.message });
if (error.message && error.message.includes('foreign key constraint')) {
res.status(500).json({
return res.status(500).json({
error: 'Cannot delete event due to existing references. Please contact support.'
});
} else {
res.status(500).json({
error: 'Failed to delete event'
});
}
res.status(500).json({ error: 'Failed to delete event' });
}
});
// Toggle event status
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), async (req, res) => {
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
@@ -1005,10 +1367,10 @@ router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), a
});
// Reset event password
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), async (req, res) => {
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const { sendEmail = true } = req.body;
const { sendEmail = true, password: clientPassword } = req.body;
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
@@ -1024,10 +1386,29 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
return res.status(400).json({ error: 'Cannot reset password for archived event' });
}
// Generate new password
const { generateReadablePassword } = require('../utils/passwordGenerator');
const newPassword = generateReadablePassword();
const passwordHash = await bcrypt.hash(newPassword, 10);
// Use the admin-supplied password when provided; otherwise auto-generate
// (preserves the previous one-click behaviour for callers/cron that don't
// pass a body). Validation matches the create-event flow so the same
// strength rules apply both ways.
let newPassword;
if (typeof clientPassword === 'string' && clientPassword.length > 0) {
const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', {
eventName: event.event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
newPassword = clientPassword;
} else {
const { generateReadablePassword } = require('../utils/passwordGenerator');
newPassword = generateReadablePassword();
}
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
// Update event with new password
await db('events')
@@ -1047,6 +1428,9 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
if (sendEmail) {
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
// event.share_link is the path-only form (`/gallery/<slug>/<token>`).
// Use the full URL so customers can click straight from the email.
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
await queueEmail(id, recipientEmail, 'gallery_created', {
customer_name: recipientName,
@@ -1054,13 +1438,13 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link,
gallery_link: shareUrl,
gallery_password: newPassword,
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor
});
}
res.json({
res.json({
message: 'Password reset successfully',
newPassword: newPassword,
emailSent: sendEmail
@@ -1072,7 +1456,7 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
});
// Resend creation email
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), async (req, res) => {
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
@@ -1112,6 +1496,9 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
// Queue the email
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
// event.share_link is the path-only form; use the full URL so the
// customer's mail client renders a clickable absolute link.
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
await queueEmail(id, recipientEmail, 'gallery_created', {
customer_name: recipientName,
@@ -1119,7 +1506,7 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link,
gallery_link: shareUrl,
gallery_password: galleryPassword,
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
welcome_message: event.welcome_message || '',
@@ -1156,7 +1543,7 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
});
// Archive event
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), async (req, res) => {
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
@@ -1265,8 +1652,84 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
}
});
// Bulk delete — destructive, irreversible. Requires the calling admin to
// re-enter their password as a confirmation gate (verified against the
// stored bcrypt hash, same pattern as /auth/admin/change-password). Caps at
// 100 events per request to keep request time bounded; the per-event
// cascade touches 5 DB tables + 3 filesystem paths so 1000 events would
// risk timing out the request. Loops via deleteEventCascade so the per-
// event delete behaviour stays in lock-step with DELETE /:id.
const BULK_DELETE_MAX = 100;
router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [
body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`),
body('eventIds.*').isInt().withMessage('Each eventId must be an integer'),
body('password').isString().notEmpty().withMessage('Password is required for confirmation')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { eventIds, password } = req.body;
// Verify the admin's password before doing anything destructive.
// Same pattern as /auth/admin/change-password (auth.js).
const admin = await db('admin_users').where({ id: req.admin.id }).first();
if (!admin) {
return res.status(401).json({ error: 'Authentication required' });
}
const validPassword = await bcrypt.compare(password, admin.password_hash);
if (!validPassword) {
logger.warn('Incorrect password on bulk-delete attempt', { adminId: req.admin.id, eventCount: eventIds.length });
return res.status(401).json({ error: 'Incorrect password', code: 'INVALID_PASSWORD' });
}
// Editor-role events.delete permission is already gated by the route
// middleware. We do NOT additionally filter to created_by here because
// the per-event delete-cascade is global (matches DELETE /:id which
// also has no role-based filter — that's why events.delete is a
// sensitive permission).
const results = { successful: [], failed: [] };
const adminContext = { id: req.admin.id, username: req.admin.username };
for (const eventId of eventIds) {
try {
const deleted = await deleteEventCascade(eventId, adminContext);
results.successful.push(deleted);
} catch (err) {
results.failed.push({
id: eventId,
name: null,
error: err.code === 'EVENT_NOT_FOUND' ? 'Event not found' : 'Failed to delete event'
});
logger.warn('Bulk-delete: per-event failure', { eventId, error: err.message });
}
}
await logActivity('bulk_delete_completed',
{
totalEvents: eventIds.length,
successfulCount: results.successful.length,
failedCount: results.failed.length
},
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: `Bulk delete completed: ${results.successful.length} succeeded, ${results.failed.length} failed`,
results
});
} catch (error) {
logger.error('Error in bulk delete', { error: error.message });
res.status(500).json({ error: 'Failed to perform bulk delete' });
}
});
// Upload event custom logo
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), eventLogoUpload.single('logo'), async (req, res) => {
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => {
try {
const { id } = req.params;
@@ -1321,7 +1784,7 @@ router.post('/:id/logo', adminAuth, requirePermission('events.edit'), eventLogoU
});
// Delete event custom logo
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), async (req, res) => {
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
+15
View File
@@ -5,6 +5,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
const { db, logActivity } = require('../database/db');
const sharp = require('sharp');
const logger = require('../utils/logger');
const router = express.Router();
@@ -108,6 +109,18 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
.first();
if (exists) { skipped++; continue; }
const stats = await fs.stat(f.full);
// Extract dimensions via Sharp
let width = null;
let height = null;
try {
const metadata = await sharp(f.full).metadata();
width = metadata.width || null;
height = metadata.height || null;
} catch (dimErr) {
logger.warn(`Could not extract dimensions for ${f.rel}: ${dimErr.message}`);
}
const inserted = await db('photos')
.insert({
event_id: eventId,
@@ -117,6 +130,8 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
thumbnail_path: null,
type,
size_bytes: stats.size,
width,
height,
source_origin: 'external',
external_relpath: f.rel
})
+6
View File
@@ -12,11 +12,13 @@ const {
validateWordFilter,
checkValidation
} = require('../utils/feedbackValidation');
const { requireEventOwnership } = require('../middleware/ownership');
// Get event feedback settings
router.get('/events/:eventId/feedback-settings',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
validateEventId,
checkValidation,
async (req, res) => {
@@ -42,6 +44,7 @@ router.get('/events/:eventId/feedback-settings',
router.put('/events/:eventId/feedback-settings',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
validateEventId,
validateFeedbackSettings,
checkValidation,
@@ -79,6 +82,7 @@ router.put('/events/:eventId/feedback-settings',
router.get('/events/:eventId/feedback',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
validateEventId,
checkValidation,
async (req, res) => {
@@ -204,6 +208,7 @@ router.delete('/feedback/:feedbackId',
router.get('/events/:eventId/feedback-analytics',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
validateEventId,
checkValidation,
async (req, res) => {
@@ -304,6 +309,7 @@ router.get('/events/:eventId/feedback-analytics',
router.get('/events/:eventId/feedback/export',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
validateEventId,
checkValidation,
async (req, res) => {
+609
View File
@@ -0,0 +1,609 @@
const express = require('express');
const crypto = require('crypto');
const archiver = require('archiver');
const router = express.Router();
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const feedbackService = require('../services/feedbackService');
const logger = require('../utils/logger');
const FRONTEND_URL = process.env.FRONTEND_URL || '';
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
async function loadGuestOr404(eventId, guestId, res) {
const guest = await db('gallery_guests')
.where({ id: guestId, event_id: eventId, is_deleted: false })
.first();
if (!guest) {
res.status(404).json({ error: 'Guest not found' });
return null;
}
return guest;
}
function serializeGuest(row) {
return {
id: row.id,
name: row.name,
email: row.email,
created_at: row.created_at,
last_seen_at: row.last_seen_at,
email_verified_at: row.email_verified_at,
is_deleted: row.is_deleted,
};
}
function escapeCsvCell(value) {
const str = value == null ? '' : String(value);
if (/[,"\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests — list guests with aggregated counts
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const rows = await db('gallery_guests')
.leftJoin('photo_feedback', function () {
this.on('photo_feedback.guest_id', '=', 'gallery_guests.id');
})
.where('gallery_guests.event_id', eventId)
.where('gallery_guests.is_deleted', false)
.groupBy('gallery_guests.id')
.select(
'gallery_guests.id',
'gallery_guests.name',
'gallery_guests.email',
'gallery_guests.created_at',
'gallery_guests.last_seen_at',
'gallery_guests.email_verified_at',
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'like' THEN 1 END) AS likes"),
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'favorite' THEN 1 END) AS favorites"),
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'comment' THEN 1 END) AS comments"),
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'rating' THEN 1 END) AS ratings"),
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
)
.orderBy('gallery_guests.created_at', 'desc');
const guests = rows.map((r) => ({
...serializeGuest(r),
stats: {
likes: parseInt(r.likes, 10) || 0,
favorites: parseInt(r.favorites, 10) || 0,
comments: parseInt(r.comments, 10) || 0,
ratings: parseInt(r.ratings, 10) || 0,
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
},
}));
res.json({ guests });
} catch (error) {
logger.error('Error listing guests:', error);
res.status(500).json({ error: 'Failed to list guests' });
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/aggregate — photos sorted by distinct
// guest pick count (Phase 2 aggregate view)
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/aggregate',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const photos = await db('photos')
.leftJoin('photo_feedback', function () {
this.on('photo_feedback.photo_id', '=', 'photos.id')
.andOn(db.raw("photo_feedback.feedback_type IN ('like','favorite')"))
.andOnNotNull('photo_feedback.guest_id');
})
.where('photos.event_id', eventId)
.groupBy('photos.id')
.select(
'photos.id',
'photos.filename',
'photos.original_filename',
db.raw('COUNT(DISTINCT photo_feedback.guest_id) AS picker_count')
)
.orderBy('picker_count', 'desc')
.orderBy('photos.id', 'desc');
res.json({
photos: photos
.filter((p) => parseInt(p.picker_count, 10) > 0)
.map((p) => ({
id: p.id,
filename: p.filename,
original_filename: p.original_filename,
url: `/api/admin/photos/${eventId}/photo/${p.id}`,
thumbnail_url: `/api/admin/photos/${eventId}/thumbnail/${p.id}`,
picker_count: parseInt(p.picker_count, 10),
})),
});
} catch (error) {
logger.error('Error fetching aggregate view:', error);
res.status(500).json({ error: 'Failed to fetch aggregate view' });
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/invites — list pre-minted invites
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/invites',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const event = await db('events').where({ id: eventId }).first();
const rows = await db('guest_invites')
.leftJoin('gallery_guests', 'gallery_guests.id', 'guest_invites.guest_id')
.where('guest_invites.event_id', eventId)
.select(
'guest_invites.id',
'guest_invites.token',
'guest_invites.created_at',
'guest_invites.redeemed_at',
'guest_invites.revoked_at',
'gallery_guests.id as guest_id',
'gallery_guests.name as guest_name',
'gallery_guests.email as guest_email'
)
.orderBy('guest_invites.created_at', 'desc');
const invites = rows.map((r) => ({
id: r.id,
token: r.token,
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${r.token}`,
created_at: r.created_at,
redeemed_at: r.redeemed_at,
revoked_at: r.revoked_at,
status: r.revoked_at ? 'revoked' : r.redeemed_at ? 'redeemed' : 'pending',
guest: {
id: r.guest_id,
name: r.guest_name,
email: r.guest_email,
},
}));
res.json({ invites });
} catch (error) {
logger.error('Error listing invites:', error);
res.status(500).json({ error: 'Failed to list invites' });
}
}
);
// ----------------------------------------------------------------------------
// POST /admin/events/:eventId/guests/invites — create guest + invite
// Body: { name, email? }
// ----------------------------------------------------------------------------
router.post(
'/events/:eventId/guests/invites',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const name = String(req.body?.name || '').trim().slice(0, 100);
const email = String(req.body?.email || '').trim().slice(0, 255).toLowerCase();
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const identifier = crypto.randomUUID();
const inviteToken = crypto.randomBytes(24).toString('hex');
let guestId;
let inviteId;
await db.transaction(async (trx) => {
const [guestRow] = await trx('gallery_guests')
.insert({
event_id: eventId,
name,
email: email || null,
identifier,
})
.returning(['id']);
guestId = guestRow.id;
const [inviteRow] = await trx('guest_invites')
.insert({
event_id: eventId,
guest_id: guestId,
token: inviteToken,
created_by_admin_id: req.admin.id,
})
.returning(['id']);
inviteId = inviteRow.id;
});
await logActivity(
'guest_invite_created',
{ event_id: eventId, guest_id: guestId, invite_id: inviteId },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
const event = await db('events').where({ id: eventId }).first();
res.json({
invite: {
id: inviteId,
token: inviteToken,
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${inviteToken}`,
status: 'pending',
guest: { id: guestId, name, email: email || null },
},
});
} catch (error) {
logger.error('Error creating invite:', error);
res.status(500).json({ error: 'Failed to create invite' });
}
}
);
// ----------------------------------------------------------------------------
// DELETE /admin/events/:eventId/guests/invites/:inviteId — revoke
// ----------------------------------------------------------------------------
router.delete(
'/events/:eventId/guests/invites/:inviteId',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, inviteId } = req.params;
const updated = await db('guest_invites')
.where({ id: inviteId, event_id: eventId })
.whereNull('revoked_at')
.update({ revoked_at: db.fn.now() });
if (!updated) {
return res.status(404).json({ error: 'Invite not found or already revoked' });
}
await logActivity(
'guest_invite_revoked',
{ event_id: eventId, invite_id: inviteId },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ success: true });
} catch (error) {
logger.error('Error revoking invite:', error);
res.status(500).json({ error: 'Failed to revoke invite' });
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/export-all — ZIP of per-guest exports
// Query: format=txt|csv|json (default: csv)
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/export-all',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'csv';
const guests = await db('gallery_guests')
.where({ event_id: eventId, is_deleted: false })
.select('id', 'name', 'email');
if (guests.length === 0) {
return res.status(404).json({ error: 'No guests to export' });
}
res.setHeader('Content-Type', 'application/zip');
res.setHeader(
'Content-Disposition',
`attachment; filename="event-${eventId}-guests.zip"`
);
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('error', (err) => {
logger.error('Archive error:', err);
res.status(500).end();
});
archive.pipe(res);
for (const g of guests) {
const selections = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.guest_id', g.id)
.whereIn('photo_feedback.feedback_type', ['like', 'favorite'])
.select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type');
const safeName = g.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${g.id}`;
const filename = `${safeName}.${format}`;
let body;
if (format === 'json') {
body = JSON.stringify({ guest: g, selections }, null, 2);
} else if (format === 'csv') {
const header = 'filename,original_filename,feedback_type';
const rows = selections.map(
(s) =>
`${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}`
);
body = [header, ...rows].join('\n');
} else {
// txt — just filenames
body = selections.map((s) => s.original_filename || s.filename).join('\n');
}
archive.append(body, { name: filename });
}
await archive.finalize();
} catch (error) {
logger.error('Error exporting all guests:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to export guests' });
}
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/:guestId — guest detail with selections
// (Phase 2)
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/:guestId',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, guestId } = req.params;
const guest = await loadGuestOr404(eventId, guestId, res);
if (!guest) return;
const feedback = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.guest_id', guestId)
.select(
'photo_feedback.id as feedback_id',
'photo_feedback.feedback_type',
'photo_feedback.rating',
'photo_feedback.comment_text',
'photo_feedback.created_at',
'photos.id as photo_id',
'photos.filename',
'photos.original_filename',
'photos.type'
)
.orderBy('photo_feedback.created_at', 'desc');
const photoFor = (row) => ({
id: row.photo_id,
filename: row.filename,
original_filename: row.original_filename,
type: row.type,
url: `/api/admin/photos/${eventId}/photo/${row.photo_id}`,
thumbnail_url: `/api/admin/photos/${eventId}/thumbnail/${row.photo_id}`,
});
const selections = {
liked: [],
favorited: [],
rated: [],
commented: [],
};
for (const row of feedback) {
if (row.feedback_type === 'like') {
selections.liked.push(photoFor(row));
} else if (row.feedback_type === 'favorite') {
selections.favorited.push(photoFor(row));
} else if (row.feedback_type === 'rating') {
selections.rated.push({ photo: photoFor(row), rating: row.rating });
} else if (row.feedback_type === 'comment') {
selections.commented.push({
photo: photoFor(row),
comment: row.comment_text,
created_at: row.created_at,
});
}
}
res.json({
guest: {
...serializeGuest(guest),
stats: {
likes: selections.liked.length,
favorites: selections.favorited.length,
comments: selections.commented.length,
ratings: selections.rated.length,
},
},
selections,
});
} catch (error) {
logger.error('Error fetching guest detail:', error);
res.status(500).json({ error: 'Failed to fetch guest detail' });
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/:guestId/export — per-guest export
// Query: format=txt|csv|json
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/:guestId/export',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, guestId } = req.params;
const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'txt';
const guest = await loadGuestOr404(eventId, guestId, res);
if (!guest) return;
const selections = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.guest_id', guestId)
.whereIn('photo_feedback.feedback_type', ['like', 'favorite'])
.select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type');
const safeName = guest.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${guest.id}`;
const filename = `${safeName}.${format}`;
if (format === 'json') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
return res.send(JSON.stringify({ guest: serializeGuest(guest), selections }, null, 2));
}
if (format === 'csv') {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
const header = 'filename,original_filename,feedback_type';
const rows = selections.map(
(s) =>
`${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}`
);
return res.send([header, ...rows].join('\n'));
}
// txt — one filename per line
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
return res.send(selections.map((s) => s.original_filename || s.filename).join('\n'));
} catch (error) {
logger.error('Error exporting guest:', error);
res.status(500).json({ error: 'Failed to export guest' });
}
}
);
// ----------------------------------------------------------------------------
// DELETE /admin/events/:eventId/guests/:guestId — anonymize (soft delete)
// ----------------------------------------------------------------------------
router.delete(
'/events/:eventId/guests/:guestId',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, guestId } = req.params;
const guest = await loadGuestOr404(eventId, guestId, res);
if (!guest) return;
const result = await feedbackService.anonymizeGuestFeedback(guestId);
await db('gallery_guests').where({ id: guestId }).update({
is_deleted: true,
name: 'Removed',
email: null,
last_seen_at: db.fn.now(),
});
await logActivity(
'guest_deleted',
{ event_id: eventId, guest_id: guestId, anonymized: result.anonymized },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ success: true, ...result });
} catch (error) {
logger.error('Error deleting guest:', error);
res.status(500).json({ error: 'Failed to delete guest' });
}
}
);
// ----------------------------------------------------------------------------
// POST /admin/events/:eventId/guests/:keepId/merge — merge guests (Phase 3.4)
// Body: { mergeIds: number[] }
// ----------------------------------------------------------------------------
router.post(
'/events/:eventId/guests/:keepId/merge',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, keepId } = req.params;
const mergeIds = Array.isArray(req.body?.mergeIds) ? req.body.mergeIds : [];
if (mergeIds.length === 0) {
return res.status(400).json({ error: 'mergeIds is required' });
}
if (mergeIds.includes(Number(keepId))) {
return res.status(400).json({ error: 'Cannot merge a guest into itself' });
}
// Sanity check: all guests belong to this event.
const all = await db('gallery_guests')
.whereIn('id', [Number(keepId), ...mergeIds.map(Number)])
.where({ event_id: eventId });
if (all.length !== mergeIds.length + 1) {
return res.status(400).json({ error: 'All guests must belong to the same event' });
}
const result = await feedbackService.mergeGuestFeedback(Number(keepId), mergeIds.map(Number));
// Soft-delete the merged (source) guests.
await db('gallery_guests')
.whereIn('id', mergeIds.map(Number))
.update({ is_deleted: true, last_seen_at: db.fn.now() });
await logActivity(
'guest_merged',
{ event_id: eventId, keep_id: keepId, merged_ids: mergeIds },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ success: true, ...result });
} catch (error) {
logger.error('Error merging guests:', error);
res.status(500).json({ error: 'Failed to merge guests' });
}
}
);
module.exports = router;
+16 -12
View File
@@ -3,11 +3,10 @@ const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const { resolvePhotoFilePath } = require('../services/photoResolver');
// Module-level progress state
let repairProgress = {
@@ -23,13 +22,18 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
}
const photos = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.where(function () {
this.whereNull('width').orWhereNull('height');
this.whereNull('photos.width').orWhereNull('photos.height');
})
.where(function () {
this.where('media_type', '!=', 'video').orWhereNull('media_type');
this.where('photos.media_type', '!=', 'video').orWhereNull('photos.media_type');
})
.select('id', 'path', 'filename');
.select(
'photos.id', 'photos.path', 'photos.filename',
'photos.source_origin', 'photos.external_relpath', 'photos.event_id',
'events.source_mode', 'events.external_path', 'events.slug'
);
if (photos.length === 0) {
return res.json({ message: 'No photos need dimension repair', count: 0 });
@@ -61,15 +65,16 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
for (const photo of photos) {
try {
if (!photo.path) {
logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`);
const event = { source_mode: photo.source_mode, external_path: photo.external_path, slug: photo.slug };
let fullPath;
try {
fullPath = resolvePhotoFilePath(event, photo);
} catch (err) {
logger.warn(`Photo ${photo.id} has no resolvable path, skipping dimension repair: ${err.message}`);
errorCount++;
continue;
}
const storagePath = getStoragePath();
const fullPath = path.join(storagePath, 'events/active', photo.path);
try {
await fs.access(fullPath);
} catch (err) {
@@ -85,8 +90,7 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
.where({ id: photo.id })
.update({
width: metadata.width,
height: metadata.height,
updated_at: db.fn.now()
height: metadata.height
});
successCount++;
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -219,9 +219,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_header,
logo_display_hero,
logo_display_mode,
hide_powered_by
hide_powered_by,
force_color_mode
} = req.body;
// Normalize force_color_mode: only 'dark' | 'light' | null are valid.
const normalizedForceColorMode = force_color_mode === 'dark'
? 'dark'
: force_color_mode === 'light'
? 'light'
: null;
// Get current watermark settings hash for change detection
const oldSettingsHash = await watermarkService.getSettingsHash();
@@ -243,7 +251,8 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_header,
logo_display_hero,
logo_display_mode,
hide_powered_by
hide_powered_by,
force_color_mode: normalizedForceColorMode
};
// Handle favicon deletion if empty string or null is provided
+10 -3
View File
@@ -114,7 +114,8 @@ router.post('/invite', [
const invitation = await userManagementService.createInvitation({
email: req.body.email,
roleId: req.body.role_id,
invitedById: req.admin.id
invitedById: req.admin.id,
inviterRoleName: req.admin.roleName
});
successResponse(res, { invitation }, 201);
@@ -146,7 +147,12 @@ router.get('/:id', [
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
], handleAsync(async (req, res) => {
validateRequest(req);
const user = await userManagementService.getAdminUserById(parseInt(req.params.id));
const targetId = parseInt(req.params.id);
// Non-super_admin users can only view their own profile
if (req.admin.roleName !== 'super_admin' && targetId !== req.admin.id) {
return res.status(403).json({ error: 'Access denied' });
}
const user = await userManagementService.getAdminUserById(targetId);
res.json({ user: transformUser(user) });
}));
@@ -169,7 +175,8 @@ router.put('/:id', [
const user = await userManagementService.updateAdminUser(
parseInt(req.params.id),
req.body,
req.admin.id
req.admin.id,
{ roleName: req.admin.roleName }
);
successResponse(res, { user: transformUser(user), message: 'User updated successfully' });
+389
View File
@@ -0,0 +1,389 @@
/**
* Admin endpoints for managing outbound webhooks (#327). Mirrors
* adminApiTokens.js same permission gates, same "secret shown once"
* pattern.
*
* Routes mounted under /api/admin/webhooks:
* GET / list
* POST / create (returns plaintext secret once)
* GET /:id detail (no secret)
* PUT /:id update name/url/events/active
* DELETE /:id delete (cascades to deliveries)
* POST /:id/test fire a synthetic delivery now
* GET /:id/deliveries list deliveries (paginated, filter)
* GET /:id/deliveries/:deliveryId delivery detail (payload+response)
* POST /:id/deliveries/:deliveryId/replay re-enqueue a delivery
*/
const express = require('express');
const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { validateExternalUrl } = require('../utils/networkValidation');
const webhookService = require('../services/webhookService');
const logger = require('../utils/logger');
const router = express.Router();
const ALLOW_PRIVATE_URLS = process.env.WEBHOOK_ALLOW_PRIVATE_URLS === 'true';
function publicWebhook(row) {
if (!row) return null;
return {
id: row.id,
name: row.name,
url: row.url,
events: typeof row.events === 'string' ? safeJson(row.events, []) : (row.events || []),
active: row.active,
secret_preview: row.secret_preview,
filter: typeof row.filter === 'string' ? safeJson(row.filter, {}) : (row.filter || {}),
template: row.template || null,
created_by: row.created_by,
created_at: row.created_at,
updated_at: row.updated_at,
last_success_at: row.last_success_at,
last_failure_at: row.last_failure_at,
};
}
function safeJson(s, fallback) {
try { return JSON.parse(s); } catch { return fallback; }
}
// ─── List ────────────────────────────────────────────────────────────────
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const rows = await db('webhooks')
.leftJoin('admin_users', 'admin_users.id', 'webhooks.created_by')
.select(
'webhooks.*',
'admin_users.username as owner_username'
)
.orderBy('webhooks.created_at', 'desc');
res.json(rows.map((r) => ({
...publicWebhook(r),
owner_username: r.owner_username,
})));
} catch (err) {
logger.error('webhooks list failed', { error: err.message });
res.status(500).json({ error: 'Failed to list webhooks' });
}
});
// ─── Create ──────────────────────────────────────────────────────────────
router.post(
'/',
adminAuth,
requirePermission('settings.edit'),
[
body('name').isString().trim().isLength({ min: 1, max: 100 }),
body('url').isString().isLength({ max: 2048 }).custom((url) => {
if (ALLOW_PRIVATE_URLS) return true;
const check = validateExternalUrl(url);
if (!check.valid) throw new Error(check.error);
return true;
}),
body('events').isArray({ min: 1 }).custom((arr) => {
const ok = arr.every((e) => webhookService.EVENT_TYPES.includes(e));
if (!ok) throw new Error(`events must be a subset of: ${webhookService.EVENT_TYPES.join(', ')}`);
return true;
}),
body('active').optional().isBoolean(),
body('filter').optional().custom((v) => {
if (v == null) return true;
if (typeof v !== 'object' || Array.isArray(v)) {
throw new Error('filter must be an object of dot-path → value pairs');
}
return true;
}),
body('template').optional({ nullable: true }).custom((v) => {
const check = webhookService.validateTemplate(v);
if (!check.valid) throw new Error(check.error);
return true;
}),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const { name, url, events, active = true, filter, template } = req.body;
const { plaintext, preview } = webhookService.generateSecret();
const insertResult = await db('webhooks').insert({
name,
url,
secret: plaintext,
secret_preview: preview,
events: JSON.stringify(events),
active,
filter: JSON.stringify(filter || {}),
template: template || null,
created_by: req.admin.id,
}).returning('id');
const id = insertResult[0]?.id || insertResult[0];
await logActivity('webhook_created', { name, events }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username,
});
const row = await db('webhooks').where({ id }).first();
res.status(201).json({
...publicWebhook(row),
secret: plaintext,
notice: 'Save this signing secret now — it will not be shown again.',
});
} catch (err) {
logger.error('webhooks create failed', { error: err.message });
res.status(500).json({ error: 'Failed to create webhook' });
}
}
);
// ─── Detail ──────────────────────────────────────────────────────────────
router.get('/:id', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
res.json(publicWebhook(row));
} catch (err) {
logger.error('webhooks detail failed', { error: err.message });
res.status(500).json({ error: 'Failed to load webhook' });
}
});
// ─── Update ──────────────────────────────────────────────────────────────
router.put(
'/:id',
adminAuth,
requirePermission('settings.edit'),
[
body('name').optional().isString().trim().isLength({ min: 1, max: 100 }),
body('url').optional().isString().isLength({ max: 2048 }).custom((url) => {
if (ALLOW_PRIVATE_URLS) return true;
const check = validateExternalUrl(url);
if (!check.valid) throw new Error(check.error);
return true;
}),
body('events').optional().isArray({ min: 1 }).custom((arr) => {
const ok = arr.every((e) => webhookService.EVENT_TYPES.includes(e));
if (!ok) throw new Error(`events must be a subset of: ${webhookService.EVENT_TYPES.join(', ')}`);
return true;
}),
body('active').optional().isBoolean(),
body('filter').optional().custom((v) => {
if (v == null) return true;
if (typeof v !== 'object' || Array.isArray(v)) {
throw new Error('filter must be an object of dot-path → value pairs');
}
return true;
}),
body('template').optional({ nullable: true }).custom((v) => {
const check = webhookService.validateTemplate(v);
if (!check.valid) throw new Error(check.error);
return true;
}),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
const updates = { updated_at: new Date() };
if ('name' in req.body) updates.name = req.body.name;
if ('url' in req.body) updates.url = req.body.url;
if ('events' in req.body) updates.events = JSON.stringify(req.body.events);
if ('active' in req.body) updates.active = req.body.active;
if ('filter' in req.body) updates.filter = JSON.stringify(req.body.filter || {});
if ('template' in req.body) updates.template = req.body.template || null;
await db('webhooks').where({ id: req.params.id }).update(updates);
const updated = await db('webhooks').where({ id: req.params.id }).first();
await logActivity('webhook_updated', { changes: Object.keys(updates) }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username,
});
res.json(publicWebhook(updated));
} catch (err) {
logger.error('webhooks update failed', { error: err.message });
res.status(500).json({ error: 'Failed to update webhook' });
}
}
);
// ─── Delete ──────────────────────────────────────────────────────────────
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
await db('webhooks').where({ id: req.params.id }).delete();
await logActivity('webhook_deleted', { name: row.name }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username,
});
res.json({ id: Number(req.params.id), deleted: true });
} catch (err) {
logger.error('webhooks delete failed', { error: err.message });
res.status(500).json({ error: 'Failed to delete webhook' });
}
});
// ─── Send test event ─────────────────────────────────────────────────────
router.post(
'/:id/test',
adminAuth,
requirePermission('settings.edit'),
[body('event_type').optional().isIn(webhookService.EVENT_TYPES)],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
if (!row.active) return res.status(400).json({ error: 'Webhook is disabled' });
const eventType = req.body.event_type || (() => {
const subscribed = typeof row.events === 'string' ? safeJson(row.events, []) : (row.events || []);
return subscribed[0] || 'event.published';
})();
// Fire a synthetic event WITHOUT writing to webhooks table — the test
// bypasses subscription matching by inserting a delivery directly.
const crypto = require('crypto');
const deliveryId = crypto.randomUUID();
const payload = {
id: deliveryId,
type: eventType,
created_at: new Date().toISOString(),
data: { test: true, fired_by: req.admin.username, webhook_id: row.id },
};
await db('webhook_deliveries').insert({
webhook_id: row.id,
event_type: eventType,
payload: JSON.stringify(payload),
attempt_count: 0,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
});
res.status(202).json({ enqueued: true, event_type: eventType });
} catch (err) {
logger.error('webhook test failed', { error: err.message });
res.status(500).json({ error: 'Failed to enqueue test event' });
}
}
);
// ─── List deliveries ─────────────────────────────────────────────────────
router.get(
'/:id/deliveries',
adminAuth,
requirePermission('settings.view'),
[
query('status').optional().isIn(['pending', 'success', 'failed']),
query('page').optional().isInt({ min: 1 }),
query('limit').optional().isInt({ min: 1, max: 100 }),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const webhookId = req.params.id;
const exists = await db('webhooks').where({ id: webhookId }).first();
if (!exists) return res.status(404).json({ error: 'Webhook not found' });
const page = parseInt(req.query.page || '1', 10);
const limit = parseInt(req.query.limit || '25', 10);
const offset = (page - 1) * limit;
let q = db('webhook_deliveries').where({ webhook_id: webhookId });
if (req.query.status) q = q.where({ status: req.query.status });
const totalRow = await q.clone().count('id as count').first();
const total = parseInt(totalRow?.count || 0, 10);
const rows = await q
.select(
'id', 'event_type', 'attempt_count', 'status', 'response_status',
'latency_ms', 'next_retry_at', 'created_at', 'completed_at', 'last_error'
)
.orderBy('created_at', 'desc')
.limit(limit)
.offset(offset);
res.json({ deliveries: rows, pagination: { page, limit, total } });
} catch (err) {
logger.error('deliveries list failed', { error: err.message });
res.status(500).json({ error: 'Failed to list deliveries' });
}
}
);
// ─── Delivery detail ─────────────────────────────────────────────────────
router.get(
'/:id/deliveries/:deliveryId',
adminAuth,
requirePermission('settings.view'),
async (req, res) => {
try {
const row = await db('webhook_deliveries')
.where({ id: req.params.deliveryId, webhook_id: req.params.id })
.first();
if (!row) return res.status(404).json({ error: 'Delivery not found' });
res.json({
...row,
payload: typeof row.payload === 'string' ? safeJson(row.payload, row.payload) : row.payload,
});
} catch (err) {
logger.error('delivery detail failed', { error: err.message });
res.status(500).json({ error: 'Failed to load delivery' });
}
}
);
// ─── Replay ──────────────────────────────────────────────────────────────
router.post(
'/:id/deliveries/:deliveryId/replay',
adminAuth,
requirePermission('settings.edit'),
async (req, res) => {
try {
const row = await db('webhook_deliveries')
.where({ id: req.params.deliveryId, webhook_id: req.params.id })
.first();
if (!row) return res.status(404).json({ error: 'Delivery not found' });
// Re-enqueue: copy the original payload + event_type into a new row
// marked pending. Preserves the audit log of the original attempt.
const crypto = require('crypto');
const newPayload = (() => {
const obj = typeof row.payload === 'string' ? safeJson(row.payload, {}) : row.payload || {};
// Replays get a fresh delivery id but keep the event payload data.
return JSON.stringify({ ...obj, id: crypto.randomUUID(), replayed_from: row.id });
})();
const insertResult = await db('webhook_deliveries').insert({
webhook_id: row.webhook_id,
event_type: row.event_type,
payload: newPayload,
attempt_count: 0,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
}).returning('id');
const newId = insertResult[0]?.id || insertResult[0];
res.status(202).json({ enqueued: true, original_id: row.id, replay_id: newId });
} catch (err) {
logger.error('delivery replay failed', { error: err.message });
res.status(500).json({ error: 'Failed to replay delivery' });
}
}
);
module.exports = router;
+194 -10
View File
@@ -13,6 +13,7 @@ const {
getGenericAuthError
} = require('../utils/authSecurity');
const { endSession } = require('../middleware/sessionTimeout');
const { revokeToken } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const {
setAdminAuthCookie,
@@ -117,9 +118,8 @@ router.post('/admin/login', [
setAdminAuthCookie(res, token);
// Include role in response
// Token is delivered via HttpOnly cookie only (not in response body)
res.json({
token,
user: {
id: admin.id,
username: admin.username,
@@ -145,7 +145,8 @@ router.post('/logout', async (req, res) => {
const token = adminToken || galleryToken;
if (token) {
// End the session
// Revoke the token so it can't be reused, then end the session
await revokeToken(token, 'user_logout');
endSession(token);
try {
@@ -198,6 +199,8 @@ router.post('/gallery/verify', [
.first();
if (!event) {
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
await bcrypt.compare(password || '', '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234');
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
@@ -280,7 +283,8 @@ router.post('/gallery/verify', [
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
require_password: requiresPassword,
photo_cap: event.photo_cap
}
});
} catch (error) {
@@ -289,6 +293,82 @@ router.post('/gallery/verify', [
}
});
// Client access login (PIN-based)
router.post('/gallery/:slug/client-login', [
body('password').notEmpty().isString()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug } = req.params;
const { password } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event || !event.client_access_enabled || !event.client_password_hash) {
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid credentials' });
}
const lockoutStatus = await checkAccountLockout(`client:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
const validPassword = await bcrypt.compare(password, event.client_password_hash);
if (!validPassword) {
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid credentials' });
}
await trackSuccessfulLogin(`client:${slug}`, ipAddress, userAgent);
const token = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
accessLevel: 'client',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setGalleryAuthCookies(res, token, event.slug);
res.json({
token,
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: true
},
accessLevel: 'client'
});
} catch (error) {
logger.error('Client login error:', error);
res.status(500).json({ error: 'Authentication failed' });
}
});
// Share link authentication (token-based)
router.post('/gallery/share-login', [
body('slug').notEmpty().trim(),
@@ -304,6 +384,17 @@ router.post('/gallery/share-login', [
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Rate limit share-link login attempts
const shareIdentifier = `gallery:${slug}:share`;
const lockoutStatus = await checkAccountLockout(shareIdentifier, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Share link login attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
let event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
@@ -316,12 +407,14 @@ router.post('/gallery/share-login', [
}
if (!event) {
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
return res.status(404).json({ error: 'Gallery not found' });
}
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid or expired share link' });
}
@@ -353,7 +446,8 @@ router.post('/gallery/share-login', [
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
require_password: requiresPassword,
photo_cap: event.photo_cap
}
});
} catch (error) {
@@ -362,10 +456,14 @@ router.post('/gallery/share-login', [
}
});
// Gallery logout to clear cookies
// Gallery logout to clear cookies and revoke token
router.post('/gallery/logout', async (req, res) => {
try {
const { slug } = req.body || {};
const token = getGalleryTokenFromRequest(req, slug);
if (token) {
await revokeToken(token, 'gallery_logout');
}
clearGalleryAuthCookies(res, slug);
res.json({ message: 'Logged out successfully' });
} catch (error) {
@@ -379,18 +477,104 @@ router.get('/session', async (req, res) => {
try {
const { slug } = req.query;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Verify with the same `issuer` claim that adminAuth/galleryAuth
// require (#350 — without this, /auth/session accepted pre-issuer
// tokens and the frontend thought the user was authenticated, but
// every protected endpoint rejected them with 401, producing a
// /admin/login → /admin/dashboard → /admin/login redirect loop).
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth'
});
// Check if token has been revoked (e.g. after logout)
const { isTokenRevoked } = require('../utils/tokenRevocation');
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ valid: false, error: 'Session has been invalidated' });
}
// The redirect loop reported on the v3.32.4-beta.0 release came
// from /auth/session reporting valid: true while the protected
// adminAuth / galleryAuth middleware rejected the same token for
// reasons /auth/session never checked: the admin user was
// deactivated, the admin's password had been changed since iat,
// or the gallery event was archived/deleted. Mirror those checks
// here so the session endpoint is always at least as strict as
// what the protected endpoints will enforce next.
if (decoded.type === 'admin') {
let admin = null;
try {
admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'password_changed_at')
.first();
} catch (lookupErr) {
// admin_users table not present (test fixture, fresh DB) — fall
// through and trust the token. Real deployments always have it.
admin = null;
// intentional swallow; if the table is missing we do not want
// to fail-closed during e.g. early bootstrap.
}
if (admin === null) {
// Lookup didn't run because the table is missing; skip the
// existence/password checks and treat the token as valid.
} else if (!admin) {
return res.json({ valid: false, error: 'Admin account no longer active' });
} else if (admin.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(admin.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
return res.json({ valid: false, error: 'Token invalid due to password change' });
}
}
// Mirror the session-timeout check that sessionTimeoutMiddleware
// enforces on every /api/admin endpoint. Without this, /auth/session
// returns valid:true for an idle/old-iat token that protected
// endpoints reject with 401 SESSION_TIMEOUT — the same redirect-loop
// shape as the issuer-claim and password-change asymmetries (issue
// #350 recurrence on v3.39.1-beta.0).
try {
const { isSessionExpired } = require('../middleware/sessionTimeout');
if (await isSessionExpired(token, decoded)) {
return res.json({ valid: false, error: 'Session expired' });
}
} catch (timeoutErr) {
// Helper lookup failed (test stub may not export it) — fall through
// and trust the token. Real deployments always have the middleware.
}
} else if (decoded.type === 'gallery') {
try {
const event = await db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
})
.first();
if (!event) {
return res.json({ valid: false, error: 'Gallery no longer available' });
}
if (event.expires_at && new Date(event.expires_at) < new Date()) {
return res.json({ valid: false, error: 'Gallery has expired' });
}
} catch (galleryLookupErr) {
// events table missing in this context — same fallback as
// admin path; trust the token rather than fail-closed.
}
}
// Calculate remaining time
const now = Date.now() / 1000;
const remainingTime = Math.max(0, decoded.exp - now);
res.json({
valid: true,
type: decoded.type,
+21
View File
@@ -190,6 +190,27 @@ router.post('/', adminAuth, [
welcome_message: welcome_message || ''
});
// Webhook lifecycle (#327). Legacy public endpoint — events go live
// immediately so created + published fire together. Payload uses the
// canonical event subject (#341) — every event.* webhook now includes
// customer contact + share_token.
try {
const webhookService = require('../services/webhookService');
const eventSubject = webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
});
await webhookService.fire('event.created', { event: eventSubject });
await webhookService.fire('event.published', { event: eventSubject });
} catch (e) { /* non-fatal */ }
res.json({
id: eventId,
slug,
+281 -84
View File
@@ -6,7 +6,7 @@ const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { verifyGalleryAccess, isAdminPreview } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
@@ -14,6 +14,9 @@ const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = r
const { handleAsync } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage } = require('../services/imageProcessor');
const downloadZipService = require('../services/downloadZipService');
const { getStorage } = require('../services/storage');
const fs = require('fs');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -74,7 +77,7 @@ router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => {
const { slug, token } = req.params;
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) })
.select('id', 'share_link', 'share_token')
.first();
@@ -122,7 +125,9 @@ router.get('/:slug/info', async (req, res) => {
'hero_logo_url',
'header_style',
'hero_divider_style',
'hero_image_anchor'
'hero_image_anchor',
'is_draft',
'default_photo_sort'
)
.first();
@@ -138,11 +143,16 @@ router.get('/:slug/info', async (req, res) => {
}
return res.status(404).json({ error: 'Gallery not found' });
}
// Check if event is archived
if (event.is_archived) {
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
}
// Check if event is a draft (allow admin preview)
if (event.is_draft && !isAdminPreview(req)) {
return res.status(404).json({ error: 'Gallery is not yet published' });
}
// If token provided, verify it matches the share link
if (token) {
@@ -176,7 +186,8 @@ router.get('/:slug/info', async (req, res) => {
hero_logo_url: event.hero_logo_url || null,
header_style: event.header_style || 'standard',
hero_divider_style: event.hero_divider_style || 'wave',
hero_image_anchor: event.hero_image_anchor || 'center'
hero_image_anchor: event.hero_image_anchor || 'center',
default_photo_sort: event.default_photo_sort || 'upload_date_desc'
});
} catch (error) {
console.error('Error fetching gallery info:', error);
@@ -198,10 +209,27 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
// Build the query with sorting
const sortOrder = order === 'asc' ? 'asc' : 'desc';
const isClient = req.accessLevel === 'client';
let photosQuery = db('photos')
.where('photos.event_id', req.event.id)
// Guests/clients never see photos still being processed by the
// background worker — the original is on disk but the thumbnail
// / dimensions / EXIF haven't landed yet. Photos with a NULL
// processing_status are pre-async-migration rows and are treated
// as complete (the migration's column default is 'complete' so
// this is just defensive against partial migration states).
.where(function() {
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
})
.select('photos.*');
// Guests only see visible photos; clients see all
if (!isClient) {
photosQuery = photosQuery.where(function() {
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
});
}
// Apply sort option
if (sort === 'capture_date') {
// Sort by capture date, falling back to uploaded_at if capture date is null
@@ -295,6 +323,11 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
}
}
// Check if feedback should be visible to guests
const feedbackService = require('../services/feedbackService');
const feedbackSettings = await feedbackService.getEventFeedbackSettings(req.event.id);
const showFeedbackToGuests = isClient || feedbackSettings.show_feedback_to_guests !== false;
// Then get comment counts separately
const commentCounts = await db('photo_feedback')
.whereIn('photo_id', photos.map(p => p.id))
@@ -383,6 +416,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
header_style: req.event.header_style || 'standard',
hero_divider_style: req.event.hero_divider_style || 'wave',
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),
...protectionSettings
},
categories: categories,
@@ -414,12 +449,20 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
height: photo.height || null,
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
requires_token: !useJwtUrl,
// Feedback data
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
average_rating: photo.average_rating || 0,
comment_count: commentMap[photo.id] || 0,
like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0
// EXIF capture date
captured_at: photo.captured_at || null,
// Media type
media_type: photo.media_type || null,
mime_type: photo.mime_type || null,
duration: photo.duration || null,
// Feedback data (hidden when show_feedback_to_guests is disabled)
has_feedback: showFeedbackToGuests ? (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0) : false,
average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0,
comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0,
like_count: showFeedbackToGuests ? (photo.like_count || 0) : 0,
favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0,
// Visibility (only included for clients)
...(isClient ? { visibility: photo.visibility || 'visible' } : {})
};
})
});
@@ -429,24 +472,91 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
}
});
// Toggle photo visibility (client-only)
router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => {
try {
if (req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Client access required' });
}
const { photoId } = req.params;
const { visibility } = req.body;
if (!['visible', 'hidden'].includes(visibility)) {
return res.status(400).json({ error: 'Invalid visibility value' });
}
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' });
}
await db('photos')
.where({ id: photoId, event_id: req.event.id })
.update({ visibility });
res.json({ message: 'Photo visibility updated', visibility });
} catch (error) {
logger.error('Error updating photo visibility:', error);
res.status(500).json({ error: 'Failed to update photo visibility' });
}
});
// Bulk toggle photo visibility (client-only)
router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => {
try {
if (req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Client access required' });
}
const { photoIds, visibility } = req.body;
if (!Array.isArray(photoIds) || photoIds.length === 0) {
return res.status(400).json({ error: 'Invalid photo IDs' });
}
if (!['visible', 'hidden'].includes(visibility)) {
return res.status(400).json({ error: 'Invalid visibility value' });
}
const count = await db('photos')
.whereIn('id', photoIds)
.where('event_id', req.event.id)
.update({ visibility });
res.json({ message: `${count} photos updated`, visibility });
} catch (error) {
logger.error('Error bulk updating photo visibility:', error);
res.status(500).json({ error: 'Failed to update photo visibility' });
}
});
// Download single photo
router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => {
try {
const { photoId } = req.params;
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
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' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Update download count
await db('photos').where('id', photoId).increment('download_count', 1);
@@ -525,32 +635,86 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
if (req.event.allow_downloads === false) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
// Try to serve pre-generated zip (instant download with Content-Length)
const zipInfo = await downloadZipService.getZipInfo(req.event.id);
if (zipInfo) {
const storage = getStorage();
// Per-event presigned-URL fast path (#328 follow-up). Conditions:
// 1. STORAGE_BACKEND=s3 (presigned URLs are S3-only)
// 2. event.allow_presigned_download is true (admin opted in)
// 3. Watermarking is OFF for this event — presigned URLs bypass the
// backend, which means no watermark on bytes leaving S3.
// Falls through to streaming on any condition mismatch.
const wantsPresigned = req.event.allow_presigned_download === true || req.event.allow_presigned_download === 1;
const watermarkOnEvent = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
if (wantsPresigned && storage.kind() === 's3' && !watermarkOnEvent) {
try {
const url = await storage.signedUrl(zipInfo.key, 300); // 5 min
db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download_all_presigned'
}).catch(() => {});
res.redirect(302, url);
return;
} catch (err) {
logger.warn('presigned download-all failed, falling back to stream', {
eventId: req.event.id,
error: err.message,
});
}
}
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Length', zipInfo.size);
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
const stream = await storage.get(zipInfo.key);
stream.pipe(res);
// Log bulk download
db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download_all'
}).catch(() => {});
return;
}
// Fallback: on-the-fly streaming (existing behavior)
// Also trigger background zip generation for next time
downloadZipService.generateZip(req.event.id).catch(err =>
logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message })
);
// Fetch photos
const photos = await db('photos')
.where('photos.event_id', req.event.id)
.select('photos.*')
.orderBy('photos.type', 'asc')
.orderBy('photos.uploaded_at', 'desc');
if (photos.length === 0) {
return res.status(404).json({ error: 'No photos found' });
}
// Count unique types
const uniqueTypes = new Set(photos.map(p => p.type)).size;
const hasMultipleTypes = uniqueTypes > 1;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', (err) => {
throw err;
});
archive.pipe(res);
// Get watermark settings - apply if global setting OR event-level setting is enabled
const watermarkSettings = await watermarkService.getWatermarkSettings();
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
@@ -561,51 +725,54 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
} : null;
// Add photos to archive
// 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) {
let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.warn('Skipping photo in bulk download due to unresolved path', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: resolveError.message,
});
continue;
}
// Determine the file name in the archive
const storageKey = resolvePhotoStorageKey(req.event, photo);
let archiveName;
if (hasMultipleTypes) {
// Use photo type as folder
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
archiveName = path.join(folderName, photo.filename);
} else {
// No folders, just the filename
archiveName = photo.filename;
}
if (shouldApplyWatermark && effectiveSettings) {
try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
try {
if (shouldApplyWatermark && effectiveSettings) {
// Watermark service operates on a local path. For managed photos in
// S3 mode, materialize a tmp local copy first.
const { withLocalCopy } = require('../services/imageProcessor');
const sourceForWatermark = storageKey
? null
: resolvePhotoFilePath(req.event, photo);
const watermarkedBuffer = storageKey
? await withLocalCopy(storageKey, (localPath) =>
watermarkService.applyWatermark(localPath, effectiveSettings)
)
: await watermarkService.applyWatermark(sourceForWatermark, effectiveSettings);
archive.append(watermarkedBuffer, { name: archiveName });
} catch (watermarkError) {
logger.warn('Failed to watermark photo for bulk download, skipping original to avoid leak', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: watermarkError.message,
});
} else if (storageKey) {
const stream = await storage.get(storageKey);
archive.append(stream, { name: archiveName });
} else {
const filePath = resolvePhotoFilePath(req.event, photo);
archive.file(filePath, { name: archiveName });
}
} else {
archive.file(filePath, { name: archiveName });
} catch (err) {
logger.warn('Skipping photo in bulk download due to error', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: err.message,
});
}
}
await archive.finalize();
// Log bulk download
await db('access_logs').insert({
event_id: req.event.id,
@@ -686,31 +853,32 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
} : null;
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`;
const storageKey = resolveSelectedKey(req.event, photo);
try {
const filePath = resolvePhotoFilePath(req.event, photo);
const name = photo.filename || `photo-${photo.id}.jpg`;
if (shouldApplyWatermark && effectiveSettings) {
try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
archive.append(watermarkedBuffer, { name });
} catch (watermarkError) {
logger.warn('Failed to watermark selected photo, skipping original to avoid leak', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: watermarkError.message,
});
}
const buf = storageKey
? await withSelectedLocalCopy(storageKey, (lp) =>
watermarkService.applyWatermark(lp, effectiveSettings)
)
: await watermarkService.applyWatermark(resolvePhotoFilePath(req.event, photo), effectiveSettings);
archive.append(buf, { name });
} else if (storageKey) {
const stream = await selectedStorage.get(storageKey);
archive.append(stream, { name });
} else {
archive.file(filePath, { name });
archive.file(resolvePhotoFilePath(req.event, photo), { name });
}
} catch (resolveError) {
logger.warn('Skipping selected photo due to unresolved path', {
} catch (err) {
logger.warn('Skipping selected photo due to error', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: resolveError.message,
error: err.message,
});
}
}
@@ -745,11 +913,15 @@ router.get('/:slug/photo/:photoId',
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Check if this is a video
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
@@ -935,6 +1107,11 @@ router.get('/:slug/thumbnail/:photoId',
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Ensure thumbnail exists and is valid, regenerate if needed
const thumbnailPath = await ensureThumbnail(photo);
@@ -1013,6 +1190,11 @@ router.get('/:slug/hero/:photoId',
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Check if this is a video - videos don't get hero images
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
if (isVideo) {
@@ -1093,10 +1275,12 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
res.json({
feedback_enabled: settings.feedback_enabled || false,
allow_ratings: settings.allow_ratings,
allow_likes: settings.allow_likes,
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites,
show_feedback_to_guests: settings.show_feedback_to_guests
show_feedback_to_guests: settings.show_feedback_to_guests,
require_name_email: settings.require_name_email || false,
identity_mode: settings.identity_mode || 'simple'
});
} catch (error) {
console.error('Error fetching feedback settings:', error);
@@ -1205,18 +1389,31 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const { processUploadedPhotos } = require('../services/photoProcessor');
const categoryId = req.body.category_id || req.event.upload_category_id || null;
const { queueFilesForProcessing } = require('../services/photoProcessor');
const rawCategory = req.body.category_id || req.event.upload_category_id || null;
const numericCategoryId = (() => {
if (rawCategory === null || rawCategory === undefined) return null;
const n = parseInt(rawCategory, 10);
return Number.isFinite(n) ? n : null;
})();
try {
// Process uploaded photos
const results = await processUploadedPhotos(req.files, eventId, 'user', categoryId);
res.json({
message: 'Photos uploaded successfully',
count: results.length,
photos: results
// Queue files as 'pending' — the background worker will process
// thumbnails / EXIF / dimensions off the request thread (#357).
const result = await queueFilesForProcessing(req.files, {
eventId,
photoType: 'individual',
categoryId: numericCategoryId,
});
res.status(202).json({
message: 'Photos queued for processing',
upload_id: result.uploadId,
count: result.photos.length,
photo_ids: result.photos.map((p) => p.id),
photos: result.photos,
errors: result.errors.length > 0 ? result.errors : undefined,
});
} catch (processError) {
console.error('Photo processing error:', processError);
+64 -32
View File
@@ -3,6 +3,7 @@ const router = express.Router();
const { photoAuth } = require('../middleware/photoAuth');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const { resolveGuest } = require('../middleware/guestAuth');
const feedbackService = require('../services/feedbackService');
const feedbackModeration = require('../services/feedbackModeration');
const { db, logActivity } = require('../database/db');
@@ -22,7 +23,7 @@ router.get('/:slug/feedback-settings',
try {
const event = req.event;
const settings = await feedbackService.getEventFeedbackSettings(event.id);
// Only send relevant settings to guests
// Convert SQLite boolean values (0/1) to proper booleans
const guestSettings = {
@@ -32,9 +33,10 @@ router.get('/:slug/feedback-settings',
allow_comments: Boolean(settings.allow_comments),
allow_favorites: Boolean(settings.allow_favorites),
require_name_email: Boolean(settings.require_name_email),
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests)
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
identity_mode: settings.identity_mode || 'simple'
};
res.json(guestSettings);
} catch (error) {
logger.error('Error getting feedback settings:', error);
@@ -46,6 +48,7 @@ router.get('/:slug/feedback-settings',
// Get feedback for a specific photo
router.get('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
resolveGuest,
validatePhotoId,
checkValidation,
async (req, res) => {
@@ -137,6 +140,7 @@ router.get('/:slug/photos/:photoId/feedback',
// Submit feedback for a photo
router.post('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
resolveGuest,
validatePhotoId,
validateFeedbackSubmission,
checkValidation,
@@ -144,15 +148,28 @@ router.post('/:slug/photos/:photoId/feedback',
try {
const { photoId } = req.params;
const event = req.event;
const guestIdentifier = generateGuestIdentifier(req);
// Get feedback settings
// Get feedback settings first so we can enforce identity_mode.
const settings = await feedbackService.getEventFeedbackSettings(event.id);
if (!settings.feedback_enabled) {
return res.status(403).json({ error: 'Feedback is not enabled for this event' });
}
// In guest identity mode, a valid guest token is required. The server
// never trusts guest_name/guest_email from the body in this mode — it
// reads them from the verified token via req.guest.
if (settings.identity_mode === 'guest') {
if (!req.guest || req.guest.eventId !== event.id) {
return res.status(401).json({
error: 'Guest identity required',
code: 'GUEST_IDENTITY_REQUIRED'
});
}
}
const guestIdentifier = generateGuestIdentifier(req);
// Check if specific feedback type is allowed
const feedbackType = req.body.feedback_type;
const typeAllowed = {
@@ -161,29 +178,32 @@ router.post('/:slug/photos/:photoId/feedback',
comment: settings.allow_comments,
favorite: settings.allow_favorites
};
if (!typeAllowed[feedbackType]) {
return res.status(403).json({ error: `${feedbackType} feedback is not enabled` });
}
// Verify photo belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Validate guest requirements
const guestValidation = await validateGuestRequirements(settings, req.body);
if (!guestValidation.valid) {
return res.status(400).json({
error: 'Guest information required',
errors: guestValidation.errors
});
// Validate guest requirements only in simple mode. In guest mode, the
// identity is already provided via the token and verified above.
if (settings.identity_mode !== 'guest') {
const guestValidation = await validateGuestRequirements(settings, req.body);
if (!guestValidation.valid) {
return res.status(400).json({
error: 'Guest information required',
errors: guestValidation.errors
});
}
}
// Apply rate limiting based on feedback type
const rateLimitMiddleware = feedbackRateLimit(feedbackType);
await new Promise((resolve, reject) => {
@@ -192,19 +212,21 @@ router.post('/:slug/photos/:photoId/feedback',
else resolve();
});
});
// If we got here and response was sent (rate limited), return
if (res.headersSent) return;
// Prepare feedback data
// Prepare feedback data. In guest mode, use the verified token as the
// source of truth for name/email — never the body.
const feedbackData = {
feedback_type: feedbackType,
rating: req.body.rating,
comment_text: req.body.comment_text,
guest_name: req.body.guest_name,
guest_email: req.body.guest_email,
guest_name: req.guest?.name ?? req.body.guest_name,
guest_email: req.guest?.email ?? req.body.guest_email,
guest_id: req.guest?.id ?? null,
ip_address: req.ip || req.connection.remoteAddress,
user_agent: req.headers['user-agent'],
user_agent: (req.headers['user-agent'] || '').replace(/[<>&"']/g, '').substring(0, 255),
moderate_comments: settings.moderate_comments
};
@@ -316,22 +338,32 @@ router.get('/:slug/feedback-summary',
// Get user's own feedback for all photos
router.get('/:slug/my-feedback',
verifyGalleryAccess,
resolveGuest,
async (req, res) => {
try {
const event = req.event;
const guestIdentifier = generateGuestIdentifier(req);
const myFeedback = await db('photo_feedback')
const query = db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', event.id)
.where('photo_feedback.guest_identifier', guestIdentifier)
.where('photo_feedback.event_id', event.id);
// Prefer guest_id lookup when a verified guest token is present
// (per-person identity). Fall back to the device hash otherwise.
if (req.guest?.id) {
query.where('photo_feedback.guest_id', req.guest.id);
} else {
const guestIdentifier = generateGuestIdentifier(req);
query.where('photo_feedback.guest_identifier', guestIdentifier);
}
const myFeedback = await query
.select(
'photo_feedback.*',
'photos.filename',
'photos.path'
)
.orderBy('photo_feedback.created_at', 'desc');
res.json(myFeedback);
} catch (error) {
logger.error('Error getting user feedback:', error);
+409
View File
@@ -0,0 +1,409 @@
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { resolveGuest, requireGuest, signGuestToken } = require('../middleware/guestAuth');
const feedbackService = require('../services/feedbackService');
const guestRecovery = require('../services/guestRecoveryService');
const MAX_NAME_LEN = 100;
const MAX_EMAIL_LEN = 255;
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// In-memory rate limit for guest registration (20 per hour per IP). Simple
// sliding window; on process restart the counters reset which is acceptable.
const registrationAttempts = new Map();
const REGISTRATION_WINDOW_MS = 60 * 60 * 1000;
const REGISTRATION_MAX = 20;
function checkRegistrationRate(ip) {
const now = Date.now();
const entry = registrationAttempts.get(ip) || { count: 0, windowStart: now };
if (now - entry.windowStart > REGISTRATION_WINDOW_MS) {
entry.count = 0;
entry.windowStart = now;
}
entry.count += 1;
registrationAttempts.set(ip, entry);
return entry.count <= REGISTRATION_MAX;
}
function sanitizeName(value) {
if (typeof value !== 'string') return '';
// Strip HTML/control chars, collapse whitespace.
const cleaned = value
.replace(/[<>&"']/g, '')
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s+/g, ' ')
.trim();
return cleaned.slice(0, MAX_NAME_LEN);
}
function sanitizeEmail(value) {
if (typeof value !== 'string') return '';
return value.trim().slice(0, MAX_EMAIL_LEN).toLowerCase();
}
/**
* POST /gallery/:slug/guest
* Body: { name, email? }
*
* Registers a new per-person guest identity for this gallery. Returns a JWT
* that the frontend must send as the x-guest-token header on subsequent
* feedback requests.
*/
router.post('/:slug/guest', verifyGalleryAccess, async (req, res) => {
try {
const ip = req.ip || req.connection.remoteAddress || 'unknown';
if (!checkRegistrationRate(ip)) {
return res.status(429).json({ error: 'Too many registration attempts' });
}
const event = req.event;
const settings = await feedbackService.getEventFeedbackSettings(event.id);
// Guest registration is only meaningful when feedback is enabled.
if (!settings.feedback_enabled) {
return res.status(403).json({ error: 'Feedback is not enabled for this gallery' });
}
const name = sanitizeName(req.body?.name);
if (!name || name.length < 1) {
return res.status(400).json({ error: 'Name is required', field: 'name' });
}
let email = sanitizeEmail(req.body?.email);
if (email && !EMAIL_REGEX.test(email)) {
return res.status(400).json({ error: 'Invalid email format', field: 'email' });
}
if (settings.require_name_email && !email) {
return res.status(400).json({ error: 'Email is required', field: 'email' });
}
const identifier = crypto.randomUUID();
const userAgent = (req.headers['user-agent'] || '').substring(0, 500);
const [row] = await db('gallery_guests')
.insert({
event_id: event.id,
name,
email: email || null,
identifier,
ip_address_last: ip.substring(0, 45),
user_agent_last: userAgent,
})
.returning(['id', 'name', 'email', 'identifier', 'created_at']);
const token = signGuestToken({
guestId: row.id,
eventId: event.id,
identifier: row.identifier,
name: row.name,
});
logger.info('Guest registered', {
eventId: event.id,
guestId: row.id,
name: row.name,
});
return res.json({
guest: {
id: row.id,
name: row.name,
email: row.email,
identifier: row.identifier,
},
token,
});
} catch (error) {
logger.error('Guest registration failed', { error: error.message });
return res.status(500).json({ error: 'Failed to register guest' });
}
});
/**
* GET /gallery/:slug/guest/me
* Returns the current guest profile from a valid guest token. 401 otherwise.
*/
router.get('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => {
try {
if (req.guest.eventId !== req.event.id) {
return res.status(403).json({ error: 'Guest token does not match gallery' });
}
// Update last_seen_at on each profile fetch (cheap and useful for admin).
await db('gallery_guests')
.where({ id: req.guest.id })
.update({
last_seen_at: db.fn.now(),
ip_address_last: (req.ip || '').substring(0, 45),
user_agent_last: (req.headers['user-agent'] || '').substring(0, 500),
});
return res.json({
guest: {
id: req.guest.id,
name: req.guest.name,
email: req.guest.email,
identifier: req.guest.identifier,
},
});
} catch (error) {
logger.error('Guest profile fetch failed', { error: error.message });
return res.status(500).json({ error: 'Failed to fetch guest profile' });
}
});
/**
* DELETE /gallery/:slug/guest/me
*
* "Forget me" soft-deletes the guest row and anonymizes their feedback so
* aggregate counts remain stable but personal data is removed.
*/
router.delete('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => {
try {
if (req.guest.eventId !== req.event.id) {
return res.status(403).json({ error: 'Guest token does not match gallery' });
}
await feedbackService.anonymizeGuestFeedback(req.guest.id);
await db('gallery_guests')
.where({ id: req.guest.id })
.update({
is_deleted: true,
name: 'Removed',
email: null,
last_seen_at: db.fn.now(),
});
logger.info('Guest self-forgot', {
eventId: req.event.id,
guestId: req.guest.id,
});
return res.json({ success: true });
} catch (error) {
logger.error('Guest forget-me failed', { error: error.message });
return res.status(500).json({ error: 'Failed to forget guest' });
}
});
// ---------------------------------------------------------------------------
// Phase 3.2 — Email-based identity recovery
// ---------------------------------------------------------------------------
// Simple in-memory rate limit for recover/verify (5 per hour per IP).
const recoveryAttempts = new Map();
const VERIFY_WINDOW_MS = 60 * 60 * 1000;
const VERIFY_MAX = 20;
function checkRecoveryRate(ip) {
const now = Date.now();
const entry = recoveryAttempts.get(ip) || { count: 0, windowStart: now };
if (now - entry.windowStart > VERIFY_WINDOW_MS) {
entry.count = 0;
entry.windowStart = now;
}
entry.count += 1;
recoveryAttempts.set(ip, entry);
return entry.count <= VERIFY_MAX;
}
/**
* POST /gallery/:slug/guest/recover
* Body: { email }
*
* Sends a 6-digit code to the email if it matches an existing guest. Returns
* 200 regardless of whether a matching guest exists (prevents enumeration).
*/
router.post('/:slug/guest/recover', verifyGalleryAccess, async (req, res) => {
try {
const ip = req.ip || 'unknown';
if (!checkRecoveryRate(ip)) {
return res.status(429).json({ error: 'Too many recovery attempts' });
}
const email = sanitizeEmail(req.body?.email);
if (!email || !EMAIL_REGEX.test(email)) {
// Still return 200 to avoid leaking validity of the email field.
return res.json({ success: true });
}
const event = req.event;
const settings = await feedbackService.getEventFeedbackSettings(event.id);
if (!settings.feedback_enabled || settings.identity_mode !== 'guest') {
return res.json({ success: true });
}
const guest = await db('gallery_guests')
.where({ event_id: event.id, email, is_deleted: false })
.first();
if (guest) {
try {
const code = await guestRecovery.createCode(event.id, email);
await guestRecovery.sendRecoveryEmail(email, code, event.event_name || 'your gallery');
} catch (sendError) {
logger.error('Failed to send recovery email', { error: sendError.message });
// Still return 200 so clients can't distinguish failures.
}
}
return res.json({ success: true });
} catch (error) {
logger.error('Guest recovery request failed', { error: error.message });
return res.json({ success: true });
}
});
/**
* POST /gallery/:slug/guest/verify
* Body: { email, code }
*
* Exchanges a valid verification code for a guest token. Reuses the existing
* guest row associated with the email (the guest continues where they left
* off, cross-device).
*/
router.post('/:slug/guest/verify', verifyGalleryAccess, async (req, res) => {
try {
const ip = req.ip || 'unknown';
if (!checkRecoveryRate(ip)) {
return res.status(429).json({ error: 'Too many verification attempts' });
}
const email = sanitizeEmail(req.body?.email);
const code = String(req.body?.code || '').trim();
if (!email || !code) {
return res.status(400).json({ error: 'Email and code are required' });
}
const event = req.event;
const verifyResult = await guestRecovery.verifyCode(event.id, email, code);
if (!verifyResult.ok) {
return res.status(401).json({ error: 'Invalid or expired code', reason: verifyResult.reason });
}
const guest = await db('gallery_guests')
.where({ event_id: event.id, email, is_deleted: false })
.first();
if (!guest) {
return res.status(404).json({ error: 'Guest not found' });
}
await db('gallery_guests')
.where({ id: guest.id })
.update({
email_verified_at: guest.email_verified_at || db.fn.now(),
last_seen_at: db.fn.now(),
ip_address_last: (req.ip || '').substring(0, 45),
});
const token = signGuestToken({
guestId: guest.id,
eventId: event.id,
identifier: guest.identifier,
name: guest.name,
});
logger.info('Guest recovered via email', { eventId: event.id, guestId: guest.id });
return res.json({
guest: {
id: guest.id,
name: guest.name,
email: guest.email,
identifier: guest.identifier,
},
token,
});
} catch (error) {
logger.error('Guest verify failed', { error: error.message });
return res.status(500).json({ error: 'Failed to verify code' });
}
});
// ---------------------------------------------------------------------------
// Phase 3.3 — Invite token redemption
// ---------------------------------------------------------------------------
/**
* POST /gallery/:slug/guest/redeem
* Body: { inviteToken }
*
* Redeems a pre-minted invite token (created by admin). Single use.
*/
router.post('/:slug/guest/redeem', verifyGalleryAccess, async (req, res) => {
try {
const inviteToken = String(req.body?.inviteToken || '').trim();
if (!inviteToken) {
return res.status(400).json({ error: 'Invite token required' });
}
const event = req.event;
const result = await db.transaction(async (trx) => {
const invite = await trx('guest_invites')
.where({ token: inviteToken, event_id: event.id })
.first();
if (!invite) return { error: 'not_found' };
if (invite.revoked_at) return { error: 'revoked' };
if (invite.redeemed_at) return { error: 'already_redeemed' };
const guest = await trx('gallery_guests')
.where({ id: invite.guest_id, is_deleted: false })
.first();
if (!guest) return { error: 'guest_missing' };
await trx('guest_invites')
.where({ id: invite.id })
.update({ redeemed_at: trx.fn.now() });
await trx('gallery_guests')
.where({ id: guest.id })
.update({
last_seen_at: trx.fn.now(),
ip_address_last: (req.ip || '').substring(0, 45),
user_agent_last: (req.headers['user-agent'] || '').substring(0, 500),
});
return { guest };
});
if (result.error) {
const statusMap = {
not_found: 404,
revoked: 410,
already_redeemed: 409,
guest_missing: 404,
};
return res.status(statusMap[result.error] || 400).json({ error: result.error });
}
const token = signGuestToken({
guestId: result.guest.id,
eventId: event.id,
identifier: result.guest.identifier,
name: result.guest.name,
});
logger.info('Invite redeemed', { eventId: event.id, guestId: result.guest.id });
return res.json({
guest: {
id: result.guest.id,
name: result.guest.name,
email: result.guest.email,
identifier: result.guest.identifier,
},
token,
});
} catch (error) {
logger.error('Invite redemption failed', { error: error.message });
return res.status(500).json({ error: 'Failed to redeem invite' });
}
});
module.exports = router;

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