Compare commits
318 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 067e460a4d | |||
| 3678193ae2 | |||
| 74eacbc78f | |||
| 37bf894412 | |||
| 506b5c3dc4 | |||
| ab6db37326 | |||
| eb2ce290a7 | |||
| 8a4c1a7c0a | |||
| 25e6c8034c | |||
| ba405c1dd2 | |||
| 04e928d762 | |||
| 0c80abd57b | |||
| 183e3117b6 | |||
| 9cf92f3e8e | |||
| 876b35b4a5 | |||
| 8c69e950c6 | |||
| 4a5395b3ac | |||
| 8050927607 | |||
| 28154da64d | |||
| de8ad5fdd5 | |||
| 21188f48d7 | |||
| a76ecf8496 | |||
| bdbe7b80a1 | |||
| 47b6b39f3a | |||
| 565ae45ca7 | |||
| 578a1745b8 | |||
| fc2bce3a01 | |||
| b19bb0c620 | |||
| 5b410ed9f8 | |||
| 67d7d8d3fa | |||
| d2a10f6523 | |||
| 5a162fc8be | |||
| 114aab5777 | |||
| f25559c0e7 | |||
| 375f51285b | |||
| ee7de6f6a1 | |||
| 01f5e44353 | |||
| c8e09c2a2a | |||
| b106da1ede | |||
| 045620e999 | |||
| dedb05ae28 | |||
| 51f28d7330 | |||
| 3d8fe2ec6e | |||
| d04bf28808 | |||
| b609a7a88c | |||
| bd0e052b1a | |||
| 5703fcb806 | |||
| dcff451572 | |||
| e382aed4d1 | |||
| ac05230867 | |||
| c60ab74ae2 | |||
| dbe0a3055b | |||
| 99392c5888 | |||
| 6fe1d8de9e | |||
| 1f1a856083 | |||
| d0d283a13e | |||
| 0a48646529 | |||
| 647aea21ae | |||
| 7491eb21c4 | |||
| 48d538f94f | |||
| 67999999d8 | |||
| d561db802b | |||
| ffb4318a1f | |||
| f410207b2d | |||
| e6c03e4b6e | |||
| b4f9b65f1d | |||
| bac51fe69a | |||
| 894e151255 | |||
| 05dadff493 | |||
| 91ad6bba99 | |||
| 045e9ea486 | |||
| 24d727752c | |||
| 0adec25fe7 | |||
| d823dcda26 | |||
| 0bd61be5a6 | |||
| aff29c91bb | |||
| 4d99eb672d | |||
| 66281f9790 | |||
| 3fe8e61bd1 | |||
| 06f2b75ec3 | |||
| ffbb0e659f | |||
| 98c6f6cf06 | |||
| bfd7169fc4 | |||
| 7e4f1acb88 | |||
| cdd40acb45 | |||
| 40d23e24cb | |||
| ee56b6762f | |||
| bd42ee1ce0 | |||
| 954ab00495 | |||
| f927b09c70 | |||
| 048870804f | |||
| 13e3a05386 | |||
| 3ab8a64a24 | |||
| 96818c7ae8 | |||
| 8f2639bdca | |||
| eb6791792e | |||
| 08d046276b | |||
| bce5c1f725 | |||
| ccd3fd9349 | |||
| cf04c0b2dc | |||
| b2c8161a43 | |||
| aade003564 | |||
| c270bcfc9f | |||
| c5bba505ac | |||
| a4e3d10fb0 | |||
| 66423bb65e | |||
| c0ca5ed687 | |||
| d552f45b20 | |||
| 0d1f82d31a | |||
| ff50c74e19 | |||
| d4d8833d82 | |||
| 65d8032960 | |||
| 07672038d4 | |||
| e8052adf1d | |||
| 297960698a | |||
| 4207207c5e | |||
| df3061893d | |||
| 4765804645 | |||
| 907bcf1eb2 | |||
| 214d8a1899 | |||
| f529c9e3d7 | |||
| b30010eb6f | |||
| 3b827b80d5 | |||
| 851744c3c4 | |||
| 86dfcc4f11 | |||
| f905f7e733 | |||
| 7b2f75e6ae | |||
| 1a530aeaa2 | |||
| c7ac9ddfe5 | |||
| 3440ecc999 | |||
| ede5193e58 | |||
| ec2eaf76ea | |||
| c282a72bd3 | |||
| ac040fbef8 | |||
| 1fae9b6099 | |||
| af2b0628cb | |||
| 07b41e691d | |||
| ef1c875f6e | |||
| f81a8728e6 | |||
| 9597333ddc | |||
| 03dd99a8b1 | |||
| e5712d8ffe | |||
| 83dedbcd45 | |||
| db29d0e278 | |||
| 88a6c6a7fb | |||
| 8f7258bfc8 | |||
| 34fdddef51 | |||
| 6229b38bac | |||
| 743086d3cb | |||
| d9d81372b8 | |||
| a5b20ca3fe | |||
| 92847bc06b | |||
| 7e65921ba6 | |||
| 02ed5d4007 | |||
| 0faf9b3281 | |||
| 39af382eb2 | |||
| 784c92fc4d | |||
| 7ea4801544 | |||
| 1e69d5ff71 | |||
| 1b1d816009 | |||
| f171f6b974 | |||
| 625711af96 | |||
| c5a2ec3842 | |||
| 1e4067713c | |||
| 42a7ae4be8 | |||
| fcddfe094b | |||
| 5275621fcd | |||
| 4c8eba0cb4 | |||
| 4c73d228ed | |||
| ca8acacd43 | |||
| f58b52a9d1 | |||
| 06d54bec4d | |||
| e232f9f2cf | |||
| ab4095f592 | |||
| 446d80a4cc | |||
| c488f481ca | |||
| 1b717ce5ed | |||
| 3d4ae4d7e9 | |||
| 2794ed6722 | |||
| ca0e48eb68 | |||
| 11de7b65c9 | |||
| 46bc894d91 | |||
| 038e84cae7 | |||
| 2eead52319 | |||
| 808b15bafb | |||
| be6cb28c80 | |||
| 4f77905b87 | |||
| b63a8774c4 | |||
| 793e410554 | |||
| 8d0fb8e157 | |||
| 822be9a9b2 | |||
| 63a6bfebce | |||
| 3d5759738f | |||
| 2f2f405d9b | |||
| 6cfff6f6a6 | |||
| e4b0f961b7 | |||
| c0989796e4 | |||
| 82adcd1f71 | |||
| d3f1206816 | |||
| e18afd3e6b | |||
| 4353acebf9 | |||
| 89f86b9fe4 | |||
| ceb2a09f48 | |||
| 094276d3cc | |||
| 59b56ed3d7 | |||
| 0a5b07de5d | |||
| b05c36ac81 | |||
| 9323befdd9 | |||
| 61142c0d0e | |||
| 623ab72916 | |||
| 3716ff5085 | |||
| dffe057772 | |||
| 3319a304ce | |||
| c303dd51e8 | |||
| b1dfbe4c2f | |||
| 54badefc51 | |||
| 15a8ab41fd | |||
| 77f07e9329 | |||
| 72c0c2d18e | |||
| 95d8bc4065 | |||
| 3856ba25bb | |||
| 9e1ba4f851 | |||
| b0efd32f7a | |||
| 9ed8a2b199 | |||
| ad4e5a7506 | |||
| d4b4dc628f | |||
| fe46e4268d | |||
| 1f3b9c6712 | |||
| 5295516b67 | |||
| ee0baafc59 | |||
| c63bc47089 | |||
| 147dc28440 | |||
| c031b1e863 | |||
| b1d16670d5 | |||
| ba1f010166 | |||
| ad64005a80 | |||
| 8805fa53e6 | |||
| 633d4a0f30 | |||
| b23c51b386 | |||
| 835bdf5abb | |||
| 4d3836fb2e | |||
| 75499992eb | |||
| 62643f241b | |||
| 64f606152f | |||
| a1b63de251 | |||
| 97b1ae5b03 | |||
| dc98206737 | |||
| 40332a71db | |||
| e2a698e892 | |||
| d1d71dba25 | |||
| bb81fa5f4b | |||
| 03e19893b3 | |||
| 279314e4b7 | |||
| 730912a3f4 | |||
| 125cd0d003 | |||
| 83868ffe2f | |||
| 9ddd50f7e4 | |||
| bec36fc99f | |||
| ea50488e99 | |||
| 8614c2232c | |||
| 07fc5e6519 | |||
| 3c8d344ddd | |||
| edf8bd54af | |||
| 2b7c9b0138 | |||
| aef9b4ed7f | |||
| ee3f6ae13b | |||
| 5025a42bf7 | |||
| 0a7a89045b | |||
| ddefd3a95e | |||
| 978e4473b5 | |||
| 8c5996e4ec | |||
| f50d7c0c51 | |||
| 4ce8dd297a | |||
| 85a4eb90fd | |||
| e32da68cbd | |||
| b54a80d251 | |||
| 2ac6c51fe5 | |||
| 23cd9cb680 | |||
| a63f1a8dd9 | |||
| 954a0118ba | |||
| ee46088985 | |||
| 3742d71535 | |||
| 486239aeb9 | |||
| 2c5ae6fbb9 | |||
| f9889a93fb | |||
| 4a93e4e8cb | |||
| e1b6e43e52 | |||
| 999c66dbbf | |||
| f5997892c4 | |||
| 7ca96315e2 | |||
| f3622396e7 | |||
| 56cf60c570 | |||
| 2618415aa1 | |||
| dfae2c2bc6 | |||
| bbeedd1888 | |||
| 201965b4b1 | |||
| 1468c459ba | |||
| 088de43f09 | |||
| 1fa222e9c4 | |||
| 6aceb40595 | |||
| 431a82eca1 | |||
| 85a07fcca7 | |||
| 1f524f2358 | |||
| 48a025b915 | |||
| c652ae0ead | |||
| 9a6d2e8e3a | |||
| fc0911acf8 | |||
| f77802325a | |||
| 74c9a5fbcd | |||
| 703c03fbee | |||
| 6f95b8c26c | |||
| 7250c427b9 | |||
| 04a7ea80f9 | |||
| c0a5cd56c8 | |||
| 908ab08815 | |||
| f07602553c | |||
| d2663bff81 | |||
| 308e086263 |
+107
@@ -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
@@ -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
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "3.17.1-beta.0"
|
||||
".": "3.42.1-beta.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
{
|
||||
".": "2.6.3"
|
||||
}
|
||||
{".":"3.43.1"}
|
||||
|
||||
+1085
-5
File diff suppressed because it is too large
Load Diff
@@ -1,350 +1,152 @@
|
||||
# 📸 PicPeak - Open Source Photo Sharing for Events
|
||||
# PicPeak
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
|
||||
|
||||
|
||||
**Self-hosted photo sharing for event photographers.**
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](DEPLOYMENT_GUIDE.md)
|
||||
[Live Demo](https://demo.picpeak.app) · [Deployment Guide](DEPLOYMENT_GUIDE.md) · [Homepage](https://www.picpeak.app)
|
||||
</div>
|
||||
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
||||
---
|
||||
|
||||
PicPeak lets you create password-protected, time-limited photo galleries for your clients — hosted on your own server. No subscriptions, no storage limits, no third-party access to your photos.
|
||||
|
||||

|
||||
|
||||
## 🎮 Live Demo
|
||||
## Demo
|
||||
|
||||
Try PicPeak without installing anything:
|
||||
Try it out at [demo.picpeak.app](https://demo.picpeak.app).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
|
||||
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
|
||||
| **Email** | `demo@picpeak.app` |
|
||||
| **Password** | `Demo2026!` |
|
||||
Admin panel: [demo.picpeak.app/admin](https://demo.picpeak.app/admin) — login with `demo@picpeak.app` / `Demo2026!`
|
||||
|
||||
> The demo resets periodically. Uploaded content may be removed without notice.
|
||||
> The demo resets periodically.
|
||||
|
||||
## 🌟 Why Choose PicPeak?
|
||||
## Features
|
||||
|
||||
Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
**Gallery Management** — Create galleries, upload photos via drag & drop, set passwords and expiration dates. Galleries auto-archive when they expire. Events start as drafts so you can upload and prepare before notifying the client.
|
||||
|
||||
- **💰 No Monthly Fees** - One-time setup, unlimited galleries
|
||||
- **🔒 Complete Data Control** - Your photos stay on your server
|
||||
- **🎨 White-Label Ready** - Full branding customization
|
||||
- **📱 Mobile-First Design** - Beautiful on all devices
|
||||
- **🚀 Lightning Fast** - Optimized performance and caching
|
||||
- **🌍 Multi-Language** - Built-in i18n support (EN, DE)
|
||||
**Client Experience** — Responsive galleries that look great on any device. Guests can browse, download individual photos or everything at once. Optional guest uploads and feedback (likes, comments, ratings).
|
||||
|
||||
## ✨ Key Features
|
||||
**Themes & Branding** — 11 built-in theme presets, custom CSS templates, configurable colors/fonts/layouts. White-label your admin panel and login page with your own logo and company name.
|
||||
|
||||
### For Photographers
|
||||
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
|
||||
- 🔗 **External Media (Reference Mode)** - Browse and import from a read‑only external folder library without copying originals
|
||||
- ⏰ **Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
|
||||
- 🔐 **Password Protection** - Secure client galleries
|
||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
|
||||
**Email Notifications** — Automated gallery creation, expiration warning, and archive emails. Multilingual templates (EN, DE, NL, PT, RU) editable from the admin UI.
|
||||
|
||||
### For Clients
|
||||
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
||||
- 📱 **Mobile Optimized** - Swipe through photos on any device
|
||||
- ⬇️ **Bulk Downloads** - Download all photos with one click
|
||||
- 🔍 **Smart Search** - Find photos quickly
|
||||
- 📤 **Guest Uploads** - Optional client photo uploads
|
||||
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
|
||||
**Photo Protection** — Watermarking, right-click prevention, canvas rendering, DevTools detection. Configurable per gallery.
|
||||
|
||||
### Technical Excellence
|
||||
- 🐳 **Docker Ready** - Deploy in minutes
|
||||
- 🔄 **Auto-Processing** - Automatic thumbnail generation
|
||||
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
|
||||
- 💾 **Smart Storage** - Automatic archiving of expired galleries
|
||||
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
||||
- 📈 **Scalable** - From small studios to large agencies
|
||||
**External Media** — Reference photos from a mounted folder instead of uploading. PicPeak reads originals in place and generates thumbnails on demand.
|
||||
|
||||
## 🚀 Quick Start
|
||||
**Multi-Language** — Full UI translations for English, German, Dutch, Portuguese, and Russian. Email templates support all languages independently.
|
||||
|
||||
Get PicPeak running in under 5 minutes:
|
||||
**Analytics** — Built-in view/download tracking plus optional Umami integration for privacy-focused analytics.
|
||||
|
||||
**Video Support** — Upload and stream MP4, WebM, MOV alongside photos. FFmpeg bundled via npm.
|
||||
|
||||
**Multiple Admins** — Role-based access control with super admin, admin, and editor roles.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit configuration (required: JWT_SECRET)
|
||||
nano .env
|
||||
|
||||
# Start with Docker Compose
|
||||
# Edit .env — set at least JWT_SECRET and passwords
|
||||
docker compose up -d
|
||||
|
||||
# Access at http://localhost:3000
|
||||
```
|
||||
|
||||
Note on Docker file permissions (PUID/PGID)
|
||||
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a non‑root user by default.
|
||||
- Set `PUID` and `PGID` in your `.env` to match your host user’s UID/GID (run `id -u` and `id -g` on the host). Compose maps the container user to these values.
|
||||
- Example in `.env`:
|
||||
- `PUID=1000`
|
||||
- `PGID=1000`
|
||||
- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied".
|
||||
Open `http://localhost:3000` and log in with the credentials from your `.env`.
|
||||
|
||||
## 🔄 Release Channels
|
||||
> **Permissions:** Set `PUID` and `PGID` in `.env` to match your host user (`id -u` / `id -g`) so Docker volumes are writable.
|
||||
|
||||
PicPeak offers two release channels for different needs:
|
||||
See the [Deployment Guide](DEPLOYMENT_GUIDE.md) for reverse proxy setup, SSL, external media, and production configuration.
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
- Thoroughly tested before release
|
||||
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
|
||||
## Screenshots
|
||||
|
||||
### Beta Channel
|
||||
- Early access to new features
|
||||
- May contain bugs or incomplete functionality
|
||||
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
|
||||
<details>
|
||||
<summary>Admin Dashboard</summary>
|
||||
|
||||
### Switching Channels
|
||||
<img src="docs/screenshot-dashboard.png" alt="Admin Dashboard" width="800" />
|
||||
</details>
|
||||
|
||||
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
|
||||
<details>
|
||||
<summary>Event Management</summary>
|
||||
|
||||
<img src="docs/screenshots-events.png" alt="Event Management" width="800" />
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Analytics</summary>
|
||||
|
||||
<img src="docs/screenshot-analytics.png" alt="Analytics" width="800" />
|
||||
</details>
|
||||
|
||||
## Comparison
|
||||
|
||||
| | PicPeak | PicDrop | Scrapbook.de |
|
||||
|---|---|---|---|
|
||||
| Self-hosted | Yes | No | No |
|
||||
| Monthly cost | $0 | $29-199 | 19-99 EUR |
|
||||
| Storage | Unlimited | 50-500 GB | 100-1000 GB |
|
||||
| Custom branding | Full | Limited | Limited |
|
||||
| Open source | Yes | No | No |
|
||||
| API | Yes | Paid | No |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend:** Node.js, Express, PostgreSQL (or SQLite)
|
||||
- **Frontend:** React, TypeScript, Tailwind CSS
|
||||
- **Infrastructure:** Docker, Nginx, Redis
|
||||
- **Processing:** Sharp (images), FFmpeg (video)
|
||||
|
||||
## Release Channels
|
||||
|
||||
**Stable** (`stable` / `latest`) — Production-ready. Use this for real deployments.
|
||||
|
||||
**Beta** (`beta`) — Early access to new features. May have rough edges.
|
||||
|
||||
```bash
|
||||
# For stable releases (default)
|
||||
PICPEAK_CHANNEL=stable
|
||||
# Set in .env
|
||||
PICPEAK_CHANNEL=stable # or beta
|
||||
|
||||
# For beta releases
|
||||
PICPEAK_CHANNEL=beta
|
||||
|
||||
# For a specific version
|
||||
PICPEAK_CHANNEL=v2.3.0
|
||||
```
|
||||
|
||||
Then update your containers:
|
||||
|
||||
```bash
|
||||
# Update
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
The admin dashboard notifies you when updates are available.
|
||||
|
||||
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
UPDATE_CHECK_ENABLED=false
|
||||
```
|
||||
We welcome contributions — bug fixes, features, translations, documentation. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions.
|
||||
|
||||
## 📖 Documentation
|
||||
## Documentation
|
||||
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
||||
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
|
||||
- 📚 [**Admin API (OpenAPI)**](docs/picpeak-admin-api.openapi.yaml) - Machine-readable documentation for event automation endpoints
|
||||
- 🛠️ [**Admin API Quickstart**](docs/admin-api-quickstart.md) - Step-by-step authentication and testing guide for the documented endpoints
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
||||
- [Deployment Guide](DEPLOYMENT_GUIDE.md) — Installation, configuration, reverse proxy, external media
|
||||
- [Admin API (OpenAPI)](docs/picpeak-admin-api.openapi.yaml) — Machine-readable API spec
|
||||
- [Admin API Quickstart](docs/admin-api-quickstart.md) — Authentication and testing guide
|
||||
- [Security Policy](SECURITY.md)
|
||||
|
||||
## 🌐 Public Landing Page
|
||||
## Contributors
|
||||
|
||||
Spotlight your studio with a customizable marketing page at `/`:
|
||||
Thanks to the people whose code, reports, and feedback have shaped PicPeak:
|
||||
|
||||
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
|
||||
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
|
||||
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
|
||||
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
|
||||
- Use **Reset to default** anytime to restore the bundled template.
|
||||
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
|
||||
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
|
||||
- [**@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.
|
||||
|
||||
## 🎯 Use Cases
|
||||
If you've contributed and aren't listed here, please open a PR.
|
||||
|
||||
Perfect for:
|
||||
- 💒 **Wedding Photographers** - Share ceremony photos securely
|
||||
- 🎂 **Event Photography** - Birthday parties, corporate events
|
||||
- 📸 **Portrait Studios** - Client galleries with download limits
|
||||
- 🏢 **Corporate Events** - Internal photo sharing with branding
|
||||
- 🎓 **School Photography** - Secure parent access with expiration
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
## License
|
||||
|
||||
- **Backend**: Node.js, Express, SQLite/PostgreSQL
|
||||
- **Frontend**: React, Tailwind CSS, Framer Motion
|
||||
- **Storage**: File-based with automatic archiving
|
||||
- **Email**: SMTP with customizable templates
|
||||
- **Analytics**: Privacy-focused with Umami integration
|
||||
|
||||
## 💻 System Requirements
|
||||
|
||||
### Minimum Requirements
|
||||
- **CPU**: 2 CPU cores
|
||||
- **RAM**: 2GB minimum
|
||||
- **Storage**: 20GB minimum (plus photo storage needs)
|
||||
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
|
||||
- **Node.js**: v18.0.0 or higher
|
||||
- **Database**: SQLite (included) or PostgreSQL 12+
|
||||
|
||||
### Docker Requirements (Recommended)
|
||||
- **Docker**: v20.10.0+
|
||||
- **Docker Compose**: v2.0.0+
|
||||
|
||||
### Video Support Requirements
|
||||
When enabling video uploads, consider these additional resources:
|
||||
|
||||
| Resource | Recommendation | Notes |
|
||||
|----------|----------------|-------|
|
||||
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
|
||||
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
|
||||
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
|
||||
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
|
||||
|
||||
**Technical Notes:**
|
||||
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
|
||||
- Maximum upload size: **10GB per video file**
|
||||
- Chunked upload support for files >100MB (resumable uploads)
|
||||
- Supported formats: MP4, WebM, MOV, AVI
|
||||
- Video thumbnails are automatically generated from the first few seconds
|
||||
|
||||
**For Nginx/Reverse Proxy:**
|
||||
If using Nginx, increase the client max body size:
|
||||
```nginx
|
||||
client_max_body_size 10G;
|
||||
proxy_read_timeout 3600;
|
||||
proxy_send_timeout 3600;
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
||||
|
||||
See our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
## 📊 Comparison with Alternatives
|
||||
|
||||
| Feature | PicPeak | PicDrop | Scrapbook.de |
|
||||
|---------|---------|---------|--------------|
|
||||
| Self-Hosted | ✅ | ❌ | ❌ |
|
||||
| Custom Branding | ✅ Full | Limited | Limited |
|
||||
| Monthly Cost | $0 | $29-199 | €19-99 |
|
||||
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
|
||||
| Client Uploads | ✅ | ✅ | ✅ |
|
||||
| API Access | ✅ | Paid | ❌ |
|
||||
| Open Source | ✅ | ❌ | ❌ |
|
||||
|
||||
*Limited only by your server storage
|
||||
|
||||
## 🛡️ Security
|
||||
|
||||
PicPeak takes security seriously:
|
||||
- 🔐 Password hashing with bcrypt
|
||||
- 🎫 JWT-based authentication
|
||||
- 🚦 Rate limiting on all endpoints
|
||||
- 🛡️ CORS protection
|
||||
- 📝 Activity logging
|
||||
- 🔒 Secure file access
|
||||
|
||||
Found a security issue? Please open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
### 🎛️ **Admin Dashboard**
|
||||
Get a complete overview of your photo galleries, analytics, and system status.
|
||||
|
||||
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
|
||||
|
||||
### 📊 **Analytics & Insights**
|
||||
Track gallery performance, view statistics, and monitor user engagement.
|
||||
|
||||
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
|
||||
|
||||
### 📁 **Event Management**
|
||||
Organize and manage your photo galleries with intuitive event management tools.
|
||||
|
||||
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
|
||||
|
||||
### ✨ **Key Interface Highlights**
|
||||
|
||||
<details>
|
||||
<summary>👆 Click to see more interface details</summary>
|
||||
|
||||
#### What makes PicPeak's interface special:
|
||||
|
||||
- **🎨 Clean Design**: Modern, photographer-friendly interface
|
||||
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
|
||||
- **⚡ Fast Loading**: Optimized for quick photo browsing
|
||||
- **🔒 Secure Access**: Password-protected galleries with expiration
|
||||
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
|
||||
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
|
||||
|
||||
</details>
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
|
||||
|
||||
### 🚧 Beta Features (Use at your own risk)
|
||||
|
||||
These features are currently in beta testing and may have limited functionality or stability:
|
||||
|
||||
| Feature | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
|
||||
|
||||
### 📋 Future Enhancements
|
||||
|
||||
| Feature | Description | Priority | Status |
|
||||
|---------|-------------|----------|---------|
|
||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||
| **External Media Library (Reference Mode)** | Use an external folder library as a read‑only source with import and on‑demand thumbnail generation | High | ✅ Implemented |
|
||||
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
|
||||
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
|
||||
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
|
||||
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
|
||||
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
|
||||
|
||||
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
|
||||
|
||||
### 🤖 AI-Assisted Development
|
||||
|
||||
This project was generated with the assistance of AI technology, but has been:
|
||||
- ✅ **Fully tested end-to-end** by human developers
|
||||
- 🔒 **Security audited** with comprehensive security checks
|
||||
- 👨💻 **Human-reviewed** for code quality and best practices
|
||||
- 🧪 **Production-tested** in real-world scenarios
|
||||
|
||||
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
|
||||
|
||||
## 📄 License
|
||||
|
||||
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
|
||||
|
||||
## 🚀 Ready to Get Started?
|
||||
|
||||
1. ⭐ **Star this repository** to show your support
|
||||
2. 📖 Read the [Deployment Guide](DEPLOYMENT_GUIDE.md)
|
||||
3. 🐛 Report issues or request features
|
||||
4. 🤝 Join our community and contribute!
|
||||
MIT — use it for personal or commercial projects.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ by photographers, for photographers
|
||||
<br>
|
||||
<a href="https://www.picpeak.app">Homepage</a> •
|
||||
<a href="https://demo.picpeak.app">Live Demo</a> •
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
<a href="https://www.picpeak.app">Homepage</a> · <a href="https://demo.picpeak.app">Live Demo</a> · <a href="DEPLOYMENT_GUIDE.md">Docs</a> · <a href="https://github.com/the-luap/picpeak/issues">Issues</a>
|
||||
</p>
|
||||
|
||||
+23
-16
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ./
|
||||
|
||||
@@ -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 & Jerry <3 "hi"'))
|
||||
.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 <script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it('escapes both the < > and & characters and quotes', () => {
|
||||
expect(safeTemplateReplace('{{x}}', { x: '<a href="evil">A & B\'s</a>' }, { escapeHtml: true }))
|
||||
.toBe('<a href="evil">A & B's</a>');
|
||||
});
|
||||
|
||||
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('& < > " '');
|
||||
});
|
||||
|
||||
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('<&>');
|
||||
});
|
||||
});
|
||||
|
||||
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 <b>world</b>');
|
||||
});
|
||||
|
||||
it('renders newlines as <br /> while keeping content escaped', () => {
|
||||
expect(formatWelcomeMessage('line 1\n<script>x</script>\nline 3'))
|
||||
.toBe('line 1<br /><script>x</script><br />line 3');
|
||||
});
|
||||
|
||||
it('escapes ampersands and quotes that would otherwise break HTML', () => {
|
||||
expect(formatWelcomeMessage('Tom & Jerry\'s "show"'))
|
||||
.toBe('Tom & Jerry's "show"');
|
||||
});
|
||||
});
|
||||
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.
@@ -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`);
|
||||
};
|
||||
Generated
+304
-198
@@ -1,20 +1,19 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.6.2",
|
||||
"version": "3.43.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.6.2",
|
||||
"version": "3.43.0",
|
||||
"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",
|
||||
"axios": "1.15.2",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -26,7 +25,7 @@
|
||||
"express-validator": "^7.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"form-data": "^4.0.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"handlebars": "^4.7.9",
|
||||
"helmet": "^7.0.0",
|
||||
"i18next": "25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -39,13 +38,15 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "^7.0.10",
|
||||
"nodemailer": "^8.0.5",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "0.34.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"uuid": "^11.1.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"uuid": "^11.1.1",
|
||||
"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",
|
||||
@@ -2669,6 +2594,18 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
|
||||
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2769,6 +2706,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",
|
||||
@@ -3606,13 +3550,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||
"integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==",
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-3.0.1.tgz",
|
||||
"integrity": "sha512-VyMVKRrpHTT8PnotUeV8L/mDaMwD5DaAKCFLP73zAqAtvF0FCqky+Ki7BYbFCYQmqFyTe9316Ed5zS70QUR9eg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
@@ -3697,6 +3641,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",
|
||||
@@ -4071,9 +4021,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
@@ -4290,9 +4240,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -4515,6 +4465,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 +5099,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 +5525,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 +5625,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 +5743,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",
|
||||
@@ -5832,21 +5793,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
|
||||
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz",
|
||||
"integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==",
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.9.tgz",
|
||||
"integrity": "sha512-jcyKVSEX13iseJqg7n/KWw+xnu/7fdrZ333Fac54KjHDIELVCfDDJXYIm6DTJ0Su4gSzrhqiK0DzY/wZbF40mw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5855,8 +5804,25 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.0.0",
|
||||
"strnum": "^2.1.2"
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
|
||||
"integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodable/entities": "^2.1.0",
|
||||
"fast-xml-builder": "^1.1.7",
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"strnum": "^2.2.3"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -5986,9 +5952,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"
|
||||
},
|
||||
@@ -6030,9 +5996,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6418,9 +6384,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||
"version": "4.7.9",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
||||
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
@@ -6800,9 +6766,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
@@ -7990,9 +7956,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
@@ -8013,6 +7979,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",
|
||||
@@ -8025,6 +7998,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",
|
||||
@@ -8056,6 +8036,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",
|
||||
@@ -8571,9 +8557,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",
|
||||
@@ -8773,9 +8759,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.12",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz",
|
||||
"integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==",
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
|
||||
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -9019,6 +9005,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",
|
||||
@@ -9158,6 +9151,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -9198,12 +9206,6 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
@@ -10675,9 +10677,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
|
||||
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
|
||||
"integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -10775,10 +10777,75 @@
|
||||
"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.9",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
|
||||
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
|
||||
"version": "7.5.13",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
@@ -11162,9 +11229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
@@ -11488,6 +11555,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",
|
||||
@@ -11552,6 +11628,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",
|
||||
|
||||
+17
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.6.3",
|
||||
"version": "3.43.1",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -10,16 +10,16 @@
|
||||
"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",
|
||||
"axios": "1.15.2",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -31,7 +31,7 @@
|
||||
"express-validator": "^7.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"form-data": "^4.0.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"handlebars": "^4.7.9",
|
||||
"helmet": "^7.0.0",
|
||||
"i18next": "25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -44,13 +44,15 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "^7.0.10",
|
||||
"nodemailer": "^8.0.5",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "0.34.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"uuid": "^11.1.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"uuid": "^11.1.1",
|
||||
"winston": "^3.8.2",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
@@ -67,10 +69,15 @@
|
||||
},
|
||||
"glob": "^11.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"fast-xml-parser": ">=5.3.8",
|
||||
"fast-xml-parser": ">=5.7.0",
|
||||
"qs": ">=6.14.2",
|
||||
"tar": ">=7.5.8",
|
||||
"brace-expansion": ">=5.0.0",
|
||||
"minimatch": ">=9.0.7"
|
||||
"tar": ">=7.5.13",
|
||||
"brace-expansion": ">=5.0.5",
|
||||
"minimatch": ">=9.0.7",
|
||||
"path-to-regexp": "0.1.13",
|
||||
"lodash": ">=4.18.1",
|
||||
"follow-redirects": ">=1.16.0",
|
||||
"@tootallnate/once": ">=3.0.1",
|
||||
"ip-address": ">=10.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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++;
|
||||
|
||||
|
||||
+597
-370
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user