Compare commits
467 Commits
v2.6.2
...
v3.49.5-beta.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4225cd153f | |||
| 1e4f762871 | |||
| d300426390 | |||
| b6b58d0659 | |||
| a135544cab | |||
| 57ea08e1ed | |||
| d39406b241 | |||
| d4155c4611 | |||
| 0b0b1bb2d5 | |||
| 409ddf9c93 | |||
| bc58520cd2 | |||
| d1034ce1c6 | |||
| a803491cf4 | |||
| 3869e5c0dc | |||
| 1cbb0c4cff | |||
| 6750f5d3b0 | |||
| c3256dc6bf | |||
| f9284010b7 | |||
| e5ef893395 | |||
| 1144e9d162 | |||
| f53b10dc2c | |||
| b3c6508712 | |||
| d856340f0d | |||
| 40e176cb46 | |||
| caf0d61857 | |||
| 0bc7e2af17 | |||
| 16e4d191c2 | |||
| fa71e7ea1e | |||
| 7126f12567 | |||
| 3122dd08a8 | |||
| 5e86eef4f8 | |||
| 7a9c4ca44e | |||
| e78e0d957a | |||
| dafc8d041a | |||
| 9be9296eb5 | |||
| c02c947463 | |||
| 3f4419356a | |||
| 9e418c759c | |||
| d0ad9879bd | |||
| 6d1af7a011 | |||
| 55a5846f6f | |||
| 5377b88e0e | |||
| 592fa1e1c2 | |||
| 0fd7ea336f | |||
| a5e58d085e | |||
| 4703fd574f | |||
| 72c55a7625 | |||
| fad2de5abe | |||
| d9d52ec8ab | |||
| 441cc41937 | |||
| dec2f5d3d2 | |||
| c86ee3d249 | |||
| 1c23ec51ec | |||
| 4f3db923a6 | |||
| 2343a162df | |||
| e3150e4213 | |||
| 53eecb6f83 | |||
| 2cae3fe47d | |||
| 358f7ee99e | |||
| 5ec26fc998 | |||
| 84c06affb7 | |||
| a125ea2ada | |||
| b31fc2140b | |||
| bd2288e6a0 | |||
| ae64a6acbc | |||
| 4e0d26d3f8 | |||
| eddcdbf4d8 | |||
| 9776d8a6fc | |||
| c0c6b4c0e8 | |||
| 2a7ae0702d | |||
| 06733f841a | |||
| 8d9d0bea83 | |||
| eb0c45e0f4 | |||
| 9091ed4012 | |||
| 35f5b86d0f | |||
| 7ac1d14738 | |||
| bf7ef14626 | |||
| 2f00bbdd90 | |||
| 15d01f3756 | |||
| 75e41eba03 | |||
| dde72a1b1b | |||
| 3ea7eea4b7 | |||
| 5b148542e6 | |||
| 88a865c813 | |||
| 2f63188a34 | |||
| 49b36a0352 | |||
| 936a277eb8 | |||
| 87dfae0074 | |||
| fe5295373b | |||
| 032a43ad01 | |||
| fd46c171ce | |||
| b252cb67eb | |||
| da08a5828a | |||
| f048011324 | |||
| 4fa7225732 | |||
| adfa29e91e | |||
| 087ef45942 | |||
| f2f48f31b0 | |||
| 55e6395e9e | |||
| 37d487db86 | |||
| d62c529b02 | |||
| 6ddb8e34d2 | |||
| 421354bc5a | |||
| f3505c2631 | |||
| be207d7120 | |||
| d2864854e7 | |||
| b4e30a4293 | |||
| 3a731e7c95 | |||
| 9c4a96fe97 | |||
| c52c1c8419 | |||
| 481f8c2f9f | |||
| 3fd8af3d56 | |||
| e54456135c | |||
| f1866e39c6 | |||
| f6be07ed4b | |||
| c3798e19c8 | |||
| 15e333681f | |||
| b6aaea21ca | |||
| fab0fcdde3 | |||
| d3007b0dd2 | |||
| 40d4c96998 | |||
| 83d79f4d39 | |||
| 197524a918 | |||
| ed37caf3d8 | |||
| 02bbc5c30d | |||
| 9ca9563dbe | |||
| b314bb21d3 | |||
| 916580adef | |||
| 479f16085b | |||
| d00f6fa7de | |||
| 087d71e8a7 | |||
| 967ade7a9a | |||
| de871b32cc | |||
| c114749921 | |||
| 46b99c6292 | |||
| 2c1288583f | |||
| 5fc427c74b | |||
| d1bc5e030f | |||
| e7228b0780 | |||
| 86ee6c80aa | |||
| 74e87b968b | |||
| a5db4bd46e | |||
| fd98a78123 | |||
| 72a7a43a4a | |||
| e1c93823c4 | |||
| 63c1b028bf | |||
| 715a806991 | |||
| e2ffd9f93d | |||
| 5c7de96b7f | |||
| f3d0f161c9 | |||
| f6cf470291 | |||
| 08707eee9e | |||
| 9326a427b3 | |||
| e54401930d | |||
| 9ade631e13 | |||
| e165ee5d9f | |||
| c2b1854df6 | |||
| 99e420b1b9 | |||
| f57429faf2 | |||
| e8df3de3fb | |||
| 7abfeb91cc | |||
| 401abf7a27 | |||
| 6b6191a426 | |||
| dfe1023161 | |||
| 8ab6fc6bbd | |||
| 523f49916b | |||
| b7d6ca0b65 | |||
| 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 | |||
| a1b63de251 | |||
| 97b1ae5b03 | |||
| dc98206737 | |||
| 40332a71db | |||
| 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
|
||||
|
||||
+400
-141
@@ -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,56 @@ 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.
|
||||
|
||||
# Default GITHUB_TOKEN to read-only at the workflow level. Each job that
|
||||
# needs to publish to GHCR sets `packages: write` explicitly. This keeps
|
||||
# the rest of the workflow (and any future steps) from inheriting unneeded
|
||||
# privileges (CKV2_GHA_1).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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
|
||||
# Trivy uploads its SARIF to the Security tab from this job — see
|
||||
# the "Run Trivy" step below. Scanning per-arch by digest (#476)
|
||||
# is reliable; scanning the multi-arch index by tag from the
|
||||
# merge-* job was not.
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- 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 +103,153 @@ 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
|
||||
|
||||
# Per-arch vulnerability scan (#476). Scanning the multi-arch
|
||||
# manifest from the merge-* job by tag is unreliable — Trivy's
|
||||
# remote resolver crashes intermittently with "no child with
|
||||
# platform linux/amd64 in index". The fix is to scan each leg
|
||||
# by its single-platform digest right here, where it just landed
|
||||
# in GHCR. Tag pinned (was @master) so the action + bundled
|
||||
# Trivy binary don't float between runs.
|
||||
#
|
||||
# exit-code is left unset (=0) for now: Trivy reports findings
|
||||
# to the Security tab but doesn't fail the build. Flipping that
|
||||
# to '1' to actually gate CI is a deliberate follow-up — needs an
|
||||
# audit pass first so the next beta build doesn't surprise red.
|
||||
- name: Run Trivy vulnerability scanner (per-arch, by digest)
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
env:
|
||||
# docker/build-push-action wraps every push in an OCI index
|
||||
# (carries the SLSA provenance attestation alongside the
|
||||
# actual image). Trivy's remote backend defaults to
|
||||
# linux/amd64 regardless of host arch when resolving an
|
||||
# index, which makes the arm64 leg crash with "no child
|
||||
# with platform linux/amd64". Telling Trivy which child to
|
||||
# scan keeps the provenance attestation intact and fixes
|
||||
# the resolver crash. Pin to matrix.platform so each leg
|
||||
# scans its own arch.
|
||||
TRIVY_PLATFORM: ${{ matrix.platform }}
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
# Distinct category per arch so the Security tab surfaces
|
||||
# per-platform findings independently — an amd64-only CVE in
|
||||
# a base layer doesn't get masked by the arm64 scan.
|
||||
category: 'backend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
|
||||
|
||||
merge-backend:
|
||||
needs: build-backend
|
||||
runs-on: ubuntu-latest
|
||||
# No security-events permission here — vulnerability scanning moved
|
||||
# to per-arch build-backend jobs (#476). This job's only job is to
|
||||
# combine the per-arch digests into a multi-arch manifest.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: 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,82 +271,50 @@ 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: Run Trivy vulnerability scanner
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-backend.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-backend.sarif'
|
||||
category: 'backend-vulnerabilities'
|
||||
- name: Inspect manifest
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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
|
||||
# See build-backend for the rationale (#476). Same pattern: per-arch
|
||||
# vulnerability scan by digest, SARIF uploaded to the Security tab.
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- 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 +329,134 @@ 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
|
||||
|
||||
# Per-arch vulnerability scan (#476). See build-backend for the
|
||||
# full rationale; identical pattern here, only the image-ref +
|
||||
# SARIF filename + category change.
|
||||
- name: Run Trivy vulnerability scanner (per-arch, by digest)
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
env:
|
||||
# See build-backend for the rationale — pin Trivy's platform
|
||||
# to the matrix arch so its remote-index resolver picks the
|
||||
# right child instead of defaulting to linux/amd64 and
|
||||
# crashing on the arm64 leg.
|
||||
TRIVY_PLATFORM: ${{ matrix.platform }}
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
category: 'frontend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
|
||||
|
||||
merge-frontend:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
# See merge-backend — vulnerability scanning moved to the per-arch
|
||||
# build-frontend matrix (#476). This job only publishes the manifest.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: 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,80 +478,79 @@ 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: Run Trivy vulnerability scanner
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-frontend.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-frontend.sarif'
|
||||
category: 'frontend-vulnerabilities'
|
||||
|
||||
# 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.
|
||||
- name: Inspect manifest
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
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.49.5-beta.0"
|
||||
}
|
||||
|
||||
+1027
-1
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -38,7 +38,7 @@ Unsure where to begin? You can start by looking through these issues:
|
||||
|
||||
### Pull Requests
|
||||
|
||||
1. **Fork the repo** and create your branch from `main`
|
||||
1. **Fork the repo** and create your branch from `beta`
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
cd backend && npm install
|
||||
|
||||
@@ -1,799 +0,0 @@
|
||||
# 🚀 PicPeak Deployment Guide
|
||||
|
||||
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Configuration](#-configuration)
|
||||
- [Deployment](#-deployment)
|
||||
- [First Login](#-first-login)
|
||||
- [Release Channels](#-release-channels)
|
||||
- [Reverse Proxy Setup](#-reverse-proxy-setup)
|
||||
- [External Media Library](#external-media-library)
|
||||
- [Maintenance](#-maintenance)
|
||||
- [Troubleshooting](#-troubleshooting)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Option 1: Automated Setup Script (Easiest)
|
||||
|
||||
For the simplest installation, use our unified setup script:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
|
||||
|
||||
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
|
||||
|
||||
### Option 2: Docker with Pre-built Images (Recommended)
|
||||
|
||||
```bash
|
||||
# Clone repository for configuration files
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
# Create required directories
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
|
||||
# Deploy using pre-built images
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
```
|
||||
|
||||
**Available image tags:**
|
||||
| Channel | Tags | Description |
|
||||
|---------|------|-------------|
|
||||
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
|
||||
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
|
||||
| Branch | `main`, `beta` | Latest from each branch |
|
||||
|
||||
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
|
||||
|
||||
### Option 3: Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Domain name (for production)
|
||||
- SMTP server credentials for emails
|
||||
- At least 2GB RAM and 20GB storage
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Essential Environment Variables
|
||||
|
||||
Generate secure values:
|
||||
```bash
|
||||
# JWT Secret
|
||||
openssl rand -base64 64
|
||||
|
||||
# Database Password (avoid $ character - see warning below)
|
||||
openssl rand -base64 32 | tr -d '$'
|
||||
|
||||
# Redis Password (avoid $ character - see warning below)
|
||||
openssl rand -base64 32 | tr -d '$'
|
||||
```
|
||||
|
||||
⚠️ **PASSWORD WARNING**: Docker Compose interprets `$` as variable substitution. Either:
|
||||
- Avoid `$` in passwords (recommended - use the commands above)
|
||||
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
|
||||
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
|
||||
|
||||
### Public Landing Page
|
||||
|
||||
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
|
||||
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
|
||||
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
|
||||
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
|
||||
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
|
||||
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
|
||||
|
||||
### Backend Configuration (.env)
|
||||
Update `.env` with:
|
||||
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
|
||||
- `DB_PASSWORD` - PostgreSQL password
|
||||
- `REDIS_PASSWORD` - Redis password
|
||||
- `SMTP_*` - Email configuration
|
||||
- **URL Configuration** (for backend CORS):
|
||||
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
|
||||
Notes:
|
||||
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
|
||||
- Always include the scheme (`http://` or `https://`).
|
||||
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
|
||||
|
||||
#### Authentication Security
|
||||
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
|
||||
|
||||
#### External Database Example
|
||||
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
|
||||
|
||||
```env
|
||||
DB_HOST=db.example.com
|
||||
DB_PORT=5432
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=change_me
|
||||
DB_NAME=picpeak_prod
|
||||
```
|
||||
|
||||
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you don’t set `DB_HOST` it will use the bundled `postgres` container.
|
||||
|
||||
### Frontend Configuration (frontend/.env)
|
||||
Create `frontend/.env` from `frontend/.env.example`:
|
||||
```bash
|
||||
cp frontend/.env.example frontend/.env
|
||||
```
|
||||
|
||||
Update `frontend/.env` with:
|
||||
- `VITE_API_URL` - Backend API URL
|
||||
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
|
||||
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
|
||||
|
||||
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
|
||||
|
||||
⚠️ **IMPORTANT PORT CONFIGURATION**:
|
||||
- The frontend runs on port **3000** in Docker (exposed via nginx)
|
||||
- The backend API runs on port **3001**
|
||||
- The frontend `.env` file MUST point to the correct backend port (3001)
|
||||
- Default `.env.example` is configured for Docker deployment
|
||||
|
||||
### Email Configuration Examples
|
||||
|
||||
#### Gmail
|
||||
```env
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-specific-password
|
||||
```
|
||||
|
||||
#### SendGrid
|
||||
```env
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
```
|
||||
|
||||
## 📦 Deployment
|
||||
|
||||
### Using Pre-built Images (Fastest)
|
||||
|
||||
```bash
|
||||
# Pull latest images from GitHub Container Registry
|
||||
docker pull ghcr.io/the-luap/picpeak/backend:latest
|
||||
docker pull ghcr.io/the-luap/picpeak/frontend:latest
|
||||
|
||||
# Start services using production compose file
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# View running containers
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Building from Source (For Customization)
|
||||
|
||||
```bash
|
||||
# Build images locally
|
||||
docker compose build
|
||||
|
||||
# Or build with no cache for clean build
|
||||
docker compose build --no-cache
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
# View running containers
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Access Points
|
||||
|
||||
By default, services are exposed on:
|
||||
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
|
||||
- Backend/API: http://localhost:3001 (API only; no UI routes)
|
||||
- PostgreSQL: localhost:5432 (if needed)
|
||||
- Redis: localhost:6379 (if needed)
|
||||
|
||||
### Initial Admin Setup
|
||||
|
||||
When deploying for the first time, an admin account is automatically created with a secure, randomly generated password. This password is displayed in the Docker logs during initialization and **must be changed** on first login.
|
||||
|
||||
#### Finding the Auto-Generated Admin Password
|
||||
|
||||
The admin password is automatically generated during the first startup and displayed in the backend container logs. Here's how to find it:
|
||||
|
||||
**Option 1: Search Docker logs for admin password** (recommended)
|
||||
```bash
|
||||
# Find the auto-generated admin password in logs
|
||||
docker compose logs backend | grep "Admin password"
|
||||
```
|
||||
|
||||
You should see output like:
|
||||
```
|
||||
✅ Admin password generated: BraveTiger6231!
|
||||
```
|
||||
|
||||
**Option 2: View the complete initialization logs**
|
||||
```bash
|
||||
# View the complete admin setup logs
|
||||
docker compose logs backend | grep -A 10 "Admin user created"
|
||||
```
|
||||
|
||||
**Option 3: Check the saved credentials file**
|
||||
```bash
|
||||
# The password is also saved in the backend container
|
||||
docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
|
||||
```
|
||||
|
||||
**Option 4: Use the helper script**
|
||||
```bash
|
||||
# Show current admin username and email (password is hidden)
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js
|
||||
|
||||
# Reset the admin password to a new random password (displays new password in console)
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
```
|
||||
|
||||
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
|
||||
|
||||
#### Important Security Notes
|
||||
|
||||
- **Login requires the email address**, not username
|
||||
- When resetting password, the new password is displayed once in the console - save it immediately
|
||||
- **Password change is MANDATORY** on first login - the system will force you to change it
|
||||
- If you lose the password before first login, use the `--reset` option to generate a new one
|
||||
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
|
||||
|
||||
## 🔐 First Login
|
||||
|
||||
After deployment, you must complete the first login process which includes mandatory password change for security.
|
||||
|
||||
### Step 1: Locate Your Admin Password
|
||||
|
||||
1. **Find the auto-generated password** from the credentials file:
|
||||
```bash
|
||||
# Docker deployment
|
||||
docker compose exec backend cat /app/data/ADMIN_CREDENTIALS.txt
|
||||
|
||||
# Or directly from the host (if you have access)
|
||||
cat data/ADMIN_CREDENTIALS.txt
|
||||
```
|
||||
|
||||
2. **Note the admin email** (default: `admin@example.com` unless customized)
|
||||
|
||||
### Step 2: Access Admin Panel
|
||||
|
||||
1. Navigate to your frontend domain and open the admin section:
|
||||
- `http://your-domain.com/admin` (behind reverse proxy)
|
||||
- `http://localhost:3000/admin` (Docker local)
|
||||
|
||||
The backend at `:3001` serves API only and does not serve the admin UI.
|
||||
2. Login using:
|
||||
- **Email**: `admin@example.com` (or your custom admin email)
|
||||
- **Password**: The auto-generated password from the logs
|
||||
|
||||
### Step 3: Mandatory Password Change
|
||||
|
||||
Upon first login, the system will **automatically redirect** you to change your password:
|
||||
|
||||
1. **You cannot skip this step** - it's enforced for security
|
||||
2. Enter the current auto-generated password
|
||||
3. Create a new secure password meeting these requirements:
|
||||
- Minimum 12 characters
|
||||
- At least one uppercase letter
|
||||
- At least one lowercase letter
|
||||
- At least one number
|
||||
- At least one special character (!@#$%^&*)
|
||||
|
||||
### Security Best Practices for New Password
|
||||
|
||||
- **Use a unique password** not used elsewhere
|
||||
- **Consider a password manager** for generation and storage
|
||||
- **Include mixed characters**: `MySecureP@ssw0rd2024!`
|
||||
- **Avoid personal information** (names, dates, etc.)
|
||||
- **Save securely** - you cannot recover this password easily
|
||||
|
||||
### If You Lose Access
|
||||
|
||||
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
||||
|
||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
|
||||
|
||||
```bash
|
||||
# Native reinstall example
|
||||
sudo ./picpeak-setup.sh --native --force-admin-password-reset
|
||||
|
||||
# Docker reinstall example
|
||||
sudo ./picpeak-setup.sh --docker --force-admin-password-reset
|
||||
```
|
||||
|
||||
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
|
||||
|
||||
#### Configuring Admin Email
|
||||
|
||||
By default, the admin email is `admin@example.com`. To use a different email address, set it in your `.env` file before first deployment:
|
||||
|
||||
```env
|
||||
# .env
|
||||
ADMIN_EMAIL=your-email@yourdomain.com
|
||||
```
|
||||
|
||||
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
|
||||
|
||||
## 🔄 Release Channels
|
||||
|
||||
PicPeak offers two release channels for different needs:
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
- Thoroughly tested before release
|
||||
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
|
||||
|
||||
### Beta Channel
|
||||
- Early access to new features
|
||||
- May contain bugs or incomplete functionality
|
||||
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
|
||||
|
||||
### Configuring Your Channel
|
||||
|
||||
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
|
||||
|
||||
```bash
|
||||
# For stable releases (default)
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# For beta releases
|
||||
PICPEAK_CHANNEL=beta
|
||||
|
||||
# For a specific version
|
||||
PICPEAK_CHANNEL=v2.3.0
|
||||
```
|
||||
|
||||
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
|
||||
```yaml
|
||||
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
|
||||
```
|
||||
|
||||
### Switching Channels
|
||||
|
||||
To switch between channels:
|
||||
|
||||
```bash
|
||||
# Edit your .env file
|
||||
nano .env
|
||||
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
|
||||
|
||||
# Pull the new images and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
|
||||
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
|
||||
- Checks GitHub releases hourly (cached to avoid rate limits)
|
||||
- Shows updates relevant to your current channel (stable or beta)
|
||||
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
|
||||
|
||||
## 🔒 Reverse Proxy Setup
|
||||
|
||||
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
|
||||
|
||||
### Routing Schema
|
||||
|
||||
PicPeak consists of two services that need to be routed correctly:
|
||||
|
||||
| Path | Service | Port | Description |
|
||||
|------|---------|------|-------------|
|
||||
| `/api/*` | Backend | 3001 | All API endpoints |
|
||||
| `/photos/*` | Backend | 3001 | Protected photo files |
|
||||
| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files |
|
||||
| `/uploads/*` | Backend | 3001 | Upload files |
|
||||
| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) |
|
||||
|
||||
> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests.
|
||||
|
||||
### Option 1: Nginx
|
||||
|
||||
Install nginx and create `/etc/nginx/sites-available/picpeak`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# Backend: API endpoints
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Backend: Protected media files
|
||||
location ~ ^/(photos|thumbnails|uploads)/ {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Frontend: Everything else (React SPA)
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the site:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/picpeak /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Option 2: Traefik
|
||||
|
||||
Add labels to `docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak.rule=Host(`your-domain.com`)"
|
||||
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
||||
|
||||
backend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# API endpoints
|
||||
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
|
||||
# Protected media files
|
||||
- "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))"
|
||||
- "traefik.http.routers.picpeak-media.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-media.loadbalancer.server.port=3001"
|
||||
```
|
||||
|
||||
### Option 3: Caddy
|
||||
|
||||
Create a `Caddyfile`:
|
||||
|
||||
```caddyfile
|
||||
your-domain.com {
|
||||
# Backend: API endpoints
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Backend: Protected media files
|
||||
handle /photos/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /thumbnails/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /uploads/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
|
||||
handle {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SSL Certificates
|
||||
|
||||
For any reverse proxy, you can use Let's Encrypt:
|
||||
|
||||
```bash
|
||||
# With Certbot
|
||||
sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com
|
||||
|
||||
# Or use your reverse proxy's built-in ACME support
|
||||
```
|
||||
|
||||
## 🔧 Maintenance
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
```
|
||||
|
||||
### Backup
|
||||
|
||||
#### Manual Backup
|
||||
```bash
|
||||
# Database backup
|
||||
docker exec picpeak-postgres pg_dump -U picpeak picpeak_prod > backup/db_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# Files backup
|
||||
tar -czf backup/photos_$(date +%Y%m%d_%H%M%S).tar.gz events/
|
||||
```
|
||||
|
||||
#### Automated Backup
|
||||
The application includes a built-in backup service. Configure it in the admin panel:
|
||||
1. Login to admin panel
|
||||
2. Go to Settings → Backup
|
||||
3. Configure destination and schedule
|
||||
4. Enable backup service
|
||||
|
||||
### Updates
|
||||
|
||||
#### Method 1: Using Pre-built Images (Recommended)
|
||||
|
||||
```bash
|
||||
# Pull latest changes (for configuration updates)
|
||||
git pull
|
||||
|
||||
# Pull latest images from GitHub Container Registry
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
|
||||
# Restart with new images
|
||||
docker compose -f docker-compose.production.yml down
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Verify services are healthy
|
||||
docker compose -f docker-compose.production.yml ps
|
||||
```
|
||||
|
||||
#### Method 2: Building from Source
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Verify services are healthy
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
#### Specific Version or Channel Updates
|
||||
|
||||
To use a specific version or switch channels, update your `.env` file:
|
||||
|
||||
```bash
|
||||
# Edit .env to change the channel or pin to a specific version
|
||||
nano .env
|
||||
|
||||
# Options for PICPEAK_CHANNEL:
|
||||
# - stable (recommended, production-ready)
|
||||
# - beta (early access to new features)
|
||||
# - v2.3.0 (pin to specific stable version)
|
||||
# - v2.3.0-beta.1 (pin to specific beta version)
|
||||
|
||||
# Then pull and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
The admin dashboard will notify you when updates are available for your configured channel.
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Migrations run automatically on startup, but you can run them manually:
|
||||
|
||||
```bash
|
||||
docker exec picpeak-backend npm run migrate
|
||||
```
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 502 Bad Gateway / Login Failures
|
||||
**This is the most common deployment issue!** Usually caused by misconfigured URLs or network problems:
|
||||
|
||||
1. **CORS Configuration Errors**:
|
||||
```bash
|
||||
# WRONG - Missing port will cause CORS errors
|
||||
FRONTEND_URL=http://10.0.252.12
|
||||
|
||||
# CORRECT - Include the port you're accessing from
|
||||
FRONTEND_URL=http://10.0.252.12:3000
|
||||
```
|
||||
|
||||
The backend validates Origin headers against `FRONTEND_URL` for CORS. If they don't match exactly, you'll get 500 errors on login.
|
||||
|
||||
2. **After Container Restarts**:
|
||||
- Nginx may have cached old container IPs
|
||||
- Solution: `docker restart picpeak-frontend`
|
||||
- Always wait 30-60 seconds for health checks
|
||||
|
||||
3. **Backend Not Starting After Migrations**:
|
||||
- The logs may only show migrations completed
|
||||
- Check if server is actually running: `docker exec picpeak-backend ps aux | grep node`
|
||||
- Should see `node server.js` process
|
||||
|
||||
4. **Login After Fresh Install**:
|
||||
- Check backend logs for auto-generated admin password: `docker compose logs backend | grep "Admin password"`
|
||||
- Email: `admin@example.com` (or your custom admin email from .env)
|
||||
- Password: Auto-generated and shown in logs (e.g., `BraveTiger6231!`)
|
||||
- Remember: Password MUST be changed on first login
|
||||
|
||||
5. **Complete Fix Sequence**:
|
||||
```bash
|
||||
# 1. Fix your .env file URLs
|
||||
# 2. Full restart
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Wait for healthy status
|
||||
sleep 60
|
||||
docker ps # All should show (healthy)
|
||||
|
||||
# 4. Test backend directly
|
||||
curl http://localhost:3001/health
|
||||
|
||||
# 5. Test through frontend
|
||||
curl http://localhost:3000/api/public/settings
|
||||
```
|
||||
|
||||
#### Port Already in Use
|
||||
```bash
|
||||
# Check what's using the port
|
||||
sudo lsof -i :3000
|
||||
sudo lsof -i :3001
|
||||
|
||||
# Change ports in .env
|
||||
FRONTEND_PORT=3002
|
||||
BACKEND_PORT=3003
|
||||
```
|
||||
|
||||
#### Docker Compose Variable Substitution Errors
|
||||
If you see warnings like:
|
||||
```
|
||||
WARN[0000] The "fgbf" variable is not set. Defaulting to a blank string.
|
||||
```
|
||||
|
||||
This means your password contains `$` which Docker Compose interprets as a variable. Solutions:
|
||||
1. **Best**: Generate passwords without `$`: `openssl rand -base64 32 | tr -d '$'`
|
||||
2. **Alternative**: Escape `$` as `$$` in your .env file
|
||||
3. **Example**: `DB_PASSWORD=Pass@#$$fgbf` instead of `DB_PASSWORD=Pass@#$fgbf`
|
||||
|
||||
#### Permission Errors
|
||||
```bash
|
||||
# Fix ownership
|
||||
sudo chown -R 1000:1000 events data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
#### Database Connection Issues
|
||||
```bash
|
||||
# Check if database is running
|
||||
docker compose ps
|
||||
docker compose logs postgres
|
||||
|
||||
# Test connection
|
||||
docker exec picpeak-postgres pg_isready
|
||||
```
|
||||
|
||||
#### Email Not Sending
|
||||
- Verify SMTP settings in .env
|
||||
- Check email queue: `docker exec picpeak-backend psql -U picpeak -d picpeak_prod -c "SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;"`
|
||||
- For Gmail, use app-specific password
|
||||
- Check logs: `docker compose logs backend | grep email`
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Backend health
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Frontend health
|
||||
curl http://localhost:3000
|
||||
|
||||
# Database health
|
||||
docker exec picpeak-postgres pg_isready
|
||||
```
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# Enter backend container
|
||||
docker exec -it picpeak-backend sh
|
||||
|
||||
# Enter database
|
||||
docker exec -it picpeak-postgres psql -U picpeak picpeak_prod
|
||||
|
||||
# Reset admin password
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
|
||||
# Check disk usage
|
||||
df -h
|
||||
du -sh events/ storage/ backup/
|
||||
|
||||
# View running processes
|
||||
docker compose top
|
||||
```
|
||||
|
||||
## Security Recommendations
|
||||
|
||||
1. **Use HTTPS**: Always use a reverse proxy with SSL in production
|
||||
2. **Firewall**: Only expose necessary ports (80, 443)
|
||||
3. **Secure passwords**: Use strong, unique passwords for all services
|
||||
4. **Regular updates**: Keep Docker images and system packages updated
|
||||
5. **Backup strategy**: Set up automated backups and test restoration
|
||||
6. **Monitor logs**: Regularly check logs for suspicious activity
|
||||
7. **Rate limiting**: The app includes built-in rate limiting, configure as needed
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
- Check logs first: `docker compose logs`
|
||||
- Review documentation in the repository
|
||||
- Check existing issues on GitHub
|
||||
- Create a new issue with:
|
||||
- Error messages
|
||||
- Log output
|
||||
- Environment details (without secrets)
|
||||
- Steps to reproduce
|
||||
@@ -7,8 +7,9 @@
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://buymeacoffee.com/theluap)
|
||||
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](DEPLOYMENT_GUIDE.md)
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support the project ☕](https://buymeacoffee.com/theluap)
|
||||
</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.
|
||||
@@ -96,6 +97,8 @@ Note on Docker file permissions (PUID/PGID)
|
||||
- `PGID=1000`
|
||||
- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied".
|
||||
|
||||
**ARM64 (aarch64) systems:** Pre-built images include native `linux/arm64`, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see [docker-compose.amd64.override.yml](docker-compose.amd64.override.yml) for a transitional fallback.
|
||||
|
||||
## 🔄 Release Channels
|
||||
|
||||
PicPeak offers two release channels for different needs:
|
||||
@@ -142,10 +145,17 @@ UPDATE_CHECK_ENABLED=false
|
||||
|
||||
## 📖 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
|
||||
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings reference, API docs, webhooks, archive lifecycle, branding, and everything else. Some quick links:
|
||||
|
||||
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
|
||||
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
|
||||
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
|
||||
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Local, S3, rsync destinations
|
||||
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
|
||||
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
|
||||
|
||||
Project meta:
|
||||
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
@@ -176,10 +186,111 @@ Perfect for:
|
||||
|
||||
- **Backend**: Node.js, Express, SQLite/PostgreSQL
|
||||
- **Frontend**: React, Tailwind CSS, Framer Motion
|
||||
- **Storage**: File-based with automatic archiving
|
||||
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](#storage-backends)
|
||||
- **Email**: SMTP with customizable templates
|
||||
- **Analytics**: Privacy-focused with Umami integration
|
||||
|
||||
## 💾 Storage Backends
|
||||
|
||||
PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.
|
||||
|
||||
| Capability | `STORAGE_BACKEND=local` (default) | `STORAGE_BACKEND=s3` |
|
||||
|---|---|---|
|
||||
| Photo / thumbnail / hero storage | Local filesystem under `STORAGE_PATH` | Bucket on any S3-compatible service |
|
||||
| Admin UI upload | ✅ | ✅ |
|
||||
| Filesystem auto-import (chokidar watcher) | ✅ | ❌ — disabled (use the upload API) |
|
||||
| Watermarks, fingerprinting, fragmentation | ✅ | ✅ (materialized to a tmp file just-in-time) |
|
||||
| Bulk download zips (cached + on-the-fly) | ✅ | ✅ |
|
||||
| Backups | ✅ | ✅ |
|
||||
| External media reference mode (`EXTERNAL_MEDIA_ROOT`) | ✅ (always local) | ✅ (still local — not migrated) |
|
||||
|
||||
### Switching to an S3-compatible backend
|
||||
|
||||
1. Provision a bucket and credentials. The minimum IAM policy is documented in `.env.example`.
|
||||
2. Set `STORAGE_BACKEND=s3` plus `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_ACCESS_KEY`, `STORAGE_S3_SECRET_KEY`. For non-AWS providers (MinIO, R2, B2, …) also set `STORAGE_S3_ENDPOINT`.
|
||||
3. If you have existing local content, copy it first: `node backend/scripts/migrate-storage.js --dry-run` then `node backend/scripts/migrate-storage.js`. The script is idempotent and writes a failures CSV.
|
||||
4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.
|
||||
|
||||
Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally **not** in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.
|
||||
|
||||
## 🔔 Webhooks
|
||||
|
||||
PicPeak POSTs event/photo lifecycle notifications to URLs you configure under **Settings → Webhooks**. Each delivery is signed `HMAC-SHA256` with a per-webhook secret in the `X-PicPeak-Signature` header so receivers can verify the request really came from your PicPeak instance.
|
||||
|
||||
### Event types
|
||||
|
||||
| Event | Fires when |
|
||||
|---|---|
|
||||
| `event.created` | Gallery created (admin or API) |
|
||||
| `event.published` | Draft becomes live (`is_draft: true → false`) — also fires when an event is created with `is_draft=false` |
|
||||
| `event.archived` | Bulk-archive, manual archive, or auto-archive on expiry |
|
||||
| `event.expired` | Expiration checker marks the gallery inactive (fires before `event.archived` in the cascade) |
|
||||
| `photo.uploaded` | Admin upload, API upload, guest upload, or auto-import |
|
||||
| `photo.deleted` | Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from `event.archived` to avoid flooding) |
|
||||
|
||||
### Payload shape
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "delivery-uuid",
|
||||
"type": "event.published",
|
||||
"created_at": "2026-04-28T05:25:00.000Z",
|
||||
"data": {
|
||||
"event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also sent on every request:
|
||||
- `X-PicPeak-Signature` — `HMAC-SHA256(secret, raw_body)` as hex
|
||||
- `X-PicPeak-Event` — the event type (handy for routing without parsing the body)
|
||||
- `X-PicPeak-Delivery` — UUID for idempotency on the receiver side
|
||||
- `User-Agent: PicPeak-Webhooks/1.0`
|
||||
|
||||
### Verifying signatures
|
||||
|
||||
**Node.js**
|
||||
```js
|
||||
const crypto = require('crypto');
|
||||
function verify(secret, rawBody, signature) {
|
||||
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
const a = Buffer.from(expected, 'hex');
|
||||
const b = Buffer.from(signature, 'hex');
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
```
|
||||
|
||||
**Python**
|
||||
```python
|
||||
import hmac, hashlib
|
||||
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
|
||||
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
```
|
||||
|
||||
**curl + openssl** (one-liner for a quick replay)
|
||||
```sh
|
||||
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
|
||||
[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH
|
||||
```
|
||||
|
||||
### Retries + observability
|
||||
|
||||
- `2xx` → success, recorded with latency
|
||||
- Non-`2xx` or network error → exponential backoff: `1m → 5m → 30m → 2h → 12h`, max 5 attempts
|
||||
- After max attempts: status `failed`, surfaces in **Settings → Webhooks → Deliveries** with a "Replay" button
|
||||
- Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via `WEBHOOK_DELIVERY_CONCURRENCY`)
|
||||
- Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log
|
||||
|
||||
The deliveries page (`/admin/webhooks/:id/deliveries`) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.
|
||||
|
||||
### SSRF protection
|
||||
|
||||
Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, `.local`/`.internal` hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).
|
||||
|
||||
For local development with a receiver on the same machine or docker network, set `WEBHOOK_ALLOW_PRIVATE_URLS=true`. Production deployments must leave this OFF.
|
||||
|
||||
## 💻 System Requirements
|
||||
|
||||
### Minimum Requirements
|
||||
@@ -312,10 +423,31 @@ These features are currently in beta testing and may have limited functionality
|
||||
|
||||
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
||||
|
||||
## ☕ Support the Project
|
||||
|
||||
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running.
|
||||
|
||||
<p align="left">
|
||||
<a href="https://buymeacoffee.com/theluap" target="_blank">
|
||||
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
|
||||
|
||||
### 👥 Contributors
|
||||
|
||||
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
|
||||
|
||||
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, and several typed-API refactors. Consistently raises the bar with thoughtful PRs.
|
||||
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
|
||||
|
||||
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
|
||||
|
||||
### 🤖 AI-Assisted Development
|
||||
|
||||
This project was generated with the assistance of AI technology, but has been:
|
||||
@@ -333,7 +465,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
## 🚀 Ready to Get Started?
|
||||
|
||||
1. ⭐ **Star this repository** to show your support
|
||||
2. 📖 Read the [Deployment Guide](DEPLOYMENT_GUIDE.md)
|
||||
2. 📖 Read the [docs at docs.picpeak.app](https://docs.picpeak.app)
|
||||
3. 🐛 Report issues or request features
|
||||
4. 🤝 Join our community and contribute!
|
||||
|
||||
@@ -345,6 +477,6 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
<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://docs.picpeak.app">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</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,47 @@ 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: 'auto' in production, false in dev (#427)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
|
||||
# login appears to succeed but the browser silently drops the
|
||||
# cookie, leaving you in a redirect loop. Only set this if you
|
||||
# ALWAYS reach the site via HTTPS)
|
||||
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP. Reads
|
||||
# req.secure from Express which respects X-Forwarded-Proto from a
|
||||
# trusted reverse proxy. This is the default and is the right
|
||||
# choice for most deployments.
|
||||
#
|
||||
# Why 'auto' is the default in production:
|
||||
# - On real HTTPS (reverse proxy with X-Forwarded-Proto), req.secure is
|
||||
# true → Secure flag is still emitted. No security regression vs. true.
|
||||
# - On plain HTTP (LAN access, first-time install before reverse proxy is
|
||||
# wired up), req.secure is false → Secure flag is omitted → login works
|
||||
# instead of silently looping back to /admin/login.
|
||||
#
|
||||
# When you'd set this explicitly:
|
||||
# - COOKIE_SECURE=true → strict HTTPS-only deployments where you want
|
||||
# defense in depth against accidentally serving over HTTP.
|
||||
# - COOKIE_SECURE=false → you intentionally only ever serve over HTTP and
|
||||
# don't want the per-request check (rare).
|
||||
#
|
||||
# Requirements for 'auto' mode to detect HTTPS correctly:
|
||||
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
|
||||
# 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
|
||||
|
||||
+19
-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
|
||||
@@ -54,5 +60,12 @@ USER nodejs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Healthcheck hits the same /health endpoint already used by the e2e
|
||||
# runner and by the docker-compose `depends_on: condition: service_healthy`
|
||||
# checks. wget is part of the Alpine base image. Long start-period covers
|
||||
# the wait-for-db.sh delay before the Node process starts listening.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["./wait-for-db.sh", "node", "server.js"]
|
||||
|
||||
@@ -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 ./
|
||||
@@ -28,5 +30,8 @@ USER nodejs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -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,123 @@
|
||||
/**
|
||||
* Pin the date-field normalisation in adminUsers transformer (#485).
|
||||
*
|
||||
* The Users page crashed on native/SQLite installs because Postgres
|
||||
* returned ISO strings while SQLite returned epoch-millisecond
|
||||
* integers, and the frontend `parseISO()` blew up on numbers with
|
||||
* "e.split is not a function". The transformer now coerces every
|
||||
* shape to an ISO 8601 string before serialising.
|
||||
*
|
||||
* These tests guard the contract so a future refactor can't quietly
|
||||
* regress and re-break the same page on the same DB.
|
||||
*/
|
||||
|
||||
const adminUsersRoute = require('../../src/routes/adminUsers');
|
||||
const { toIso, transformUser, transformInvitation } = adminUsersRoute.__test;
|
||||
|
||||
describe('toIso', () => {
|
||||
it('passes null and undefined through unchanged', () => {
|
||||
expect(toIso(null)).toBeNull();
|
||||
expect(toIso(undefined)).toBeUndefined();
|
||||
// Empty string also short-circuits — important so an unset
|
||||
// last_login renders as "Never" instead of 1970-01-01T00:00:00Z.
|
||||
expect(toIso('')).toBe('');
|
||||
});
|
||||
|
||||
it('coerces an integer epoch (SQLite shape) to an ISO 8601 string', () => {
|
||||
// 2026-05-14T10:00:00.000Z, in epoch ms.
|
||||
const epochMs = 1778752800000;
|
||||
expect(toIso(epochMs)).toBe('2026-05-14T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('coerces a stringified large integer to an ISO 8601 string', () => {
|
||||
// Some SQLite drivers stringify large integers because they
|
||||
// overflow JS safe-integer in the driver's serialiser. Re-coerce
|
||||
// so the frontend doesn't try to parseISO('1778752800000').
|
||||
expect(toIso('1778752800000')).toBe('2026-05-14T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('coerces a Date instance via toISOString', () => {
|
||||
const d = new Date('2026-01-01T12:34:56.000Z');
|
||||
expect(toIso(d)).toBe('2026-01-01T12:34:56.000Z');
|
||||
});
|
||||
|
||||
it('passes an existing ISO string through unchanged', () => {
|
||||
const iso = '2026-05-14T10:00:00.000Z';
|
||||
expect(toIso(iso)).toBe(iso);
|
||||
});
|
||||
|
||||
it('passes a non-numeric short string (e.g. truncated date) through unchanged', () => {
|
||||
// Defensive: anything that isn't a 10+ digit integer string is
|
||||
// treated as already-stringified — the date library will surface
|
||||
// the failure cleanly if it's malformed, rather than the
|
||||
// transformer silently rewriting it.
|
||||
expect(toIso('2026-05-14')).toBe('2026-05-14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformUser', () => {
|
||||
it('normalises last_login, created_at, updated_at coming from SQLite', () => {
|
||||
const sqliteRow = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
is_active: 1,
|
||||
last_login: 1778752800000, // epoch ms
|
||||
last_login_ip: '127.0.0.1',
|
||||
created_at: 1778751144600, // epoch ms
|
||||
updated_at: 1778751242320, // epoch ms
|
||||
role_id: 1,
|
||||
role_name: 'super_admin',
|
||||
role_display_name: 'Super Admin',
|
||||
created_by_username: null,
|
||||
};
|
||||
|
||||
const out = transformUser(sqliteRow);
|
||||
|
||||
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
|
||||
expect(out.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(out.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
// Other fields untouched.
|
||||
expect(out.username).toBe('admin');
|
||||
expect(out.lastLoginIp).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('leaves Postgres ISO strings intact', () => {
|
||||
const pgRow = {
|
||||
id: 2,
|
||||
username: 'second',
|
||||
email: 'second@example.com',
|
||||
is_active: true,
|
||||
last_login: '2026-05-14T10:00:00.000Z',
|
||||
created_at: '2026-05-13T08:00:00.000Z',
|
||||
updated_at: '2026-05-14T09:00:00.000Z',
|
||||
};
|
||||
const out = transformUser(pgRow);
|
||||
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
|
||||
expect(out.createdAt).toBe('2026-05-13T08:00:00.000Z');
|
||||
expect(out.updatedAt).toBe('2026-05-14T09:00:00.000Z');
|
||||
});
|
||||
|
||||
it('keeps last_login null when the user has never logged in', () => {
|
||||
const out = transformUser({
|
||||
id: 3, username: 'fresh', email: 'fresh@example.com',
|
||||
is_active: 1, last_login: null,
|
||||
});
|
||||
expect(out.lastLogin).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformInvitation', () => {
|
||||
it('normalises expires_at and created_at from SQLite epoch-ms', () => {
|
||||
const out = transformInvitation({
|
||||
id: 9,
|
||||
email: 'invitee@example.com',
|
||||
expires_at: 1779357600000,
|
||||
created_at: 1778752800000,
|
||||
role_name: 'admin',
|
||||
invited_by: 'admin',
|
||||
});
|
||||
expect(out.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(out.createdAt).toBe('2026-05-14T10:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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`);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Migration 087: Add a dedicated email template for the admin "Send Test
|
||||
* Email" button on the Update Notifications settings page (#418).
|
||||
*
|
||||
* Previously the button reused the version_update_available template via
|
||||
* sendUpdateNotificationNow(), which bailed out early when no real update
|
||||
* was pending — so admins on the latest version had no way to verify
|
||||
* their SMTP / recipient list config worked.
|
||||
*
|
||||
* The test template makes the intent unambiguous in the inbox ("This is
|
||||
* a test of your update-notification setup, no action needed") and lets
|
||||
* the send code run unconditionally regardless of update availability.
|
||||
*
|
||||
* Languages: EN + DE only, matching the convention of the existing
|
||||
* version_update_available template (070). The email_templates table
|
||||
* doesn't have nl/pt/ru columns; sendTemplateEmail falls back to EN.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 087_add_update_notification_test_template');
|
||||
|
||||
const existing = await knex('email_templates')
|
||||
.where('template_key', 'version_update_test')
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
console.log(' version_update_test template already exists, skipping insert');
|
||||
return;
|
||||
}
|
||||
|
||||
await knex('email_templates').insert({
|
||||
template_key: 'version_update_test',
|
||||
subject_en: '[TEST] PicPeak Update Notification — configuration check',
|
||||
subject_de: '[TEST] PicPeak Update-Benachrichtigung — Konfigurationsprüfung',
|
||||
body_html_en: `
|
||||
<h2>This is a test email</h2>
|
||||
|
||||
<p>You are receiving this message because an administrator clicked
|
||||
<strong>Send Test Email</strong> on the Update Notifications page of your
|
||||
PicPeak installation.</p>
|
||||
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Installed version:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Channel:</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Recipient address:</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
|
||||
<p>If you can read this email, your SMTP configuration and the recipient
|
||||
list are working correctly. When a real new version becomes available,
|
||||
PicPeak will send a separate notification with release notes and update
|
||||
instructions.</p>
|
||||
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">No action is required.
|
||||
You may safely delete this message.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your PicPeak Installation</p>`,
|
||||
body_text_en: `This is a test email
|
||||
|
||||
You are receiving this message because an administrator clicked
|
||||
"Send Test Email" on the Update Notifications page of your PicPeak
|
||||
installation.
|
||||
|
||||
Installed version: {{current_version}}
|
||||
Channel: {{channel}}
|
||||
Recipient address: {{recipient_email}}
|
||||
|
||||
If you can read this email, your SMTP configuration and the recipient
|
||||
list are working correctly. When a real new version becomes available,
|
||||
PicPeak will send a separate notification with release notes and update
|
||||
instructions.
|
||||
|
||||
No action is required. You may safely delete this message.
|
||||
|
||||
Best regards,
|
||||
Your PicPeak Installation`,
|
||||
body_html_de: `
|
||||
<h2>Dies ist eine Test-E-Mail</h2>
|
||||
|
||||
<p>Sie erhalten diese Nachricht, weil ein Administrator auf der Seite
|
||||
"Update-Benachrichtigungen" Ihrer PicPeak-Installation auf
|
||||
<strong>Test-E-Mail senden</strong> geklickt hat.</p>
|
||||
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Installierte Version:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Kanal:</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Empfänger-Adresse:</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
|
||||
<p>Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration
|
||||
und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar
|
||||
ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen
|
||||
und Update-Anweisungen.</p>
|
||||
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">Es ist keine Aktion
|
||||
erforderlich. Sie können diese Nachricht gefahrlos löschen.</p>
|
||||
|
||||
<p>Mit freundlichen Grüßen,<br>
|
||||
Ihre PicPeak-Installation</p>`,
|
||||
body_text_de: `Dies ist eine Test-E-Mail
|
||||
|
||||
Sie erhalten diese Nachricht, weil ein Administrator auf der Seite
|
||||
"Update-Benachrichtigungen" Ihrer PicPeak-Installation auf
|
||||
"Test-E-Mail senden" geklickt hat.
|
||||
|
||||
Installierte Version: {{current_version}}
|
||||
Kanal: {{channel}}
|
||||
Empfänger-Adresse: {{recipient_email}}
|
||||
|
||||
Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration
|
||||
und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar
|
||||
ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen
|
||||
und Update-Anweisungen.
|
||||
|
||||
Es ist keine Aktion erforderlich. Sie können diese Nachricht gefahrlos löschen.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihre PicPeak-Installation`,
|
||||
variables: JSON.stringify(['current_version', 'channel', 'recipient_email'])
|
||||
});
|
||||
|
||||
console.log('Migration 087_add_update_notification_test_template completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 087_add_update_notification_test_template');
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'version_update_test')
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Migration 088: Feature flags table.
|
||||
*
|
||||
* Backs the Features tab on the admin Settings page. Flags gate which
|
||||
* product surfaces appear in the main sidebar and (in future PRs) which
|
||||
* background jobs run.
|
||||
*
|
||||
* Existing-vs-fresh detection:
|
||||
* The Features tab introduces a curated set of "default ON" flags
|
||||
* (galleries, reminderEmails, analytics, userManagement) and "default
|
||||
* OFF" flags for surfaces that aren't built yet (calendar, quotes,
|
||||
* bills, messaging). For a brand-new install those defaults are right
|
||||
* out of the box. For an existing install, we want every flag ON so
|
||||
* nothing in the admin's UI silently disappears the moment they
|
||||
* upgrade — they can opt out later via the Features tab.
|
||||
*
|
||||
* Detection rule: if the `events` table has any rows at migration
|
||||
* time, treat this as an existing install. Empty events = fresh.
|
||||
* This is single-shot (the migration only runs once) and atomic
|
||||
* (no race window). It picks up the rare edge case where an admin
|
||||
* upgrades immediately after running setup but before creating an
|
||||
* event — they'll get fresh-install defaults, which is acceptable
|
||||
* (they can flip flags on the Features page).
|
||||
*
|
||||
* Schema:
|
||||
* - key (PK): the flag identifier (matches FeatureKey on the frontend)
|
||||
* - value: the boolean state
|
||||
* - updated_at: last-changed timestamp
|
||||
* - updated_by: admin id of the last person who flipped it (nullable
|
||||
* for the migration-seeded rows)
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 088_add_feature_flags');
|
||||
|
||||
const exists = await knex.schema.hasTable('feature_flags');
|
||||
if (!exists) {
|
||||
await knex.schema.createTable('feature_flags', (table) => {
|
||||
table.string('key', 64).primary();
|
||||
table.boolean('value').notNullable();
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.integer('updated_by').references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
});
|
||||
console.log(' created feature_flags table');
|
||||
} else {
|
||||
console.log(' feature_flags table already exists, skipping create');
|
||||
}
|
||||
|
||||
// Detect install age. Use events table — it's user-created content,
|
||||
// unlike admin_users which is seeded by migration 001.
|
||||
const eventCountRow = await knex('events').count({ count: '*' }).first();
|
||||
const eventCount = parseInt(eventCountRow?.count || 0, 10);
|
||||
const isExistingInstall = eventCount > 0;
|
||||
console.log(` detected ${isExistingInstall ? 'EXISTING' : 'FRESH'} install (events count: ${eventCount})`);
|
||||
|
||||
// Spec defaults (frontend/src/contexts/FeatureFlagsContext.tsx).
|
||||
// For an existing install every flag becomes TRUE so nothing
|
||||
// disappears from the admin UI on upgrade.
|
||||
const FLAGS_FRESH = {
|
||||
galleries: true, // always-on, locked
|
||||
reminderEmails: true, // existing cron, locked-on for now
|
||||
calendar: false, // surface not built yet
|
||||
calendarBooking: false, // ditto
|
||||
quotes: false, // surface not built yet
|
||||
bills: false, // surface not built yet (depends on quotes)
|
||||
messaging: false, // surface not built yet
|
||||
analytics: true, // existing surface
|
||||
userManagement: true, // existing surface
|
||||
};
|
||||
const flagsToSeed = isExistingInstall
|
||||
? Object.fromEntries(Object.keys(FLAGS_FRESH).map((k) => [k, true]))
|
||||
: FLAGS_FRESH;
|
||||
|
||||
for (const [key, value] of Object.entries(flagsToSeed)) {
|
||||
const existingRow = await knex('feature_flags').where({ key }).first();
|
||||
if (!existingRow) {
|
||||
await knex('feature_flags').insert({ key, value });
|
||||
}
|
||||
}
|
||||
console.log(` seeded ${Object.keys(flagsToSeed).length} flags`);
|
||||
|
||||
console.log('Migration 088_add_feature_flags completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 088_add_feature_flags');
|
||||
await knex.schema.dropTableIfExists('feature_flags');
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Migration 089: Footer overhaul (#441 + #440).
|
||||
*
|
||||
* Three concerns, all in the gallery footer area:
|
||||
*
|
||||
* 1. Per-CMS-page "Show in footer" toggle (#441 part a). Lets admins
|
||||
* hide legal-link entries (Impressum, Datenschutz, etc.) when the
|
||||
* target jurisdiction doesn't require them. Default TRUE so existing
|
||||
* installs see no change.
|
||||
*
|
||||
* 2. Social links in the footer (#441 part b). Five branding settings
|
||||
* for the canonical photographer-relevant networks: Facebook,
|
||||
* Instagram, WhatsApp, X/Twitter, YouTube. All optional strings.
|
||||
*
|
||||
* 3. Promotional markdown slot above/below the footer (#440). Global
|
||||
* default + per-event override with a three-way mode switch:
|
||||
* - inherit (default): use the global, render nothing if global empty
|
||||
* - custom: render the per-event markdown
|
||||
* - off: suppress entirely for this event regardless of global
|
||||
*
|
||||
* Markdown only (no raw HTML) — the rendering pipeline is
|
||||
* `marked → DOMPurify` on the frontend so admins can format text
|
||||
* without opening an XSS surface.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 089_footer_overhaul');
|
||||
|
||||
// 1. cms_pages.show_in_footer
|
||||
const hasShowInFooter = await knex.schema.hasColumn('cms_pages', 'show_in_footer');
|
||||
if (!hasShowInFooter) {
|
||||
await knex.schema.alterTable('cms_pages', (table) => {
|
||||
table.boolean('show_in_footer').notNullable().defaultTo(true);
|
||||
});
|
||||
console.log(' added cms_pages.show_in_footer (default true)');
|
||||
} else {
|
||||
console.log(' cms_pages.show_in_footer already exists, skipping');
|
||||
}
|
||||
|
||||
// 2. events.promo_mode + events.promo_markdown
|
||||
const hasPromoMode = await knex.schema.hasColumn('events', 'promo_mode');
|
||||
if (!hasPromoMode) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.string('promo_mode', 16).notNullable().defaultTo('inherit');
|
||||
});
|
||||
console.log(' added events.promo_mode (default "inherit")');
|
||||
} else {
|
||||
console.log(' events.promo_mode already exists, skipping');
|
||||
}
|
||||
|
||||
const hasPromoMarkdown = await knex.schema.hasColumn('events', 'promo_markdown');
|
||||
if (!hasPromoMarkdown) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.text('promo_markdown').nullable();
|
||||
});
|
||||
console.log(' added events.promo_markdown (nullable text)');
|
||||
} else {
|
||||
console.log(' events.promo_markdown already exists, skipping');
|
||||
}
|
||||
|
||||
// 3. New branding settings rows. Use the same `branding_<name>`
|
||||
// convention as the existing 21 branding rows (verified via
|
||||
// SELECT setting_key FROM app_settings WHERE setting_key LIKE 'branding_%').
|
||||
const newSettings = [
|
||||
{ setting_key: 'branding_facebook_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
|
||||
{ setting_key: 'branding_instagram_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
|
||||
{ setting_key: 'branding_whatsapp_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
|
||||
{ setting_key: 'branding_twitter_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
|
||||
{ setting_key: 'branding_youtube_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
|
||||
{ setting_key: 'branding_promo_markdown', setting_value: JSON.stringify(''), setting_type: 'branding' },
|
||||
// 'above_footer' | 'below_footer' (string instead of enum so we can
|
||||
// expand without a schema change later).
|
||||
{ setting_key: 'branding_promo_position', setting_value: JSON.stringify('above_footer'), setting_type: 'branding' },
|
||||
];
|
||||
|
||||
for (const setting of newSettings) {
|
||||
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
|
||||
}
|
||||
}
|
||||
console.log(` ensured ${newSettings.length} branding rows`);
|
||||
|
||||
console.log('Migration 089_footer_overhaul completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 089_footer_overhaul');
|
||||
|
||||
if (await knex.schema.hasColumn('cms_pages', 'show_in_footer')) {
|
||||
await knex.schema.alterTable('cms_pages', (table) => {
|
||||
table.dropColumn('show_in_footer');
|
||||
});
|
||||
}
|
||||
|
||||
if (await knex.schema.hasColumn('events', 'promo_mode')) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('promo_mode');
|
||||
});
|
||||
}
|
||||
|
||||
if (await knex.schema.hasColumn('events', 'promo_markdown')) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('promo_markdown');
|
||||
});
|
||||
}
|
||||
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_facebook_url',
|
||||
'branding_instagram_url',
|
||||
'branding_whatsapp_url',
|
||||
'branding_twitter_url',
|
||||
'branding_youtube_url',
|
||||
'branding_promo_markdown',
|
||||
'branding_promo_position',
|
||||
])
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Migration: Add Customer Accounts (recurring user logins)
|
||||
*
|
||||
* Implements the customer tier from discussion the-luap/picpeak#354.
|
||||
*
|
||||
* Three new tables:
|
||||
* - customer_accounts : the user record (email + bcrypt password)
|
||||
* - customer_invitations : admin → customer invite handshake (mirrors admin_invitations)
|
||||
* - event_customer_assignments: many-to-many junction with events
|
||||
*
|
||||
* Three new RBAC permissions seeded so super_admin and admin roles can
|
||||
* manage customers immediately after migrate. Editor / viewer remain
|
||||
* locked out by design (matches the existing users.* permissions pattern).
|
||||
*
|
||||
* Migration is idempotent — every step checks for existing state so a
|
||||
* partial install can be resumed.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// ---- customer_accounts -----------------------------------------------
|
||||
if (!(await knex.schema.hasTable('customer_accounts'))) {
|
||||
await knex.schema.createTable('customer_accounts', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('email', 255).unique().notNullable();
|
||||
|
||||
// --- auth ---------------------------------------------------------
|
||||
// password_hash is nullable until the invitation is accepted —
|
||||
// an unaccepted account row exists only after acceptInvitation, so
|
||||
// in practice this is always set, but the column is nullable to
|
||||
// allow for future "admin creates pre-loaded account" flows.
|
||||
table.string('password_hash', 255);
|
||||
table.boolean('must_change_password').notNullable().defaultTo(false);
|
||||
table.boolean('is_active').notNullable().defaultTo(true);
|
||||
// Tracks password-change time so JWTs issued before a password
|
||||
// change are rejected by customerAuth middleware. Mirrors the
|
||||
// admin_users.password_changed_at column.
|
||||
table.timestamp('password_changed_at');
|
||||
table.timestamp('last_login');
|
||||
table.string('last_login_ip', 45);
|
||||
table.string('preferred_language', 8).defaultTo('en');
|
||||
|
||||
// --- contact ------------------------------------------------------
|
||||
// Salutation honorific (Herr / Frau / Mx / Dr / Other). Stored as
|
||||
// free text rather than an enum so future locales (German "Frau",
|
||||
// French "Mme", legal titles "Dr.", etc.) don't need a migration.
|
||||
table.string('salutation', 32);
|
||||
table.string('first_name', 80);
|
||||
table.string('last_name', 80);
|
||||
// Convenience display name kept separately so the dashboard can
|
||||
// greet customers without joining first/last (e.g. "Welcome, Luca").
|
||||
table.string('display_name', 120);
|
||||
table.string('phone', 40);
|
||||
table.string('company_name', 120);
|
||||
|
||||
// --- billing / address (for future quotes & invoicing) -----------
|
||||
table.string('billing_email', 255);
|
||||
table.string('vat_id', 40);
|
||||
table.string('address_line1', 255);
|
||||
table.string('address_line2', 255);
|
||||
table.string('postal_code', 20);
|
||||
table.string('city', 120);
|
||||
table.string('state', 120);
|
||||
table.string('country_code', 2); // ISO 3166-1 alpha-2
|
||||
|
||||
// --- audit --------------------------------------------------------
|
||||
table.text('notes'); // free-text admin notes, never shown to the customer
|
||||
table.integer('created_by_admin_id').unsigned()
|
||||
.references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.index(['email']);
|
||||
table.index(['is_active']);
|
||||
table.index(['last_name']);
|
||||
table.index(['company_name']);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- customer_invitations --------------------------------------------
|
||||
if (!(await knex.schema.hasTable('customer_invitations'))) {
|
||||
await knex.schema.createTable('customer_invitations', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('email', 255).notNullable();
|
||||
// 64 chars = 32 bytes hex = 256 bits — same entropy as admin invites.
|
||||
table.string('token', 64).unique().notNullable();
|
||||
table.integer('invited_by').unsigned()
|
||||
.references('id').inTable('admin_users').onDelete('CASCADE').notNullable();
|
||||
table.timestamp('expires_at').notNullable();
|
||||
table.timestamp('accepted_at');
|
||||
table.integer('accepted_customer_id').unsigned()
|
||||
.references('id').inTable('customer_accounts').onDelete('SET NULL');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.index(['token']);
|
||||
table.index(['email']);
|
||||
table.index(['expires_at']);
|
||||
table.index(['accepted_at']);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- event_customer_assignments --------------------------------------
|
||||
if (!(await knex.schema.hasTable('event_customer_assignments'))) {
|
||||
await knex.schema.createTable('event_customer_assignments', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').unsigned().notNullable()
|
||||
.references('id').inTable('events').onDelete('CASCADE');
|
||||
table.integer('customer_account_id').unsigned().notNullable()
|
||||
.references('id').inTable('customer_accounts').onDelete('CASCADE');
|
||||
table.integer('assigned_by_admin_id').unsigned()
|
||||
.references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
table.timestamp('assigned_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.unique(['event_id', 'customer_account_id']);
|
||||
table.index(['customer_account_id']);
|
||||
table.index(['event_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- RBAC permissions -------------------------------------------------
|
||||
// Insert the three customers.* permissions if they're not already present
|
||||
// (guards against re-running the migration in dev). The same idempotency
|
||||
// pattern as 055_add_permissions_table.js.
|
||||
const existingPermissions = await knex('permissions').select('name');
|
||||
const existingNames = new Set(existingPermissions.map((p) => p.name));
|
||||
const newPermissions = [
|
||||
{
|
||||
name: 'customers.view',
|
||||
display_name: 'View Customers',
|
||||
category: 'customers',
|
||||
description: 'View customer accounts and their event assignments',
|
||||
},
|
||||
{
|
||||
name: 'customers.create',
|
||||
display_name: 'Invite Customers',
|
||||
category: 'customers',
|
||||
description: 'Issue customer invitations and assign customers to events',
|
||||
},
|
||||
{
|
||||
name: 'customers.delete',
|
||||
display_name: 'Deactivate Customers',
|
||||
category: 'customers',
|
||||
description: 'Deactivate customer accounts and revoke their access',
|
||||
},
|
||||
].filter((p) => !existingNames.has(p.name));
|
||||
|
||||
if (newPermissions.length > 0) {
|
||||
await knex('permissions').insert(newPermissions);
|
||||
}
|
||||
|
||||
// Grant the three permissions to super_admin and admin so the feature
|
||||
// is usable immediately. We look up role / permission ids fresh because
|
||||
// the inserts above just landed.
|
||||
const roles = await knex('roles').select('id', 'name')
|
||||
.whereIn('name', ['super_admin', 'admin']);
|
||||
const perms = await knex('permissions').select('id', 'name')
|
||||
.whereIn('name', ['customers.view', 'customers.create', 'customers.delete']);
|
||||
|
||||
if (roles.length > 0 && perms.length > 0) {
|
||||
const existing = await knex('role_permissions').select('role_id', 'permission_id');
|
||||
const existingSet = new Set(existing.map((m) => `${m.role_id}-${m.permission_id}`));
|
||||
const inserts = [];
|
||||
for (const role of roles) {
|
||||
for (const perm of perms) {
|
||||
const key = `${role.id}-${perm.id}`;
|
||||
if (!existingSet.has(key)) {
|
||||
inserts.push({ role_id: role.id, permission_id: perm.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inserts.length > 0) {
|
||||
await knex('role_permissions').insert(inserts);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- email template seed ---------------------------------------------
|
||||
// Schema is the post-075 shape: a master `email_templates` row keyed by
|
||||
// template_key, plus one `email_template_translations` row per language.
|
||||
// The legacy subject/body_html/body_text columns on email_templates may
|
||||
// still exist for back-compat, so we populate both wherever the column
|
||||
// is present — defensive, since some installs may run mid-upgrade.
|
||||
if (await knex.schema.hasTable('email_templates')) {
|
||||
const existing = await knex('email_templates')
|
||||
.where('template_key', 'customer_invitation')
|
||||
.first();
|
||||
|
||||
let templateId = existing?.id;
|
||||
if (!existing) {
|
||||
// Build the master row by introspecting the columns that actually
|
||||
// exist on this install. The schema has drifted across migrations
|
||||
// (075 adds language-specific columns then 075 normalises into a
|
||||
// separate translations table; some installs lack `created_at` /
|
||||
// `updated_at` on the master row). Anything not present is skipped
|
||||
// silently rather than causing the whole migration to abort and
|
||||
// taking the backend down with it.
|
||||
const masterColumns = await knex('email_templates').columnInfo();
|
||||
const insertRow = {
|
||||
template_key: 'customer_invitation',
|
||||
};
|
||||
if (masterColumns.variables) {
|
||||
insertRow.variables = JSON.stringify(['invite_link', 'expires_at']);
|
||||
}
|
||||
if (masterColumns.created_at) insertRow.created_at = knex.fn.now();
|
||||
if (masterColumns.updated_at) insertRow.updated_at = knex.fn.now();
|
||||
// Populate legacy single-language columns when present so older
|
||||
// email service code paths still find a sensible default body.
|
||||
if (masterColumns.subject) insertRow.subject = 'You\'ve been invited to access your photo galleries';
|
||||
if (masterColumns.body_html) {
|
||||
insertRow.body_html = '<p>You\'ve been invited to create a customer account. <a href="{{invite_link}}">Set up your account</a> (expires {{expires_at}}).</p>';
|
||||
}
|
||||
if (masterColumns.body_text) {
|
||||
insertRow.body_text = 'Set up your customer account: {{invite_link}} (expires {{expires_at}}).';
|
||||
}
|
||||
// Some installs have language-specific master columns from migration 075.
|
||||
if (masterColumns.subject_en) insertRow.subject_en = insertRow.subject || 'You\'ve been invited to access your photo galleries';
|
||||
if (masterColumns.body_html_en) insertRow.body_html_en = insertRow.body_html || '';
|
||||
if (masterColumns.body_text_en) insertRow.body_text_en = insertRow.body_text || '';
|
||||
|
||||
const [insertedId] = await knex('email_templates').insert(insertRow).returning('id');
|
||||
templateId = insertedId?.id || insertedId;
|
||||
}
|
||||
|
||||
if (templateId && await knex.schema.hasTable('email_template_translations')) {
|
||||
const transColumns = await knex('email_template_translations').columnInfo();
|
||||
const buildTranslationRow = (language, subject, bodyHtml, bodyText) => {
|
||||
const row = { template_id: templateId, language };
|
||||
if (transColumns.subject) row.subject = subject;
|
||||
if (transColumns.body_html) row.body_html = bodyHtml;
|
||||
if (transColumns.body_text) row.body_text = bodyText;
|
||||
if (transColumns.created_at) row.created_at = new Date();
|
||||
if (transColumns.updated_at) row.updated_at = new Date();
|
||||
return row;
|
||||
};
|
||||
|
||||
// The button uses the wrapper's `.button` class instead of inline
|
||||
// styles, which inherits the admin-configured `email_primary_color`
|
||||
// (Settings → Branding → Email palette). Inline `background-color`
|
||||
// would override it and lock the button to the legacy green
|
||||
// regardless of branding — that's the bug shipped on the very
|
||||
// first cut of this template.
|
||||
const en = buildTranslationRow(
|
||||
'en',
|
||||
'You\'ve been invited to access your photo galleries',
|
||||
`
|
||||
<h2>Welcome to your photo galleries</h2>
|
||||
<p>You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Set up your account</a>
|
||||
</div>
|
||||
<p>This invitation expires on {{expires_at}}. If the link doesn't work, copy and paste it into your browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>If you weren't expecting this email, you can safely ignore it.</p>`,
|
||||
`Welcome to your photo galleries
|
||||
|
||||
You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.
|
||||
|
||||
Set up your account: {{invite_link}}
|
||||
|
||||
This invitation expires on {{expires_at}}.
|
||||
|
||||
If you weren't expecting this email, you can safely ignore it.`
|
||||
);
|
||||
|
||||
const de = buildTranslationRow(
|
||||
'de',
|
||||
'Sie wurden eingeladen, auf Ihre Fotogalerien zuzugreifen',
|
||||
`
|
||||
<h2>Willkommen bei Ihren Fotogalerien</h2>
|
||||
<p>Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Konto einrichten</a>
|
||||
</div>
|
||||
<p>Diese Einladung läuft am {{expires_at}} ab. Falls der Link nicht funktioniert, kopieren Sie ihn in Ihren Browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.</p>`,
|
||||
`Willkommen bei Ihren Fotogalerien
|
||||
|
||||
Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.
|
||||
|
||||
Konto einrichten: {{invite_link}}
|
||||
|
||||
Diese Einladung läuft am {{expires_at}} ab.
|
||||
|
||||
Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.`
|
||||
);
|
||||
|
||||
for (const row of [en, de]) {
|
||||
const exists = await knex('email_template_translations')
|
||||
.where({ template_id: templateId, language: row.language })
|
||||
.first();
|
||||
if (!exists) {
|
||||
await knex('email_template_translations').insert(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Drop in reverse dependency order. Permissions / role grants are left
|
||||
// alone — they're idempotent on re-run and cleaning them on rollback
|
||||
// would require deleting role_permissions rows we may not own.
|
||||
await knex.schema.dropTableIfExists('event_customer_assignments');
|
||||
await knex.schema.dropTableIfExists('customer_invitations');
|
||||
await knex.schema.dropTableIfExists('customer_accounts');
|
||||
|
||||
// Best-effort cleanup of the three customers.* permissions if no other
|
||||
// code path inserted them. role_permissions rows cascade via FK.
|
||||
await knex('permissions').whereIn('name', [
|
||||
'customers.view',
|
||||
'customers.create',
|
||||
'customers.delete',
|
||||
]).del();
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Migration: Allow admins to pre-fill customer profile fields on invite (#354 follow-up).
|
||||
*
|
||||
* Adds a single `prefill_data` JSON column to `customer_invitations`. When an
|
||||
* admin invites a customer they can optionally pass first/last name, company,
|
||||
* phone and a billing address — that data is stashed here and copied onto the
|
||||
* new customer_accounts row by acceptInvitation(). The customer can then
|
||||
* confirm or edit those values on the accept-invite form before submitting.
|
||||
*
|
||||
* JSON instead of one column per field because:
|
||||
* - the prefill set may grow (vat id, salutation, etc.) and we don't want a
|
||||
* migration for every UI tweak;
|
||||
* - the data is only ever read+copied wholesale at accept time, never
|
||||
* filtered/queried.
|
||||
*
|
||||
* Migration is idempotent — bails out if the column already exists.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const hasTable = await knex.schema.hasTable('customer_invitations');
|
||||
if (!hasTable) {
|
||||
// Migration 087 not yet run — nothing to alter. Should never happen in
|
||||
// practice (knex runs migrations in order), but be defensive.
|
||||
return;
|
||||
}
|
||||
|
||||
const hasColumn = await knex.schema.hasColumn('customer_invitations', 'prefill_data');
|
||||
if (hasColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
await knex.schema.alterTable('customer_invitations', (table) => {
|
||||
// Knex's `json` type maps to JSONB on Postgres and TEXT on SQLite, which
|
||||
// matches how we already store other free-form payloads in this codebase
|
||||
// (see app_settings.theme_config). Nullable: invitations sent the
|
||||
// old way (or via the API without a body) should still work.
|
||||
table.json('prefill_data');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
const hasTable = await knex.schema.hasTable('customer_invitations');
|
||||
if (!hasTable) return;
|
||||
const hasColumn = await knex.schema.hasColumn('customer_invitations', 'prefill_data');
|
||||
if (!hasColumn) return;
|
||||
await knex.schema.alterTable('customer_invitations', (table) => {
|
||||
table.dropColumn('prefill_data');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Migration: Customer-surface feature flags, branding toggles, and password resets (#354 follow-up).
|
||||
*
|
||||
* Three things in one migration so a single rollback returns the install
|
||||
* to the prior state:
|
||||
*
|
||||
* 1. Per-customer feature flags on customer_accounts:
|
||||
* feature_calendar / feature_quotes / feature_bills (BOOLEAN, default
|
||||
* false). Default false because the matching pages are still
|
||||
* coming-soon stubs — admin opts a customer in once the feature is
|
||||
* actually useful for them. Combined with the global toggles below
|
||||
* via AND-logic in the customer session response.
|
||||
*
|
||||
* 2. Global customer-surface toggles seeded into app_settings under
|
||||
* setting_type='customer_surface':
|
||||
* customer_feature_calendar_enabled (default false)
|
||||
* customer_feature_quotes_enabled (default false)
|
||||
* customer_feature_bills_enabled (default false)
|
||||
* customer_show_logo (default true — preserves
|
||||
* current visual behaviour)
|
||||
* customer_show_company_name (default true — preserves
|
||||
* current visual behaviour)
|
||||
*
|
||||
* 3. customer_password_resets table — admin-triggered password reset
|
||||
* flow. Distinct from customer_invitations (which is the "create
|
||||
* account" flow): a reset always points at an existing customer_id
|
||||
* and updates the existing password_hash on accept.
|
||||
*
|
||||
* Plus the customer_password_reset email template, idempotently seeded.
|
||||
*
|
||||
* All steps are idempotent — a partial rollout can be resumed by re-running
|
||||
* `knex migrate:latest`.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// ---- per-customer feature flags ---------------------------------------
|
||||
|
||||
if (await knex.schema.hasTable('customer_accounts')) {
|
||||
const cols = ['feature_calendar', 'feature_quotes', 'feature_bills'];
|
||||
for (const col of cols) {
|
||||
const exists = await knex.schema.hasColumn('customer_accounts', col);
|
||||
if (!exists) {
|
||||
// Add as a separate alterTable per column so a half-applied
|
||||
// migration (column A added, B failing) leaves the table in a
|
||||
// consistent state on retry.
|
||||
await knex.schema.alterTable('customer_accounts', (table) => {
|
||||
table.boolean(col).notNullable().defaultTo(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- global customer-surface settings ---------------------------------
|
||||
|
||||
if (await knex.schema.hasTable('app_settings')) {
|
||||
const seeds = [
|
||||
// Features: default false. Admin must explicitly enable on the
|
||||
// settings page before the corresponding sidebar entry can show
|
||||
// for any customer.
|
||||
{ setting_key: 'customer_feature_calendar_enabled', setting_value: false, setting_type: 'customer_surface' },
|
||||
{ setting_key: 'customer_feature_quotes_enabled', setting_value: false, setting_type: 'customer_surface' },
|
||||
{ setting_key: 'customer_feature_bills_enabled', setting_value: false, setting_type: 'customer_surface' },
|
||||
// Branding: default true so existing installs keep their current
|
||||
// logo + company name in the customer header until the admin
|
||||
// opts to hide them.
|
||||
{ setting_key: 'customer_show_logo', setting_value: true, setting_type: 'customer_surface' },
|
||||
{ setting_key: 'customer_show_company_name', setting_value: true, setting_type: 'customer_surface' },
|
||||
];
|
||||
|
||||
for (const row of seeds) {
|
||||
const existing = await knex('app_settings').where('setting_key', row.setting_key).first();
|
||||
if (!existing) {
|
||||
// Postgres JSONB column accepts both a JSON literal and a
|
||||
// JSON-stringified value depending on driver version. Stringify
|
||||
// for SQLite compatibility; Postgres accepts the same shape.
|
||||
await knex('app_settings').insert({
|
||||
setting_key: row.setting_key,
|
||||
setting_value: JSON.stringify(row.setting_value),
|
||||
setting_type: row.setting_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- customer_password_resets table -----------------------------------
|
||||
|
||||
if (!(await knex.schema.hasTable('customer_password_resets'))) {
|
||||
await knex.schema.createTable('customer_password_resets', (table) => {
|
||||
table.increments('id').primary();
|
||||
// 64-char hex token, same shape as invitations and admin invites.
|
||||
table.string('token', 64).unique().notNullable();
|
||||
// Always points at an existing customer_account; if the account is
|
||||
// deleted, the reset disappears too.
|
||||
table.integer('customer_account_id').notNullable()
|
||||
.references('id').inTable('customer_accounts').onDelete('CASCADE');
|
||||
table.integer('requested_by_admin_id')
|
||||
.references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
table.timestamp('expires_at').notNullable();
|
||||
table.timestamp('used_at');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.index('token');
|
||||
table.index('customer_account_id');
|
||||
});
|
||||
}
|
||||
|
||||
// ---- customer_password_reset email template ---------------------------
|
||||
|
||||
if (await knex.schema.hasTable('email_templates')) {
|
||||
const existing = await knex('email_templates').where('template_key', 'customer_password_reset').first();
|
||||
if (!existing) {
|
||||
// Two email_templates schema variants exist in the wild:
|
||||
//
|
||||
// (a) legacy single-locale: subject / body_html / body_text columns
|
||||
// (b) multi-locale: subject_en / subject_de / body_html_en /
|
||||
// body_text_en / ... — all NOT NULL on at least one install
|
||||
// (the maintainer's prod), where the previous version of this
|
||||
// migration silently produced a 23502 NOT NULL violation and
|
||||
// crash-looped the backend.
|
||||
//
|
||||
// Detect whichever variant is present and populate every matching
|
||||
// column. For non-en locale columns we fall back to the English
|
||||
// content so the install isn't left with NULL-violation rows;
|
||||
// proper translations can be filled in later via the admin UI.
|
||||
const cols = await knex('email_templates').columnInfo();
|
||||
const SUBJECT = 'Reset your password';
|
||||
const BODY_HTML = `<p>Hello,</p>
|
||||
<p>Your photographer has triggered a password reset for your customer account.</p>
|
||||
<p><a href="{{reset_link}}">Click here to set a new password</a>. This link expires on {{expires_at}}.</p>
|
||||
<p>If you didn't expect this, you can ignore the message — your current password will keep working until you click the link.</p>`;
|
||||
const BODY_TEXT = `Your photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`;
|
||||
|
||||
const row = {};
|
||||
if ('template_key' in cols) row.template_key = 'customer_password_reset';
|
||||
if ('language' in cols) row.language = 'en';
|
||||
if ('is_active' in cols) row.is_active = true;
|
||||
if ('created_at' in cols) row.created_at = new Date();
|
||||
if ('updated_at' in cols) row.updated_at = new Date();
|
||||
|
||||
// Populate every subject/body column that exists, regardless of
|
||||
// locale suffix. Fallback content == English; safe because email
|
||||
// templates are user-editable post-install.
|
||||
for (const colName of Object.keys(cols)) {
|
||||
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
|
||||
row[colName] = SUBJECT;
|
||||
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
|
||||
row[colName] = BODY_HTML;
|
||||
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
|
||||
row[colName] = BODY_TEXT;
|
||||
}
|
||||
}
|
||||
|
||||
await knex('email_templates').insert(row);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// ---- table -----------------------------------------------------------
|
||||
if (await knex.schema.hasTable('customer_password_resets')) {
|
||||
await knex.schema.dropTable('customer_password_resets');
|
||||
}
|
||||
|
||||
// ---- per-customer flags ---------------------------------------------
|
||||
if (await knex.schema.hasTable('customer_accounts')) {
|
||||
const cols = ['feature_calendar', 'feature_quotes', 'feature_bills'];
|
||||
for (const col of cols) {
|
||||
if (await knex.schema.hasColumn('customer_accounts', col)) {
|
||||
await knex.schema.alterTable('customer_accounts', (table) => {
|
||||
table.dropColumn(col);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- settings -------------------------------------------------------
|
||||
if (await knex.schema.hasTable('app_settings')) {
|
||||
await knex('app_settings').whereIn('setting_key', [
|
||||
'customer_feature_calendar_enabled',
|
||||
'customer_feature_quotes_enabled',
|
||||
'customer_feature_bills_enabled',
|
||||
'customer_show_logo',
|
||||
'customer_show_company_name',
|
||||
]).del();
|
||||
}
|
||||
|
||||
// ---- template --------------------------------------------------------
|
||||
if (await knex.schema.hasTable('email_templates')) {
|
||||
await knex('email_templates').where('template_key', 'customer_password_reset').del();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Migration: Flip the per-customer feature flag semantic to "opt-out".
|
||||
*
|
||||
* Original semantic (089): both global toggle AND per-customer flag had
|
||||
* to be ON for a customer to see Calendar/Quotes/Bills. Defaults: false.
|
||||
* Result: enabling a feature globally was a no-op until the admin clicked
|
||||
* through every customer detail page and toggled them on individually.
|
||||
*
|
||||
* New semantic (this migration): global toggle is the master; per-customer
|
||||
* flag defaults to TRUE and only acts as an override-to-hide. So:
|
||||
*
|
||||
* - Global ON, per-customer default (true) → visible
|
||||
* - Global ON, per-customer set to false → hidden for this customer
|
||||
* - Global OFF, per-customer anything → hidden (master wins)
|
||||
*
|
||||
* Migration steps:
|
||||
* 1. Update column defaults to true so new customer_accounts rows
|
||||
* auto-opt-in.
|
||||
* 2. Flip every existing row's feature_* columns from false → true.
|
||||
* Rows that were never touched (i.e. ALL of them at this stage in
|
||||
* dev) end up with the new default. If a maintainer had already
|
||||
* hand-toggled a customer to false to hide a feature, that's
|
||||
* indistinguishable from the seeded default at this layer — so
|
||||
* this migration deliberately overwrites. Acceptable because the
|
||||
* original semantic only shipped for one image and nobody is
|
||||
* relying on hand-set false values yet.
|
||||
*
|
||||
* Idempotent: re-running is a no-op (the UPDATE just confirms current
|
||||
* values).
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('customer_accounts'))) return;
|
||||
|
||||
// Step 1 — change defaults. Knex's .alter() rewrites the column;
|
||||
// we keep notNullable to match 089.
|
||||
await knex.schema.alterTable('customer_accounts', (table) => {
|
||||
table.boolean('feature_calendar').notNullable().defaultTo(true).alter();
|
||||
table.boolean('feature_quotes').notNullable().defaultTo(true).alter();
|
||||
table.boolean('feature_bills').notNullable().defaultTo(true).alter();
|
||||
});
|
||||
|
||||
// Step 2 — flip existing rows so they pick up the new default. Without
|
||||
// this, customers created on the 089-shipped image stay invisible even
|
||||
// after the admin enables the feature globally.
|
||||
await knex('customer_accounts').update({
|
||||
feature_calendar: true,
|
||||
feature_quotes: true,
|
||||
feature_bills: true,
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('customer_accounts'))) return;
|
||||
// Restore the 089 default of false. Don't bulk-update existing rows
|
||||
// back to false: that would silently hide features for customers the
|
||||
// admin had explicitly enabled post-090.
|
||||
await knex.schema.alterTable('customer_accounts', (table) => {
|
||||
table.boolean('feature_calendar').notNullable().defaultTo(false).alter();
|
||||
table.boolean('feature_quotes').notNullable().defaultTo(false).alter();
|
||||
table.boolean('feature_bills').notNullable().defaultTo(false).alter();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Migration: Re-theme the customer_invitation email's CTA button.
|
||||
*
|
||||
* The original 087 seed inlined `background-color: #5C8762` on the
|
||||
* "Set up your account" anchor, which locked the button to the legacy
|
||||
* green regardless of the admin's `email_primary_color` setting
|
||||
* (Settings → Branding → Email palette). The wrapper template
|
||||
* (emailProcessor.wrapEmailHtml) already exposes a `.button` class
|
||||
* that inherits the configured palette — switching the anchor over
|
||||
* is a one-line change, but existing installs already have the bad
|
||||
* HTML in their email_template_translations rows. This migration
|
||||
* rewrites those rows so the next outbound invitation picks up the
|
||||
* brand colour.
|
||||
*
|
||||
* Idempotent: only updates rows whose stored body still contains the
|
||||
* old hardcoded anchor markup. If the admin has hand-edited the
|
||||
* template (typical for non-English locales they translated
|
||||
* themselves) the row is left alone.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
if (!(await knex.schema.hasTable('email_template_translations'))) return;
|
||||
|
||||
const master = await knex('email_templates')
|
||||
.where('template_key', 'customer_invitation')
|
||||
.first();
|
||||
if (!master) return; // 087 hasn't run on this install — nothing to fix.
|
||||
|
||||
// English translation
|
||||
const enRow = await knex('email_template_translations')
|
||||
.where({ template_id: master.id, language: 'en' })
|
||||
.first();
|
||||
if (enRow && typeof enRow.body_html === 'string'
|
||||
&& enRow.body_html.includes('background-color: #5C8762')
|
||||
&& enRow.body_html.includes('Set up your account')) {
|
||||
const newHtml = `
|
||||
<h2>Welcome to your photo galleries</h2>
|
||||
<p>You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Set up your account</a>
|
||||
</div>
|
||||
<p>This invitation expires on {{expires_at}}. If the link doesn't work, copy and paste it into your browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>If you weren't expecting this email, you can safely ignore it.</p>`;
|
||||
await knex('email_template_translations')
|
||||
.where({ id: enRow.id })
|
||||
.update({ body_html: newHtml, updated_at: new Date() });
|
||||
}
|
||||
|
||||
// German translation
|
||||
const deRow = await knex('email_template_translations')
|
||||
.where({ template_id: master.id, language: 'de' })
|
||||
.first();
|
||||
if (deRow && typeof deRow.body_html === 'string'
|
||||
&& deRow.body_html.includes('background-color: #5C8762')
|
||||
&& deRow.body_html.includes('Konto einrichten')) {
|
||||
const newHtml = `
|
||||
<h2>Willkommen bei Ihren Fotogalerien</h2>
|
||||
<p>Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Konto einrichten</a>
|
||||
</div>
|
||||
<p>Diese Einladung läuft am {{expires_at}} ab. Falls der Link nicht funktioniert, kopieren Sie ihn in Ihren Browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.</p>`;
|
||||
await knex('email_template_translations')
|
||||
.where({ id: deRow.id })
|
||||
.update({ body_html: newHtml, updated_at: new Date() });
|
||||
}
|
||||
|
||||
// Legacy single-language column on the master row, if present.
|
||||
// Older installs may also have a hardcoded body_html on email_templates
|
||||
// itself (pre-translations table). Same idempotent rewrite logic.
|
||||
if (typeof master.body_html === 'string'
|
||||
&& master.body_html.includes('background-color: #5C8762')) {
|
||||
await knex('email_templates')
|
||||
.where({ id: master.id })
|
||||
.update({
|
||||
body_html: '<p>You\'ve been invited to create a customer account. <a href="{{invite_link}}" class="button">Set up your account</a> (expires {{expires_at}}).</p>',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(/* knex */) {
|
||||
// No-op: rolling back the visual fix would intentionally restore the
|
||||
// bug. Admins who want the old green button can edit the template
|
||||
// from Settings → Email Templates.
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Migration 095: Add `customerPortal` to feature_flags.
|
||||
*
|
||||
* The customer portal (#354) is the foundation feature for the
|
||||
* customer-side UI surface — login, dashboard, profile, password reset,
|
||||
* and the admin Customers management page. Subordinate flags
|
||||
* (calendar, calendarBooking, quotes, bills, messaging) are already
|
||||
* present in the table from migration 088 and gate the customer-side
|
||||
* tabs that hang off the dashboard.
|
||||
*
|
||||
* Default seeding rule mirrors 088:
|
||||
* - Existing install (events table has rows) → customerPortal = TRUE.
|
||||
* The PR ships with the customer-portal foundation already wired,
|
||||
* so an admin who upgrades shouldn't see admin sidebar entries
|
||||
* vanish until they explicitly opt out from Settings → Features.
|
||||
* - Fresh install (no events) → customerPortal = FALSE. Picpeak still
|
||||
* ships as a focused gallery delivery tool by default; admins flip
|
||||
* this on when they want recurring-customer logins.
|
||||
*
|
||||
* Idempotent: skips the insert when the row already exists. Re-running
|
||||
* is a no-op.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||
|
||||
const existing = await knex('feature_flags').where({ key: 'customerPortal' }).first();
|
||||
if (existing) return;
|
||||
|
||||
// Same existing-vs-fresh detection 088 uses — count events. The flag
|
||||
// table is shared with 088's seeded keys; re-running detection keeps
|
||||
// each new feature flag in lockstep with the install state instead
|
||||
// of guessing per migration.
|
||||
const eventCountRow = await knex('events').count({ count: '*' }).first();
|
||||
const eventCount = parseInt(eventCountRow?.count || 0, 10);
|
||||
const isExistingInstall = eventCount > 0;
|
||||
|
||||
await knex('feature_flags').insert({
|
||||
key: 'customerPortal',
|
||||
value: isExistingInstall,
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||
await knex('feature_flags').where({ key: 'customerPortal' }).del();
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Migration: Backfill photo dimensions (v2)
|
||||
*
|
||||
* Re-runs the dimension backfill from migration 064 for any rows that are
|
||||
* still NULL. Migration 064 only ran once at upgrade time; new photos
|
||||
* imported via fileWatcher.js or s3AutoImporter.js between then and now
|
||||
* had their width/height columns left NULL because those code paths did
|
||||
* not capture metadata on insert. This PR fixes both writers, but
|
||||
* pre-existing rows still need a backfill — that is what this does.
|
||||
*
|
||||
* Without dimensions, MasonryGalleryLayout falls back to a hard-coded
|
||||
* 800×600 default, which is why every card in masonry mode looks like
|
||||
* the same 4:3 box (#447).
|
||||
*
|
||||
* Local-fs only — S3 deployments cannot read source objects in a
|
||||
* migration without instantiating the storage backend. Those
|
||||
* deployments rely on the writer fix in s3AutoImporter.js for new
|
||||
* photos and can run a one-shot script if a backfill is needed.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const hasWidth = await knex.schema.hasColumn('photos', 'width');
|
||||
const hasHeight = await knex.schema.hasColumn('photos', 'height');
|
||||
if (!hasWidth || !hasHeight) {
|
||||
console.log('[Migration 090] width/height columns not present, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = (process.env.STORAGE_BACKEND || 'local').toLowerCase();
|
||||
if (backend !== 'local') {
|
||||
console.log(`[Migration 090] STORAGE_BACKEND=${backend} — backfill skipped (S3 deployments not supported in-migration)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
const photos = await knex('photos')
|
||||
.where(function () {
|
||||
this.whereNull('width').orWhereNull('height');
|
||||
})
|
||||
.andWhere(function () {
|
||||
// Skip videos — sharp can't handle them; they need ffprobe.
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
})
|
||||
.select('id', 'path', 'filename');
|
||||
|
||||
if (photos.length === 0) {
|
||||
console.log('[Migration 090] no photos missing dimensions');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Migration 090] backfilling ${photos.length} photos`);
|
||||
|
||||
let sharp;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch (err) {
|
||||
console.error('[Migration 090] sharp unavailable, skipping:', err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
if (!photo.path) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.join(storagePath, 'events/active', photo.path);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
const metadata = await sharp(fullPath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
await knex('photos').where('id', photo.id).update({
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
});
|
||||
updated++;
|
||||
if (updated % 100 === 0) {
|
||||
console.log(`[Migration 090] ${updated}/${photos.length}`);
|
||||
}
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Migration 090] photo ${photo.id}: ${err.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Migration 090] done — ${updated} updated, ${failed} skipped`);
|
||||
};
|
||||
|
||||
exports.down = async function() {
|
||||
// Data-only migration; no rollback action.
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Migration: Add the `clients` top-level feature flag.
|
||||
*
|
||||
* Introduces a parent flag for the "Clients" sidebar section, which
|
||||
* groups customer accounts today and will host calendar / quotes /
|
||||
* bills / messaging in future PRs. The existing `customerPortal` flag
|
||||
* is unchanged and continues to gate the /customer/* surface plus the
|
||||
* Accounts sub-page; it now lives logically beneath `clients` in the
|
||||
* Features tab.
|
||||
*
|
||||
* Initial value: mirrors the install's current `customerPortal` value
|
||||
* so an admin who had the customer portal enabled keeps seeing the
|
||||
* Clients sidebar entry after upgrade, and an admin who had it off
|
||||
* doesn't suddenly see a new sidebar entry.
|
||||
*
|
||||
* Idempotent: re-runs are no-ops.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||
|
||||
const existing = await knex('feature_flags').where({ key: 'clients' }).first();
|
||||
if (existing) return;
|
||||
|
||||
const portalRow = await knex('feature_flags').where({ key: 'customerPortal' }).first();
|
||||
let initialValue = false;
|
||||
if (portalRow) {
|
||||
const raw = portalRow.value;
|
||||
initialValue = raw === true || raw === 1 || raw === '1' || raw === 'true';
|
||||
}
|
||||
|
||||
await knex('feature_flags').insert({ key: 'clients', value: initialValue });
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||
await knex('feature_flags').where({ key: 'clients' }).del();
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Migration: Categorise email templates + link them to feature flags.
|
||||
*
|
||||
* Adds three metadata columns to `email_templates`:
|
||||
*
|
||||
* - `category` — top-level display group in the admin Templates UI.
|
||||
* One of:
|
||||
* 'core' — gallery delivery, admin, system, backups.
|
||||
* Always visible, no feature flag.
|
||||
* 'customers' — customer-portal lifecycle (invitation, reset).
|
||||
* 'billing' — Bills feature (#354, not yet built).
|
||||
* 'quotes' — Quotes feature (#354, not yet built).
|
||||
* 'calendar' — Calendar feature (#354, not yet built).
|
||||
* Values outside this set are accepted (forward-compat) but the
|
||||
* UI will lump them under 'core' for now.
|
||||
*
|
||||
* - `subcategory` — second-level group inside `core` (which is busy
|
||||
* with 14 templates). One of:
|
||||
* 'gallery' — gallery delivery lifecycle (created / expiring /
|
||||
* expired / archived).
|
||||
* 'admin' — admin lifecycle (invitation, password reset).
|
||||
* 'backup' — DB + file backups (completed / failed) and
|
||||
* restores.
|
||||
* 'system' — version update notifications.
|
||||
* Only meaningful when category='core'; other categories ignore
|
||||
* it. NULL on rows that don't need a sub-bucket.
|
||||
*
|
||||
* - `feature_flag` — name of the feature flag whose `false` value
|
||||
* should mark this template as "Feature off" in the admin UI.
|
||||
* NULL means the template is always active (gallery delivery,
|
||||
* admin lifecycle, system notifications).
|
||||
*
|
||||
* Categorisation does NOT hide templates. Disabled-feature templates
|
||||
* stay visible and editable so admins can prep them before a feature
|
||||
* launch; the UI shows a small "Feature off" chip on the entry.
|
||||
*
|
||||
* Idempotent: re-runs are no-ops.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
|
||||
const hasCategory = await knex.schema.hasColumn('email_templates', 'category');
|
||||
if (!hasCategory) {
|
||||
await knex.schema.alterTable('email_templates', (table) => {
|
||||
// Default 'core' so existing rows aren't NULL; the backfill below
|
||||
// overrides for templates that belong to a feature group.
|
||||
table.string('category', 32).notNullable().defaultTo('core');
|
||||
});
|
||||
}
|
||||
|
||||
const hasSubcategory = await knex.schema.hasColumn('email_templates', 'subcategory');
|
||||
if (!hasSubcategory) {
|
||||
await knex.schema.alterTable('email_templates', (table) => {
|
||||
table.string('subcategory', 32).nullable();
|
||||
});
|
||||
}
|
||||
|
||||
const hasFeatureFlag = await knex.schema.hasColumn('email_templates', 'feature_flag');
|
||||
if (!hasFeatureFlag) {
|
||||
await knex.schema.alterTable('email_templates', (table) => {
|
||||
table.string('feature_flag', 64).nullable();
|
||||
});
|
||||
}
|
||||
|
||||
// Backfill — keyed by template_key so we don't accidentally update
|
||||
// a row that's been renamed. Templates not in this map keep the
|
||||
// 'core' / NULL defaults from the column definitions above.
|
||||
const TEMPLATE_METADATA = {
|
||||
// Core / Galleries — gallery delivery lifecycle.
|
||||
gallery_created: { category: 'core', subcategory: 'gallery', feature_flag: null },
|
||||
expiration_warning: { category: 'core', subcategory: 'gallery', feature_flag: null },
|
||||
gallery_expired: { category: 'core', subcategory: 'gallery', feature_flag: null },
|
||||
archive_complete: { category: 'core', subcategory: 'gallery', feature_flag: null },
|
||||
// Core / Admin — admin account lifecycle.
|
||||
admin_invitation: { category: 'core', subcategory: 'admin', feature_flag: null },
|
||||
admin_password_reset: { category: 'core', subcategory: 'admin', feature_flag: null },
|
||||
// Core / Backup — database + file backups + restores.
|
||||
database_backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
|
||||
database_backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
|
||||
restore_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
|
||||
restore_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
|
||||
backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
|
||||
backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
|
||||
// Core / System — version-update notifications.
|
||||
version_update_available: { category: 'core', subcategory: 'system', feature_flag: null },
|
||||
version_update_test: { category: 'core', subcategory: 'system', feature_flag: null },
|
||||
// Customer portal (#354). Admin-triggered password reset for
|
||||
// customer accounts ships in the same feature, so both templates
|
||||
// share the `customers` category and the `customerPortal` flag.
|
||||
// Future calendar / quotes / bills templates will land here under
|
||||
// their own categories.
|
||||
customer_invitation: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' },
|
||||
customer_password_reset: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' },
|
||||
};
|
||||
|
||||
for (const [key, meta] of Object.entries(TEMPLATE_METADATA)) {
|
||||
await knex('email_templates')
|
||||
.where({ template_key: key })
|
||||
.update({
|
||||
category: meta.category,
|
||||
subcategory: meta.subcategory,
|
||||
feature_flag: meta.feature_flag,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
|
||||
if (await knex.schema.hasColumn('email_templates', 'feature_flag')) {
|
||||
await knex.schema.alterTable('email_templates', (table) => {
|
||||
table.dropColumn('feature_flag');
|
||||
});
|
||||
}
|
||||
|
||||
if (await knex.schema.hasColumn('email_templates', 'subcategory')) {
|
||||
await knex.schema.alterTable('email_templates', (table) => {
|
||||
table.dropColumn('subcategory');
|
||||
});
|
||||
}
|
||||
|
||||
if (await knex.schema.hasColumn('email_templates', 'category')) {
|
||||
await knex.schema.alterTable('email_templates', (table) => {
|
||||
table.dropColumn('category');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,797 @@
|
||||
/**
|
||||
* Migration: Auto-fill missing email-template translations for nl / pt /
|
||||
* ru / fr — plus the en/de rows for templates that were seeded AFTER
|
||||
* migration 075 ran (customer_password_reset from 092, version_update_test
|
||||
* from 087). Those two carry their EN/DE content in the legacy
|
||||
* subject_en/body_html_en/... columns; without a row in
|
||||
* email_template_translations, the admin Templates UI shows them as
|
||||
* empty until an admin clicks save.
|
||||
*
|
||||
* Coverage going in:
|
||||
* - gallery_created / expiration_warning / gallery_expired /
|
||||
* archive_complete already had en/de/nl/pt/ru from migration 075
|
||||
* → this migration adds the missing `fr` row.
|
||||
* - admin_*, backup_*, restore_*, database_backup_*, customer_invitation,
|
||||
* version_update_available had en/de only → this migration adds
|
||||
* nl/pt/ru/fr.
|
||||
* - customer_password_reset + version_update_test had legacy-column
|
||||
* EN/DE only (post-075 inserts) → this migration adds the full
|
||||
* en/de/nl/pt/ru/fr set, sourcing en/de from the legacy columns
|
||||
* when present and falling back to the curated copy below.
|
||||
*
|
||||
* The non-EN/DE translations below were generated by an LLM and are
|
||||
* flagged in the PR description as needing native-speaker review
|
||||
* before the next stable release. en / de remain hand-translated.
|
||||
*
|
||||
* Idempotent: every insert checks (template_id, language) for an
|
||||
* existing row first and skips if present. Safe to re-run.
|
||||
*
|
||||
* Variable placeholders ({{name}}) are preserved verbatim across all
|
||||
* locales so emailProcessor's safeTemplateReplace continues to wire
|
||||
* them up unchanged.
|
||||
*/
|
||||
|
||||
const TRANSLATIONS = {
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Gallery delivery (core) — only fr is missing, the rest landed in
|
||||
// migration 075.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
gallery_created: {
|
||||
fr: {
|
||||
subject: 'Votre galerie photo est prête !',
|
||||
body_html: `<h2>Galerie créée avec succès</h2>
|
||||
<p>Bonjour {{host_name}},</p>
|
||||
<p>Votre galerie photo « {{event_name}} » a été créée avec succès !</p>
|
||||
<p><strong>Détails de la galerie :</strong></p>
|
||||
<ul>
|
||||
<li>Date de l'événement : {{event_date}}</li>
|
||||
<li>Lien de la galerie : <a href="{{gallery_link}}">{{gallery_link}}</a></li>
|
||||
<li>Mot de passe : {{gallery_password}}</li>
|
||||
<li>Expire le : {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Partagez ce lien et le mot de passe avec vos invités pour qu'ils puissent voir et télécharger les photos.</p>
|
||||
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
|
||||
body_text: `Galerie créée avec succès\n\nBonjour {{host_name}},\n\nVotre galerie photo « {{event_name}} » a été créée avec succès !\n\nLien de la galerie : {{gallery_link}}\nMot de passe : {{gallery_password}}\nExpire le : {{expiry_date}}`,
|
||||
},
|
||||
},
|
||||
|
||||
expiration_warning: {
|
||||
fr: {
|
||||
subject: 'Votre galerie photo expire bientôt',
|
||||
body_html: `<h2>La galerie expire bientôt</h2>
|
||||
<p>Bonjour {{host_name}},</p>
|
||||
<p>Votre galerie photo « {{event_name}} » expire dans {{days_remaining}} jours.</p>
|
||||
<p>Après l'expiration, la galerie sera archivée et ne sera plus accessible aux invités.</p>
|
||||
<p><a href="{{gallery_link}}">Voir la galerie</a></p>`,
|
||||
body_text: `La galerie expire bientôt\n\nBonjour {{host_name}},\n\nVotre galerie photo « {{event_name}} » expire dans {{days_remaining}} jours.\n\nGalerie : {{gallery_link}}`,
|
||||
},
|
||||
},
|
||||
|
||||
gallery_expired: {
|
||||
fr: {
|
||||
subject: 'Galerie photo expirée et archivée',
|
||||
body_html: `<h2>Galerie archivée</h2>
|
||||
<p>Bonjour {{host_name}},</p>
|
||||
<p>Votre galerie photo « {{event_name}} » a expiré et a été archivée.</p>
|
||||
<p>Les invités ne peuvent plus accéder à la galerie. Contactez votre photographe si vous avez besoin de restaurer l'accès.</p>`,
|
||||
body_text: `Galerie archivée\n\nBonjour {{host_name}},\n\nVotre galerie photo « {{event_name}} » a expiré et a été archivée.`,
|
||||
},
|
||||
},
|
||||
|
||||
archive_complete: {
|
||||
fr: {
|
||||
subject: 'Galerie archivée : {{event_name}}',
|
||||
body_html: `<h2>Galerie archivée avec succès</h2>
|
||||
<p>La galerie photo « {{event_name}} » a été archivée.</p>
|
||||
<p><strong>Détails de l'archive :</strong></p>
|
||||
<ul>
|
||||
<li>Taille de l'archive : {{archive_size}}</li>
|
||||
<li>Nombre de photos : {{photo_count}}</li>
|
||||
<li>Emplacement : {{archive_path}}</li>
|
||||
</ul>`,
|
||||
body_text: `Galerie archivée avec succès\n\nLa galerie photo « {{event_name}} » a été archivée.\n\nTaille : {{archive_size}}\nPhotos : {{photo_count}}\nEmplacement : {{archive_path}}`,
|
||||
},
|
||||
},
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Admin / RBAC — invitation + password reset. en/de exist, adding
|
||||
// nl/pt/ru/fr.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
admin_invitation: {
|
||||
nl: {
|
||||
subject: 'U bent uitgenodigd om deel te nemen aan PicPeak als {{role_name}}',
|
||||
body_html: `<h2>Welkom bij PicPeak</h2>
|
||||
<p>U bent uitgenodigd door {{inviter_name}} om deel te nemen aan PicPeak als {{role_name}}.</p>
|
||||
<p>Klik op de onderstaande link om uw account in te stellen:</p>
|
||||
<p><a href="{{invitation_link}}" class="button">Account instellen</a></p>
|
||||
<p>Deze uitnodiging verloopt op {{expires_at}}.</p>
|
||||
<p>Als u deze e-mail niet verwachtte, kunt u deze gerust negeren.</p>`,
|
||||
body_text: `Welkom bij PicPeak\n\nU bent uitgenodigd door {{inviter_name}} om deel te nemen aan PicPeak als {{role_name}}.\n\nStel uw account in: {{invitation_link}}\n\nDeze uitnodiging verloopt op {{expires_at}}.`,
|
||||
},
|
||||
pt: {
|
||||
subject: 'Você foi convidado para o PicPeak como {{role_name}}',
|
||||
body_html: `<h2>Bem-vindo(a) ao PicPeak</h2>
|
||||
<p>Você foi convidado(a) por {{inviter_name}} para participar do PicPeak como {{role_name}}.</p>
|
||||
<p>Clique no link abaixo para configurar sua conta:</p>
|
||||
<p><a href="{{invitation_link}}" class="button">Configurar conta</a></p>
|
||||
<p>Este convite expira em {{expires_at}}.</p>
|
||||
<p>Se você não esperava este e-mail, pode ignorá-lo com segurança.</p>`,
|
||||
body_text: `Bem-vindo(a) ao PicPeak\n\nVocê foi convidado(a) por {{inviter_name}} para participar do PicPeak como {{role_name}}.\n\nConfigure sua conta: {{invitation_link}}\n\nEste convite expira em {{expires_at}}.`,
|
||||
},
|
||||
ru: {
|
||||
subject: 'Вас пригласили присоединиться к PicPeak в роли {{role_name}}',
|
||||
body_html: `<h2>Добро пожаловать в PicPeak</h2>
|
||||
<p>{{inviter_name}} пригласил(а) вас присоединиться к PicPeak в роли {{role_name}}.</p>
|
||||
<p>Перейдите по ссылке ниже, чтобы настроить учётную запись:</p>
|
||||
<p><a href="{{invitation_link}}" class="button">Настроить учётную запись</a></p>
|
||||
<p>Срок действия приглашения истекает {{expires_at}}.</p>
|
||||
<p>Если вы не ожидали этого письма, можете его проигнорировать.</p>`,
|
||||
body_text: `Добро пожаловать в PicPeak\n\n{{inviter_name}} пригласил(а) вас присоединиться к PicPeak в роли {{role_name}}.\n\nНастроить учётную запись: {{invitation_link}}\n\nСрок действия приглашения истекает {{expires_at}}.`,
|
||||
},
|
||||
fr: {
|
||||
subject: 'Vous avez été invité(e) à rejoindre PicPeak en tant que {{role_name}}',
|
||||
body_html: `<h2>Bienvenue sur PicPeak</h2>
|
||||
<p>{{inviter_name}} vous a invité(e) à rejoindre PicPeak en tant que {{role_name}}.</p>
|
||||
<p>Cliquez sur le lien ci-dessous pour configurer votre compte :</p>
|
||||
<p><a href="{{invitation_link}}" class="button">Configurer le compte</a></p>
|
||||
<p>Cette invitation expire le {{expires_at}}.</p>
|
||||
<p>Si vous n'attendiez pas cet e-mail, vous pouvez l'ignorer en toute sécurité.</p>`,
|
||||
body_text: `Bienvenue sur PicPeak\n\n{{inviter_name}} vous a invité(e) à rejoindre PicPeak en tant que {{role_name}}.\n\nConfigurer le compte : {{invitation_link}}\n\nCette invitation expire le {{expires_at}}.`,
|
||||
},
|
||||
},
|
||||
|
||||
admin_password_reset: {
|
||||
nl: {
|
||||
subject: 'Uw PicPeak-administratorwachtwoord is opnieuw ingesteld',
|
||||
body_html: `<h2>Wachtwoord opnieuw ingesteld</h2>
|
||||
<p>Hallo {{admin_name}},</p>
|
||||
<p>Uw PicPeak-administratorwachtwoord is opnieuw ingesteld door {{reset_by}}.</p>
|
||||
<p>Klik op de onderstaande link om een nieuw wachtwoord in te stellen:</p>
|
||||
<p><a href="{{reset_link}}" class="button">Nieuw wachtwoord instellen</a></p>
|
||||
<p>Deze link verloopt op {{expires_at}}. Heeft u deze actie niet aangevraagd? Neem dan onmiddellijk contact op met uw teambeheerder.</p>`,
|
||||
body_text: `Wachtwoord opnieuw ingesteld\n\nHallo {{admin_name}},\n\nUw PicPeak-administratorwachtwoord is opnieuw ingesteld door {{reset_by}}.\n\nStel een nieuw wachtwoord in: {{reset_link}}\n\nDeze link verloopt op {{expires_at}}.`,
|
||||
},
|
||||
pt: {
|
||||
subject: 'Sua senha de administrador do PicPeak foi redefinida',
|
||||
body_html: `<h2>Senha redefinida</h2>
|
||||
<p>Olá {{admin_name}},</p>
|
||||
<p>Sua senha de administrador do PicPeak foi redefinida por {{reset_by}}.</p>
|
||||
<p>Clique no link abaixo para definir uma nova senha:</p>
|
||||
<p><a href="{{reset_link}}" class="button">Definir nova senha</a></p>
|
||||
<p>Este link expira em {{expires_at}}. Se você não solicitou esta ação, entre em contato com o administrador da sua equipe imediatamente.</p>`,
|
||||
body_text: `Senha redefinida\n\nOlá {{admin_name}},\n\nSua senha de administrador do PicPeak foi redefinida por {{reset_by}}.\n\nDefinir nova senha: {{reset_link}}\n\nEste link expira em {{expires_at}}.`,
|
||||
},
|
||||
ru: {
|
||||
subject: 'Ваш пароль администратора PicPeak был сброшен',
|
||||
body_html: `<h2>Пароль сброшен</h2>
|
||||
<p>Здравствуйте, {{admin_name}}!</p>
|
||||
<p>Ваш пароль администратора PicPeak был сброшен пользователем {{reset_by}}.</p>
|
||||
<p>Перейдите по ссылке ниже, чтобы задать новый пароль:</p>
|
||||
<p><a href="{{reset_link}}" class="button">Задать новый пароль</a></p>
|
||||
<p>Срок действия ссылки истекает {{expires_at}}. Если вы не запрашивали это действие, немедленно свяжитесь с администратором вашей команды.</p>`,
|
||||
body_text: `Пароль сброшен\n\nЗдравствуйте, {{admin_name}}!\n\nВаш пароль администратора PicPeak был сброшен пользователем {{reset_by}}.\n\nЗадать новый пароль: {{reset_link}}\n\nСрок действия ссылки истекает {{expires_at}}.`,
|
||||
},
|
||||
fr: {
|
||||
subject: 'Votre mot de passe administrateur PicPeak a été réinitialisé',
|
||||
body_html: `<h2>Mot de passe réinitialisé</h2>
|
||||
<p>Bonjour {{admin_name}},</p>
|
||||
<p>Votre mot de passe administrateur PicPeak a été réinitialisé par {{reset_by}}.</p>
|
||||
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :</p>
|
||||
<p><a href="{{reset_link}}" class="button">Définir un nouveau mot de passe</a></p>
|
||||
<p>Ce lien expire le {{expires_at}}. Si vous n'êtes pas à l'origine de cette demande, contactez immédiatement votre administrateur.</p>`,
|
||||
body_text: `Mot de passe réinitialisé\n\nBonjour {{admin_name}},\n\nVotre mot de passe administrateur PicPeak a été réinitialisé par {{reset_by}}.\n\nDéfinir un nouveau mot de passe : {{reset_link}}\n\nCe lien expire le {{expires_at}}.`,
|
||||
},
|
||||
},
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Customer portal (#354). customer_invitation already has hand-tuned
|
||||
// en/de from migration 090 (themed button via migration 094).
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
customer_invitation: {
|
||||
nl: {
|
||||
subject: 'U bent uitgenodigd om uw fotogalerijen te bekijken',
|
||||
body_html: `<h2>Welkom bij uw fotogalerijen</h2>
|
||||
<p>U bent uitgenodigd om een klantaccount aan te maken, zodat u al uw evenementgalerijen op één plek kunt bekijken — geen aparte links en wachtwoorden meer.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Account instellen</a>
|
||||
</div>
|
||||
<p>Deze uitnodiging verloopt op {{expires_at}}. Werkt de link niet? Kopieer hem en plak hem in uw browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>Heeft u deze e-mail niet verwacht? U kunt deze gerust negeren.</p>`,
|
||||
body_text: `Welkom bij uw fotogalerijen\n\nU bent uitgenodigd om een klantaccount aan te maken zodat u al uw galerijen op één plek kunt bekijken.\n\nAccount instellen: {{invite_link}}\n\nDeze uitnodiging verloopt op {{expires_at}}.`,
|
||||
},
|
||||
pt: {
|
||||
subject: 'Você foi convidado(a) a acessar suas galerias de fotos',
|
||||
body_html: `<h2>Bem-vindo(a) às suas galerias</h2>
|
||||
<p>Você foi convidado(a) a criar uma conta de cliente para visualizar todas as suas galerias de eventos em um só lugar — sem mais links e senhas separados.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Configurar conta</a>
|
||||
</div>
|
||||
<p>Este convite expira em {{expires_at}}. Se o link não funcionar, copie e cole-o no navegador:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>Se você não esperava este e-mail, pode ignorá-lo com segurança.</p>`,
|
||||
body_text: `Bem-vindo(a) às suas galerias\n\nVocê foi convidado(a) a criar uma conta de cliente para acessar todas as suas galerias em um só lugar.\n\nConfigurar conta: {{invite_link}}\n\nEste convite expira em {{expires_at}}.`,
|
||||
},
|
||||
ru: {
|
||||
subject: 'Вас пригласили получить доступ к вашим фотогалереям',
|
||||
body_html: `<h2>Добро пожаловать в ваши галереи</h2>
|
||||
<p>Вас пригласили создать учётную запись клиента, чтобы видеть все ваши галереи событий в одном месте — больше никаких отдельных ссылок и паролей.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Настроить учётную запись</a>
|
||||
</div>
|
||||
<p>Срок действия приглашения истекает {{expires_at}}. Если ссылка не работает, скопируйте её в адресную строку браузера:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>Если вы не ожидали этого письма, можете его проигнорировать.</p>`,
|
||||
body_text: `Добро пожаловать в ваши галереи\n\nВас пригласили создать учётную запись клиента, чтобы видеть все ваши галереи в одном месте.\n\nНастроить учётную запись: {{invite_link}}\n\nСрок действия приглашения истекает {{expires_at}}.`,
|
||||
},
|
||||
fr: {
|
||||
subject: 'Vous avez été invité(e) à accéder à vos galeries photo',
|
||||
body_html: `<h2>Bienvenue dans vos galeries photo</h2>
|
||||
<p>Vous avez été invité(e) à créer un compte client pour voir toutes vos galeries d'événements en un seul endroit — plus de liens et mots de passe séparés.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Configurer le compte</a>
|
||||
</div>
|
||||
<p>Cette invitation expire le {{expires_at}}. Si le lien ne fonctionne pas, copiez-le et collez-le dans votre navigateur :</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>Si vous n'attendiez pas cet e-mail, vous pouvez l'ignorer en toute sécurité.</p>`,
|
||||
body_text: `Bienvenue dans vos galeries photo\n\nVous avez été invité(e) à créer un compte client pour accéder à toutes vos galeries en un seul endroit.\n\nConfigurer le compte : {{invite_link}}\n\nCette invitation expire le {{expires_at}}.`,
|
||||
},
|
||||
},
|
||||
|
||||
// Customer password reset (#354 follow-up). Seeded by migration 092
|
||||
// in the legacy columns with English-only copy (the EN string was
|
||||
// broadcast to every subject_*/body_html_* column to satisfy NOT
|
||||
// NULL on multi-locale schemas). Replacing here with a proper
|
||||
// per-locale set including hand-tuned EN/DE plus AI-generated
|
||||
// nl/pt/ru/fr.
|
||||
customer_password_reset: {
|
||||
en: {
|
||||
subject: 'Reset your customer account password',
|
||||
body_html: `<p>Hello,</p>
|
||||
<p>Your photographer has triggered a password reset for your customer account.</p>
|
||||
<p><a href="{{reset_link}}" class="button">Set a new password</a></p>
|
||||
<p>This link expires on {{expires_at}}.</p>
|
||||
<p>If you didn't expect this, you can ignore the message — your current password keeps working until you click the link.</p>`,
|
||||
body_text: `Reset your customer account password\n\nYour photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`,
|
||||
},
|
||||
de: {
|
||||
subject: 'Passwort für dein Kundenkonto zurücksetzen',
|
||||
body_html: `<p>Hallo,</p>
|
||||
<p>Dein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.</p>
|
||||
<p><a href="{{reset_link}}" class="button">Neues Passwort festlegen</a></p>
|
||||
<p>Dieser Link läuft am {{expires_at}} ab.</p>
|
||||
<p>Wenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.</p>`,
|
||||
body_text: `Passwort für dein Kundenkonto zurücksetzen\n\nDein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.\n\nNeues Passwort festlegen: {{reset_link}}\n\nDieser Link läuft am {{expires_at}} ab.\n\nWenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.`,
|
||||
},
|
||||
nl: {
|
||||
subject: 'Wachtwoord van uw klantaccount opnieuw instellen',
|
||||
body_html: `<p>Hallo,</p>
|
||||
<p>Uw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.</p>
|
||||
<p><a href="{{reset_link}}" class="button">Nieuw wachtwoord instellen</a></p>
|
||||
<p>Deze link verloopt op {{expires_at}}.</p>
|
||||
<p>Heeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.</p>`,
|
||||
body_text: `Wachtwoord opnieuw instellen\n\nUw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.\n\nNieuw wachtwoord instellen: {{reset_link}}\n\nDeze link verloopt op {{expires_at}}.\n\nHeeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.`,
|
||||
},
|
||||
pt: {
|
||||
subject: 'Redefina a senha da sua conta de cliente',
|
||||
body_html: `<p>Olá,</p>
|
||||
<p>Seu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.</p>
|
||||
<p><a href="{{reset_link}}" class="button">Definir nova senha</a></p>
|
||||
<p>Este link expira em {{expires_at}}.</p>
|
||||
<p>Se você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.</p>`,
|
||||
body_text: `Redefinir senha\n\nSeu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.\n\nDefinir nova senha: {{reset_link}}\n\nEste link expira em {{expires_at}}.\n\nSe você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.`,
|
||||
},
|
||||
ru: {
|
||||
subject: 'Сброс пароля вашей клиентской учётной записи',
|
||||
body_html: `<p>Здравствуйте!</p>
|
||||
<p>Ваш фотограф инициировал сброс пароля для вашей клиентской учётной записи.</p>
|
||||
<p><a href="{{reset_link}}" class="button">Задать новый пароль</a></p>
|
||||
<p>Срок действия ссылки истекает {{expires_at}}.</p>
|
||||
<p>Если вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.</p>`,
|
||||
body_text: `Сброс пароля\n\nВаш фотограф инициировал сброс пароля для вашей клиентской учётной записи.\n\nЗадать новый пароль: {{reset_link}}\n\nСрок действия ссылки истекает {{expires_at}}.\n\nЕсли вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.`,
|
||||
},
|
||||
fr: {
|
||||
subject: 'Réinitialisez le mot de passe de votre compte client',
|
||||
body_html: `<p>Bonjour,</p>
|
||||
<p>Votre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.</p>
|
||||
<p><a href="{{reset_link}}" class="button">Définir un nouveau mot de passe</a></p>
|
||||
<p>Ce lien expire le {{expires_at}}.</p>
|
||||
<p>Si vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.</p>`,
|
||||
body_text: `Réinitialiser le mot de passe\n\nVotre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.\n\nDéfinir un nouveau mot de passe : {{reset_link}}\n\nCe lien expire le {{expires_at}}.\n\nSi vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.`,
|
||||
},
|
||||
},
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Database backups
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
database_backup_completed: {
|
||||
nl: {
|
||||
subject: '[PicPeak] Database-back-up succesvol',
|
||||
body_html: `<h2>Database-back-up voltooid</h2>
|
||||
<p>De geplande database-back-up is succesvol voltooid.</p>
|
||||
<ul>
|
||||
<li>Tijdstip: {{completed_at}}</li>
|
||||
<li>Grootte: {{backup_size}}</li>
|
||||
<li>Locatie: {{backup_path}}</li>
|
||||
</ul>`,
|
||||
body_text: `Database-back-up voltooid\n\nTijdstip: {{completed_at}}\nGrootte: {{backup_size}}\nLocatie: {{backup_path}}`,
|
||||
},
|
||||
pt: {
|
||||
subject: '[PicPeak] Backup do banco de dados concluído',
|
||||
body_html: `<h2>Backup do banco de dados concluído</h2>
|
||||
<p>O backup agendado do banco de dados foi concluído com sucesso.</p>
|
||||
<ul>
|
||||
<li>Horário: {{completed_at}}</li>
|
||||
<li>Tamanho: {{backup_size}}</li>
|
||||
<li>Localização: {{backup_path}}</li>
|
||||
</ul>`,
|
||||
body_text: `Backup do banco de dados concluído\n\nHorário: {{completed_at}}\nTamanho: {{backup_size}}\nLocalização: {{backup_path}}`,
|
||||
},
|
||||
ru: {
|
||||
subject: '[PicPeak] Резервная копия БД успешно создана',
|
||||
body_html: `<h2>Резервная копия базы данных создана</h2>
|
||||
<p>Запланированное резервное копирование базы данных успешно завершено.</p>
|
||||
<ul>
|
||||
<li>Время: {{completed_at}}</li>
|
||||
<li>Размер: {{backup_size}}</li>
|
||||
<li>Расположение: {{backup_path}}</li>
|
||||
</ul>`,
|
||||
body_text: `Резервная копия базы данных создана\n\nВремя: {{completed_at}}\nРазмер: {{backup_size}}\nРасположение: {{backup_path}}`,
|
||||
},
|
||||
fr: {
|
||||
subject: '[PicPeak] Sauvegarde de la base de données réussie',
|
||||
body_html: `<h2>Sauvegarde de la base de données terminée</h2>
|
||||
<p>La sauvegarde planifiée de la base de données s'est terminée avec succès.</p>
|
||||
<ul>
|
||||
<li>Heure : {{completed_at}}</li>
|
||||
<li>Taille : {{backup_size}}</li>
|
||||
<li>Emplacement : {{backup_path}}</li>
|
||||
</ul>`,
|
||||
body_text: `Sauvegarde de la base de données terminée\n\nHeure : {{completed_at}}\nTaille : {{backup_size}}\nEmplacement : {{backup_path}}`,
|
||||
},
|
||||
},
|
||||
|
||||
database_backup_failed: {
|
||||
nl: {
|
||||
subject: '[PicPeak] Database-back-up MISLUKT',
|
||||
body_html: `<h2 style="color:#b91c1c;">Database-back-up mislukt</h2>
|
||||
<p>De geplande database-back-up is mislukt en moet handmatig worden onderzocht.</p>
|
||||
<ul>
|
||||
<li>Tijdstip: {{failed_at}}</li>
|
||||
<li>Foutmelding: {{error_message}}</li>
|
||||
</ul>
|
||||
<p>Controleer de serverlogboeken voor meer details.</p>`,
|
||||
body_text: `Database-back-up mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}\n\nControleer de serverlogboeken voor meer details.`,
|
||||
},
|
||||
pt: {
|
||||
subject: '[PicPeak] FALHA no backup do banco de dados',
|
||||
body_html: `<h2 style="color:#b91c1c;">Falha no backup do banco de dados</h2>
|
||||
<p>O backup agendado do banco de dados falhou e precisa de investigação manual.</p>
|
||||
<ul>
|
||||
<li>Horário: {{failed_at}}</li>
|
||||
<li>Erro: {{error_message}}</li>
|
||||
</ul>
|
||||
<p>Verifique os logs do servidor para mais detalhes.</p>`,
|
||||
body_text: `Falha no backup do banco de dados\n\nHorário: {{failed_at}}\nErro: {{error_message}}\n\nVerifique os logs do servidor.`,
|
||||
},
|
||||
ru: {
|
||||
subject: '[PicPeak] ОШИБКА резервного копирования БД',
|
||||
body_html: `<h2 style="color:#b91c1c;">Ошибка резервного копирования базы данных</h2>
|
||||
<p>Запланированное резервное копирование базы данных завершилось с ошибкой и требует ручной проверки.</p>
|
||||
<ul>
|
||||
<li>Время: {{failed_at}}</li>
|
||||
<li>Ошибка: {{error_message}}</li>
|
||||
</ul>
|
||||
<p>Проверьте журналы сервера для получения дополнительной информации.</p>`,
|
||||
body_text: `Ошибка резервного копирования базы данных\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}\n\nПроверьте журналы сервера.`,
|
||||
},
|
||||
fr: {
|
||||
subject: '[PicPeak] ÉCHEC de la sauvegarde de la base de données',
|
||||
body_html: `<h2 style="color:#b91c1c;">Échec de la sauvegarde de la base de données</h2>
|
||||
<p>La sauvegarde planifiée de la base de données a échoué et nécessite une investigation manuelle.</p>
|
||||
<ul>
|
||||
<li>Heure : {{failed_at}}</li>
|
||||
<li>Erreur : {{error_message}}</li>
|
||||
</ul>
|
||||
<p>Consultez les journaux du serveur pour plus de détails.</p>`,
|
||||
body_text: `Échec de la sauvegarde de la base de données\n\nHeure : {{failed_at}}\nErreur : {{error_message}}\n\nConsultez les journaux du serveur.`,
|
||||
},
|
||||
},
|
||||
|
||||
restore_completed: {
|
||||
nl: {
|
||||
subject: '[PicPeak] Database-herstel succesvol',
|
||||
body_html: `<h2>Database-herstel voltooid</h2>
|
||||
<p>De handmatige database-herstel-operatie is succesvol voltooid.</p>
|
||||
<ul>
|
||||
<li>Tijdstip: {{completed_at}}</li>
|
||||
<li>Herstelpunt: {{source_backup}}</li>
|
||||
</ul>`,
|
||||
body_text: `Database-herstel voltooid\n\nTijdstip: {{completed_at}}\nHerstelpunt: {{source_backup}}`,
|
||||
},
|
||||
pt: {
|
||||
subject: '[PicPeak] Restauração do banco de dados concluída',
|
||||
body_html: `<h2>Restauração concluída</h2>
|
||||
<p>A restauração manual do banco de dados foi concluída com sucesso.</p>
|
||||
<ul>
|
||||
<li>Horário: {{completed_at}}</li>
|
||||
<li>Origem: {{source_backup}}</li>
|
||||
</ul>`,
|
||||
body_text: `Restauração concluída\n\nHorário: {{completed_at}}\nOrigem: {{source_backup}}`,
|
||||
},
|
||||
ru: {
|
||||
subject: '[PicPeak] Восстановление БД успешно завершено',
|
||||
body_html: `<h2>Восстановление базы данных завершено</h2>
|
||||
<p>Ручная операция восстановления базы данных успешно завершена.</p>
|
||||
<ul>
|
||||
<li>Время: {{completed_at}}</li>
|
||||
<li>Точка восстановления: {{source_backup}}</li>
|
||||
</ul>`,
|
||||
body_text: `Восстановление базы данных завершено\n\nВремя: {{completed_at}}\nТочка восстановления: {{source_backup}}`,
|
||||
},
|
||||
fr: {
|
||||
subject: '[PicPeak] Restauration de la base de données réussie',
|
||||
body_html: `<h2>Restauration de la base terminée</h2>
|
||||
<p>L'opération manuelle de restauration de la base de données s'est terminée avec succès.</p>
|
||||
<ul>
|
||||
<li>Heure : {{completed_at}}</li>
|
||||
<li>Source : {{source_backup}}</li>
|
||||
</ul>`,
|
||||
body_text: `Restauration terminée\n\nHeure : {{completed_at}}\nSource : {{source_backup}}`,
|
||||
},
|
||||
},
|
||||
|
||||
restore_failed: {
|
||||
nl: {
|
||||
subject: '[PicPeak] Database-herstel MISLUKT',
|
||||
body_html: `<h2 style="color:#b91c1c;">Database-herstel mislukt</h2>
|
||||
<p>De handmatige database-herstel-operatie is mislukt en moet handmatig worden onderzocht.</p>
|
||||
<ul>
|
||||
<li>Tijdstip: {{failed_at}}</li>
|
||||
<li>Foutmelding: {{error_message}}</li>
|
||||
</ul>`,
|
||||
body_text: `Database-herstel mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}`,
|
||||
},
|
||||
pt: {
|
||||
subject: '[PicPeak] FALHA na restauração do banco de dados',
|
||||
body_html: `<h2 style="color:#b91c1c;">Falha na restauração</h2>
|
||||
<p>A restauração manual do banco de dados falhou e precisa de investigação.</p>
|
||||
<ul>
|
||||
<li>Horário: {{failed_at}}</li>
|
||||
<li>Erro: {{error_message}}</li>
|
||||
</ul>`,
|
||||
body_text: `Falha na restauração\n\nHorário: {{failed_at}}\nErro: {{error_message}}`,
|
||||
},
|
||||
ru: {
|
||||
subject: '[PicPeak] ОШИБКА восстановления БД',
|
||||
body_html: `<h2 style="color:#b91c1c;">Ошибка восстановления базы данных</h2>
|
||||
<p>Ручная операция восстановления базы данных завершилась с ошибкой и требует проверки.</p>
|
||||
<ul>
|
||||
<li>Время: {{failed_at}}</li>
|
||||
<li>Ошибка: {{error_message}}</li>
|
||||
</ul>`,
|
||||
body_text: `Ошибка восстановления базы данных\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}`,
|
||||
},
|
||||
fr: {
|
||||
subject: '[PicPeak] ÉCHEC de la restauration de la base de données',
|
||||
body_html: `<h2 style="color:#b91c1c;">Échec de la restauration</h2>
|
||||
<p>L'opération manuelle de restauration de la base de données a échoué et nécessite une investigation.</p>
|
||||
<ul>
|
||||
<li>Heure : {{failed_at}}</li>
|
||||
<li>Erreur : {{error_message}}</li>
|
||||
</ul>`,
|
||||
body_text: `Échec de la restauration\n\nHeure : {{failed_at}}\nErreur : {{error_message}}`,
|
||||
},
|
||||
},
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// File backups (legacy backup_completed / backup_failed pair).
|
||||
// Mostly identical content to database_backup_* but kept separate
|
||||
// because the legacy keys are still wired to a different code path.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
backup_completed: {
|
||||
nl: {
|
||||
subject: '[PicPeak] Bestandsback-up voltooid',
|
||||
body_html: `<h2>Bestandsback-up voltooid</h2>
|
||||
<p>De geplande bestandsback-up is succesvol voltooid.</p>
|
||||
<ul>
|
||||
<li>Tijdstip: {{completed_at}}</li>
|
||||
<li>Grootte: {{backup_size}}</li>
|
||||
<li>Locatie: {{backup_path}}</li>
|
||||
</ul>`,
|
||||
body_text: `Bestandsback-up voltooid\n\nTijdstip: {{completed_at}}\nGrootte: {{backup_size}}\nLocatie: {{backup_path}}`,
|
||||
},
|
||||
pt: {
|
||||
subject: '[PicPeak] Backup de arquivos concluído',
|
||||
body_html: `<h2>Backup de arquivos concluído</h2>
|
||||
<p>O backup agendado de arquivos foi concluído com sucesso.</p>
|
||||
<ul>
|
||||
<li>Horário: {{completed_at}}</li>
|
||||
<li>Tamanho: {{backup_size}}</li>
|
||||
<li>Localização: {{backup_path}}</li>
|
||||
</ul>`,
|
||||
body_text: `Backup de arquivos concluído\n\nHorário: {{completed_at}}\nTamanho: {{backup_size}}\nLocalização: {{backup_path}}`,
|
||||
},
|
||||
ru: {
|
||||
subject: '[PicPeak] Резервное копирование файлов завершено',
|
||||
body_html: `<h2>Резервное копирование файлов завершено</h2>
|
||||
<p>Запланированное резервное копирование файлов успешно завершено.</p>
|
||||
<ul>
|
||||
<li>Время: {{completed_at}}</li>
|
||||
<li>Размер: {{backup_size}}</li>
|
||||
<li>Расположение: {{backup_path}}</li>
|
||||
</ul>`,
|
||||
body_text: `Резервное копирование файлов завершено\n\nВремя: {{completed_at}}\nРазмер: {{backup_size}}\nРасположение: {{backup_path}}`,
|
||||
},
|
||||
fr: {
|
||||
subject: '[PicPeak] Sauvegarde des fichiers terminée',
|
||||
body_html: `<h2>Sauvegarde des fichiers terminée</h2>
|
||||
<p>La sauvegarde planifiée des fichiers s'est terminée avec succès.</p>
|
||||
<ul>
|
||||
<li>Heure : {{completed_at}}</li>
|
||||
<li>Taille : {{backup_size}}</li>
|
||||
<li>Emplacement : {{backup_path}}</li>
|
||||
</ul>`,
|
||||
body_text: `Sauvegarde des fichiers terminée\n\nHeure : {{completed_at}}\nTaille : {{backup_size}}\nEmplacement : {{backup_path}}`,
|
||||
},
|
||||
},
|
||||
|
||||
backup_failed: {
|
||||
nl: {
|
||||
subject: '[PicPeak] Bestandsback-up MISLUKT',
|
||||
body_html: `<h2 style="color:#b91c1c;">Bestandsback-up mislukt</h2>
|
||||
<p>De geplande bestandsback-up is mislukt en moet worden onderzocht.</p>
|
||||
<ul>
|
||||
<li>Tijdstip: {{failed_at}}</li>
|
||||
<li>Foutmelding: {{error_message}}</li>
|
||||
</ul>`,
|
||||
body_text: `Bestandsback-up mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}`,
|
||||
},
|
||||
pt: {
|
||||
subject: '[PicPeak] FALHA no backup de arquivos',
|
||||
body_html: `<h2 style="color:#b91c1c;">Falha no backup de arquivos</h2>
|
||||
<p>O backup agendado de arquivos falhou e precisa de investigação.</p>
|
||||
<ul>
|
||||
<li>Horário: {{failed_at}}</li>
|
||||
<li>Erro: {{error_message}}</li>
|
||||
</ul>`,
|
||||
body_text: `Falha no backup de arquivos\n\nHorário: {{failed_at}}\nErro: {{error_message}}`,
|
||||
},
|
||||
ru: {
|
||||
subject: '[PicPeak] ОШИБКА резервного копирования файлов',
|
||||
body_html: `<h2 style="color:#b91c1c;">Ошибка резервного копирования файлов</h2>
|
||||
<p>Запланированное резервное копирование файлов завершилось с ошибкой и требует проверки.</p>
|
||||
<ul>
|
||||
<li>Время: {{failed_at}}</li>
|
||||
<li>Ошибка: {{error_message}}</li>
|
||||
</ul>`,
|
||||
body_text: `Ошибка резервного копирования файлов\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}`,
|
||||
},
|
||||
fr: {
|
||||
subject: '[PicPeak] ÉCHEC de la sauvegarde des fichiers',
|
||||
body_html: `<h2 style="color:#b91c1c;">Échec de la sauvegarde des fichiers</h2>
|
||||
<p>La sauvegarde planifiée des fichiers a échoué et nécessite une investigation.</p>
|
||||
<ul>
|
||||
<li>Heure : {{failed_at}}</li>
|
||||
<li>Erreur : {{error_message}}</li>
|
||||
</ul>`,
|
||||
body_text: `Échec de la sauvegarde des fichiers\n\nHeure : {{failed_at}}\nErreur : {{error_message}}`,
|
||||
},
|
||||
},
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Version update notifications
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
version_update_available: {
|
||||
nl: {
|
||||
subject: 'PicPeak-update beschikbaar: versie {{new_version}}',
|
||||
body_html: `<h2>Nieuwe PicPeak-versie beschikbaar</h2>
|
||||
<p>Er is een nieuwe versie van PicPeak beschikbaar.</p>
|
||||
<ul>
|
||||
<li>Huidige versie: {{current_version}}</li>
|
||||
<li>Nieuwe versie: {{new_version}}</li>
|
||||
<li>Releasekanaal: {{channel}}</li>
|
||||
</ul>
|
||||
<p><a href="{{release_url}}" class="button">Release-notities bekijken</a></p>`,
|
||||
body_text: `Nieuwe PicPeak-versie beschikbaar\n\nHuidige versie: {{current_version}}\nNieuwe versie: {{new_version}}\nReleasekanaal: {{channel}}\n\nRelease-notities: {{release_url}}`,
|
||||
},
|
||||
pt: {
|
||||
subject: 'Atualização do PicPeak disponível: versão {{new_version}}',
|
||||
body_html: `<h2>Nova versão do PicPeak disponível</h2>
|
||||
<p>Uma nova versão do PicPeak está disponível.</p>
|
||||
<ul>
|
||||
<li>Versão atual: {{current_version}}</li>
|
||||
<li>Nova versão: {{new_version}}</li>
|
||||
<li>Canal: {{channel}}</li>
|
||||
</ul>
|
||||
<p><a href="{{release_url}}" class="button">Ver notas da versão</a></p>`,
|
||||
body_text: `Nova versão do PicPeak disponível\n\nVersão atual: {{current_version}}\nNova versão: {{new_version}}\nCanal: {{channel}}\n\nNotas da versão: {{release_url}}`,
|
||||
},
|
||||
ru: {
|
||||
subject: 'Доступно обновление PicPeak: версия {{new_version}}',
|
||||
body_html: `<h2>Доступна новая версия PicPeak</h2>
|
||||
<p>Появилась новая версия PicPeak.</p>
|
||||
<ul>
|
||||
<li>Текущая версия: {{current_version}}</li>
|
||||
<li>Новая версия: {{new_version}}</li>
|
||||
<li>Канал выпуска: {{channel}}</li>
|
||||
</ul>
|
||||
<p><a href="{{release_url}}" class="button">Посмотреть примечания к выпуску</a></p>`,
|
||||
body_text: `Доступна новая версия PicPeak\n\nТекущая версия: {{current_version}}\nНовая версия: {{new_version}}\nКанал: {{channel}}\n\nПримечания к выпуску: {{release_url}}`,
|
||||
},
|
||||
fr: {
|
||||
subject: 'Mise à jour PicPeak disponible : version {{new_version}}',
|
||||
body_html: `<h2>Nouvelle version de PicPeak disponible</h2>
|
||||
<p>Une nouvelle version de PicPeak est disponible.</p>
|
||||
<ul>
|
||||
<li>Version actuelle : {{current_version}}</li>
|
||||
<li>Nouvelle version : {{new_version}}</li>
|
||||
<li>Canal : {{channel}}</li>
|
||||
</ul>
|
||||
<p><a href="{{release_url}}" class="button">Voir les notes de version</a></p>`,
|
||||
body_text: `Nouvelle version de PicPeak disponible\n\nVersion actuelle : {{current_version}}\nNouvelle version : {{new_version}}\nCanal : {{channel}}\n\nNotes de version : {{release_url}}`,
|
||||
},
|
||||
},
|
||||
|
||||
// version_update_test was seeded by migration 087 in the legacy
|
||||
// subject_en / body_html_en / subject_de / body_html_de columns
|
||||
// AFTER migration 075 had already migrated existing rows into
|
||||
// email_template_translations — so this template has zero
|
||||
// translation rows even though the EN/DE content exists. The
|
||||
// Templates admin UI consequently shows it as empty. Seeding the
|
||||
// full set here (mirroring 087's curated EN/DE plus AI-generated
|
||||
// nl/pt/ru/fr) restores the editor.
|
||||
version_update_test: {
|
||||
en: {
|
||||
subject: '[TEST] PicPeak Update Notification — configuration check',
|
||||
body_html: `<h2>This is a test email</h2>
|
||||
<p>You are receiving this message because an administrator clicked
|
||||
<strong>Send Test Email</strong> on the Update Notifications page of your
|
||||
PicPeak installation.</p>
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Installed version:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Channel:</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Recipient address:</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
<p>If you can read this email, your SMTP configuration and the recipient
|
||||
list are working correctly. When a real new version becomes available,
|
||||
PicPeak will send a separate notification with release notes and update
|
||||
instructions.</p>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">No action is required.
|
||||
You may safely delete this message.</p>`,
|
||||
body_text: `This is a test email\n\nYou are receiving this message because an administrator clicked "Send Test Email" on the Update Notifications page of your PicPeak installation.\n\nInstalled version: {{current_version}}\nChannel: {{channel}}\nRecipient address: {{recipient_email}}\n\nIf you can read this email, your SMTP configuration and the recipient list are working correctly. When a real new version becomes available, PicPeak will send a separate notification with release notes and update instructions.\n\nNo action is required. You may safely delete this message.`,
|
||||
},
|
||||
de: {
|
||||
subject: '[TEST] PicPeak Update-Benachrichtigung — Konfigurationsprüfung',
|
||||
body_html: `<h2>Dies ist eine Test-E-Mail</h2>
|
||||
<p>Sie erhalten diese Nachricht, weil ein Administrator auf der Seite
|
||||
„Update-Benachrichtigungen" Ihrer PicPeak-Installation auf
|
||||
<strong>Test-E-Mail senden</strong> geklickt hat.</p>
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Installierte Version:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Kanal:</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Empfänger-Adresse:</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
<p>Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration
|
||||
und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar
|
||||
ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen
|
||||
und Update-Anweisungen.</p>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">Es ist keine Aktion
|
||||
erforderlich. Sie können diese Nachricht gefahrlos löschen.</p>`,
|
||||
body_text: `Dies ist eine Test-E-Mail\n\nSie erhalten diese Nachricht, weil ein Administrator auf der Seite „Update-Benachrichtigungen" Ihrer PicPeak-Installation auf „Test-E-Mail senden" geklickt hat.\n\nInstallierte Version: {{current_version}}\nKanal: {{channel}}\nEmpfänger-Adresse: {{recipient_email}}\n\nWenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen und Update-Anweisungen.\n\nEs ist keine Aktion erforderlich. Sie können diese Nachricht gefahrlos löschen.`,
|
||||
},
|
||||
nl: {
|
||||
subject: '[TEST] PicPeak-update-melding — configuratiecontrole',
|
||||
body_html: `<h2>Dit is een test-e-mail</h2>
|
||||
<p>U ontvangt dit bericht omdat een beheerder op de pagina
|
||||
"Update-meldingen" van uw PicPeak-installatie op
|
||||
<strong>Test-e-mail verzenden</strong> heeft geklikt.</p>
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Geïnstalleerde versie:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Kanaal:</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Ontvangeradres:</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
<p>Als u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct. Wanneer er een echte nieuwe versie beschikbaar komt, stuurt PicPeak een aparte melding met release-notities en update-instructies.</p>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">Geen actie vereist. U kunt dit bericht veilig verwijderen.</p>`,
|
||||
body_text: `Dit is een test-e-mail\n\nU ontvangt dit bericht omdat een beheerder op de pagina "Update-meldingen" van uw PicPeak-installatie op "Test-e-mail verzenden" heeft geklikt.\n\nGeïnstalleerde versie: {{current_version}}\nKanaal: {{channel}}\nOntvangeradres: {{recipient_email}}\n\nAls u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct.\n\nGeen actie vereist.`,
|
||||
},
|
||||
pt: {
|
||||
subject: '[TESTE] Notificação de atualização do PicPeak — verificação',
|
||||
body_html: `<h2>Este é um e-mail de teste</h2>
|
||||
<p>Você está recebendo esta mensagem porque um administrador clicou em
|
||||
<strong>Enviar e-mail de teste</strong> na página "Notificações de
|
||||
atualização" da sua instalação do PicPeak.</p>
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Versão instalada:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Canal:</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Endereço do destinatário:</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
<p>Se você consegue ler este e-mail, sua configuração SMTP e a lista de destinatários estão funcionando corretamente. Quando uma nova versão real estiver disponível, o PicPeak enviará uma notificação separada com notas de versão e instruções de atualização.</p>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">Nenhuma ação é necessária. Você pode excluir esta mensagem com segurança.</p>`,
|
||||
body_text: `Este é um e-mail de teste\n\nVocê está recebendo esta mensagem porque um administrador clicou em "Enviar e-mail de teste" na página "Notificações de atualização" da sua instalação do PicPeak.\n\nVersão instalada: {{current_version}}\nCanal: {{channel}}\nEndereço do destinatário: {{recipient_email}}\n\nSe você consegue ler este e-mail, sua configuração SMTP está funcionando corretamente.\n\nNenhuma ação é necessária.`,
|
||||
},
|
||||
ru: {
|
||||
subject: '[ТЕСТ] Уведомление об обновлениях PicPeak — проверка',
|
||||
body_html: `<h2>Это тестовое письмо</h2>
|
||||
<p>Вы получили это сообщение, потому что администратор нажал
|
||||
<strong>Отправить тестовое письмо</strong> на странице
|
||||
«Уведомления об обновлениях» вашей установки PicPeak.</p>
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Установленная версия:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Канал:</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Адрес получателя:</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
<p>Если вы видите это письмо, значит ваша конфигурация SMTP и список получателей работают корректно. Когда станет доступна новая версия, PicPeak отправит отдельное уведомление с примечаниями к выпуску и инструкциями по обновлению.</p>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">Никаких действий не требуется. Можете безопасно удалить это сообщение.</p>`,
|
||||
body_text: `Это тестовое письмо\n\nВы получили это сообщение, потому что администратор нажал «Отправить тестовое письмо» на странице «Уведомления об обновлениях» вашей установки PicPeak.\n\nУстановленная версия: {{current_version}}\nКанал: {{channel}}\nАдрес получателя: {{recipient_email}}\n\nЕсли вы видите это письмо, ваша конфигурация SMTP работает корректно.\n\nНикаких действий не требуется.`,
|
||||
},
|
||||
fr: {
|
||||
subject: '[TEST] Notification de mise à jour PicPeak — vérification',
|
||||
body_html: `<h2>Ceci est un e-mail de test</h2>
|
||||
<p>Vous recevez ce message parce qu'un administrateur a cliqué sur
|
||||
<strong>Envoyer un e-mail de test</strong> sur la page « Notifications
|
||||
de mise à jour » de votre installation PicPeak.</p>
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Version installée :</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Canal :</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Adresse du destinataire :</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
<p>Si vous pouvez lire cet e-mail, votre configuration SMTP et la liste des destinataires fonctionnent correctement. Lorsqu'une nouvelle version réelle sera disponible, PicPeak enverra une notification distincte avec les notes de version et les instructions de mise à jour.</p>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">Aucune action n'est requise. Vous pouvez supprimer ce message en toute sécurité.</p>`,
|
||||
body_text: `Ceci est un e-mail de test\n\nVous recevez ce message parce qu'un administrateur a cliqué sur « Envoyer un e-mail de test » sur la page « Notifications de mise à jour » de votre installation PicPeak.\n\nVersion installée : {{current_version}}\nCanal : {{channel}}\nAdresse du destinataire : {{recipient_email}}\n\nSi vous pouvez lire cet e-mail, votre configuration SMTP fonctionne correctement.\n\nAucune action n'est requise.`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
if (!(await knex.schema.hasTable('email_template_translations'))) return;
|
||||
|
||||
// Resolve template_key → id once, skip keys that aren't seeded on
|
||||
// this install (e.g. customer_invitation on a pre-090 instance).
|
||||
const rows = await knex('email_templates')
|
||||
.whereIn('template_key', Object.keys(TRANSLATIONS))
|
||||
.select('id', 'template_key');
|
||||
const keyToId = Object.fromEntries(rows.map((r) => [r.template_key, r.id]));
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const [key, perLocale] of Object.entries(TRANSLATIONS)) {
|
||||
const templateId = keyToId[key];
|
||||
if (!templateId) {
|
||||
// Template not present on this install (older release than the
|
||||
// seeding migration). Skip — there's nothing to attach to.
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [language, content] of Object.entries(perLocale)) {
|
||||
const existing = await knex('email_template_translations')
|
||||
.where({ template_id: templateId, language })
|
||||
.first();
|
||||
if (existing) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await knex('email_template_translations').insert({
|
||||
template_id: templateId,
|
||||
language,
|
||||
subject: content.subject,
|
||||
body_html: content.body_html,
|
||||
body_text: content.body_text,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`099_seed_missing_email_template_translations: inserted=${inserted}, skipped=${skipped}`);
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Down-migration intentionally a no-op. We don't know which of the
|
||||
// locale rows existed before this migration vs were inserted by it —
|
||||
// dropping every nl/pt/ru/fr row would wipe content the admin may
|
||||
// have edited in the UI. Rollback by hand if you really need to.
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Migration: Re-apply email-template backfills that earlier deployed
|
||||
* versions of 098 / 099 missed.
|
||||
*
|
||||
* Why a separate migration? Knex tracks migrations by filename — once
|
||||
* 098 / 099 were recorded as applied in `knex_migrations`, editing
|
||||
* them doesn't re-run on subsequent deploys. The first deployed
|
||||
* versions of those migrations didn't include:
|
||||
* - the `subcategory` column population (added later)
|
||||
* - the `customer_password_reset` category override (added later)
|
||||
* - the en/de/nl/pt/ru/fr translation rows for
|
||||
* `customer_password_reset` and `version_update_test` (both
|
||||
* inserted after 075 ran, so they sat in legacy columns only
|
||||
* and showed empty in the Templates editor — the AI translations
|
||||
* were added to 099 after its first deploy).
|
||||
*
|
||||
* This migration is append-only (no schema change beyond defensive
|
||||
* column checks) and re-applies all the affected data:
|
||||
* 1. Sets category / subcategory / feature_flag on every known
|
||||
* template_key.
|
||||
* 2. Seeds missing translation rows for the two post-075
|
||||
* templates across all six locales.
|
||||
*
|
||||
* Idempotent throughout: skips translation inserts that already
|
||||
* exist, and the category writes are no-ops when values already match.
|
||||
*/
|
||||
|
||||
const TEMPLATE_METADATA = {
|
||||
// Core / Galleries — gallery delivery lifecycle.
|
||||
gallery_created: { category: 'core', subcategory: 'gallery', feature_flag: null },
|
||||
expiration_warning: { category: 'core', subcategory: 'gallery', feature_flag: null },
|
||||
gallery_expired: { category: 'core', subcategory: 'gallery', feature_flag: null },
|
||||
archive_complete: { category: 'core', subcategory: 'gallery', feature_flag: null },
|
||||
// Core / Admin — admin account lifecycle.
|
||||
admin_invitation: { category: 'core', subcategory: 'admin', feature_flag: null },
|
||||
admin_password_reset: { category: 'core', subcategory: 'admin', feature_flag: null },
|
||||
// Core / Backup — database + file backups + restores.
|
||||
database_backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
|
||||
database_backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
|
||||
restore_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
|
||||
restore_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
|
||||
backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
|
||||
backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
|
||||
// Core / System — version-update notifications.
|
||||
version_update_available: { category: 'core', subcategory: 'system', feature_flag: null },
|
||||
version_update_test: { category: 'core', subcategory: 'system', feature_flag: null },
|
||||
// Customers — customer-portal lifecycle.
|
||||
customer_invitation: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' },
|
||||
customer_password_reset: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' },
|
||||
};
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
|
||||
// Defensive: if migration 098 didn't run for some reason on this
|
||||
// install (a fork, a partial copy, etc.) make sure the columns
|
||||
// exist before we try to write to them. Idempotent — these are
|
||||
// no-ops if the column already exists.
|
||||
const cols = await knex('email_templates').columnInfo();
|
||||
if (!cols.category) {
|
||||
await knex.schema.alterTable('email_templates', (t) => {
|
||||
t.string('category', 32).notNullable().defaultTo('core');
|
||||
});
|
||||
}
|
||||
if (!cols.subcategory) {
|
||||
await knex.schema.alterTable('email_templates', (t) => {
|
||||
t.string('subcategory', 32).nullable();
|
||||
});
|
||||
}
|
||||
if (!cols.feature_flag) {
|
||||
await knex.schema.alterTable('email_templates', (t) => {
|
||||
t.string('feature_flag', 64).nullable();
|
||||
});
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
for (const [key, meta] of Object.entries(TEMPLATE_METADATA)) {
|
||||
const result = await knex('email_templates')
|
||||
.where({ template_key: key })
|
||||
.update({
|
||||
category: meta.category,
|
||||
subcategory: meta.subcategory,
|
||||
feature_flag: meta.feature_flag,
|
||||
});
|
||||
if (result > 0) updated += 1;
|
||||
}
|
||||
console.log(`100_backfill_email_template_subcategory: updated ${updated} template rows`);
|
||||
|
||||
// ── Translation backfill ──────────────────────────────────────────
|
||||
// customer_password_reset (migration 092) and version_update_test
|
||||
// (migration 087) were inserted AFTER migration 075 ran, so they
|
||||
// have content in legacy subject_*/body_html_* columns but zero
|
||||
// rows in `email_template_translations`. The Templates editor
|
||||
// reads exclusively from the translations table → shows them as
|
||||
// 0/6 empty until we seed them. Migration 099 added these rows on
|
||||
// initial deploy, but the earlier-shipped version of 099 didn't
|
||||
// include them, so instances that ran it then-and-now still have
|
||||
// empty editors. Re-seed defensively here, skipping any
|
||||
// (template_id, language) pair that already exists.
|
||||
if (!(await knex.schema.hasTable('email_template_translations'))) return;
|
||||
|
||||
const TRANSLATIONS = {
|
||||
customer_password_reset: {
|
||||
en: {
|
||||
subject: 'Reset your customer account password',
|
||||
body_html: `<p>Hello,</p>
|
||||
<p>Your photographer has triggered a password reset for your customer account.</p>
|
||||
<p><a href="{{reset_link}}" class="button">Set a new password</a></p>
|
||||
<p>This link expires on {{expires_at}}.</p>
|
||||
<p>If you didn't expect this, you can ignore the message — your current password keeps working until you click the link.</p>`,
|
||||
body_text: `Reset your customer account password\n\nYour photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`,
|
||||
},
|
||||
de: {
|
||||
subject: 'Passwort für dein Kundenkonto zurücksetzen',
|
||||
body_html: `<p>Hallo,</p>
|
||||
<p>Dein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.</p>
|
||||
<p><a href="{{reset_link}}" class="button">Neues Passwort festlegen</a></p>
|
||||
<p>Dieser Link läuft am {{expires_at}} ab.</p>
|
||||
<p>Wenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.</p>`,
|
||||
body_text: `Passwort für dein Kundenkonto zurücksetzen\n\nDein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.\n\nNeues Passwort festlegen: {{reset_link}}\n\nDieser Link läuft am {{expires_at}} ab.\n\nWenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.`,
|
||||
},
|
||||
nl: {
|
||||
subject: 'Wachtwoord van uw klantaccount opnieuw instellen',
|
||||
body_html: `<p>Hallo,</p>
|
||||
<p>Uw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.</p>
|
||||
<p><a href="{{reset_link}}" class="button">Nieuw wachtwoord instellen</a></p>
|
||||
<p>Deze link verloopt op {{expires_at}}.</p>
|
||||
<p>Heeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.</p>`,
|
||||
body_text: `Wachtwoord opnieuw instellen\n\nUw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.\n\nNieuw wachtwoord instellen: {{reset_link}}\n\nDeze link verloopt op {{expires_at}}.\n\nHeeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.`,
|
||||
},
|
||||
pt: {
|
||||
subject: 'Redefina a senha da sua conta de cliente',
|
||||
body_html: `<p>Olá,</p>
|
||||
<p>Seu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.</p>
|
||||
<p><a href="{{reset_link}}" class="button">Definir nova senha</a></p>
|
||||
<p>Este link expira em {{expires_at}}.</p>
|
||||
<p>Se você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.</p>`,
|
||||
body_text: `Redefinir senha\n\nSeu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.\n\nDefinir nova senha: {{reset_link}}\n\nEste link expira em {{expires_at}}.\n\nSe você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.`,
|
||||
},
|
||||
ru: {
|
||||
subject: 'Сброс пароля вашей клиентской учётной записи',
|
||||
body_html: `<p>Здравствуйте!</p>
|
||||
<p>Ваш фотограф инициировал сброс пароля для вашей клиентской учётной записи.</p>
|
||||
<p><a href="{{reset_link}}" class="button">Задать новый пароль</a></p>
|
||||
<p>Срок действия ссылки истекает {{expires_at}}.</p>
|
||||
<p>Если вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.</p>`,
|
||||
body_text: `Сброс пароля\n\nВаш фотограф инициировал сброс пароля для вашей клиентской учётной записи.\n\nЗадать новый пароль: {{reset_link}}\n\nСрок действия ссылки истекает {{expires_at}}.\n\nЕсли вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.`,
|
||||
},
|
||||
fr: {
|
||||
subject: 'Réinitialisez le mot de passe de votre compte client',
|
||||
body_html: `<p>Bonjour,</p>
|
||||
<p>Votre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.</p>
|
||||
<p><a href="{{reset_link}}" class="button">Définir un nouveau mot de passe</a></p>
|
||||
<p>Ce lien expire le {{expires_at}}.</p>
|
||||
<p>Si vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.</p>`,
|
||||
body_text: `Réinitialiser le mot de passe\n\nVotre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.\n\nDéfinir un nouveau mot de passe : {{reset_link}}\n\nCe lien expire le {{expires_at}}.\n\nSi vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.`,
|
||||
},
|
||||
},
|
||||
version_update_test: {
|
||||
en: {
|
||||
subject: '[TEST] PicPeak Update Notification — configuration check',
|
||||
body_html: `<h2>This is a test email</h2>
|
||||
<p>You are receiving this message because an administrator clicked
|
||||
<strong>Send Test Email</strong> on the Update Notifications page of your
|
||||
PicPeak installation.</p>
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Installed version:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Channel:</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Recipient address:</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
<p>If you can read this email, your SMTP configuration and the recipient
|
||||
list are working correctly. When a real new version becomes available,
|
||||
PicPeak will send a separate notification with release notes and update
|
||||
instructions.</p>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">No action is required.
|
||||
You may safely delete this message.</p>`,
|
||||
body_text: `This is a test email\n\nYou are receiving this message because an administrator clicked "Send Test Email" on the Update Notifications page of your PicPeak installation.\n\nInstalled version: {{current_version}}\nChannel: {{channel}}\nRecipient address: {{recipient_email}}\n\nIf you can read this email, your SMTP configuration and the recipient list are working correctly. When a real new version becomes available, PicPeak will send a separate notification with release notes and update instructions.\n\nNo action is required. You may safely delete this message.`,
|
||||
},
|
||||
de: {
|
||||
subject: '[TEST] PicPeak Update-Benachrichtigung — Konfigurationsprüfung',
|
||||
body_html: `<h2>Dies ist eine Test-E-Mail</h2>
|
||||
<p>Sie erhalten diese Nachricht, weil ein Administrator auf der Seite
|
||||
„Update-Benachrichtigungen" Ihrer PicPeak-Installation auf
|
||||
<strong>Test-E-Mail senden</strong> geklickt hat.</p>
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Installierte Version:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Kanal:</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Empfänger-Adresse:</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
<p>Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration
|
||||
und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar
|
||||
ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen
|
||||
und Update-Anweisungen.</p>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">Es ist keine Aktion
|
||||
erforderlich. Sie können diese Nachricht gefahrlos löschen.</p>`,
|
||||
body_text: `Dies ist eine Test-E-Mail\n\nSie erhalten diese Nachricht, weil ein Administrator auf der Seite „Update-Benachrichtigungen" Ihrer PicPeak-Installation auf „Test-E-Mail senden" geklickt hat.\n\nInstallierte Version: {{current_version}}\nKanal: {{channel}}\nEmpfänger-Adresse: {{recipient_email}}\n\nWenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen und Update-Anweisungen.\n\nEs ist keine Aktion erforderlich. Sie können diese Nachricht gefahrlos löschen.`,
|
||||
},
|
||||
nl: {
|
||||
subject: '[TEST] PicPeak-update-melding — configuratiecontrole',
|
||||
body_html: `<h2>Dit is een test-e-mail</h2>
|
||||
<p>U ontvangt dit bericht omdat een beheerder op de pagina
|
||||
"Update-meldingen" van uw PicPeak-installatie op
|
||||
<strong>Test-e-mail verzenden</strong> heeft geklikt.</p>
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Geïnstalleerde versie:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Kanaal:</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Ontvangeradres:</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
<p>Als u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct. Wanneer er een echte nieuwe versie beschikbaar komt, stuurt PicPeak een aparte melding met release-notities en update-instructies.</p>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">Geen actie vereist. U kunt dit bericht veilig verwijderen.</p>`,
|
||||
body_text: `Dit is een test-e-mail\n\nU ontvangt dit bericht omdat een beheerder op de pagina "Update-meldingen" van uw PicPeak-installatie op "Test-e-mail verzenden" heeft geklikt.\n\nGeïnstalleerde versie: {{current_version}}\nKanaal: {{channel}}\nOntvangeradres: {{recipient_email}}\n\nAls u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct.\n\nGeen actie vereist.`,
|
||||
},
|
||||
pt: {
|
||||
subject: '[TESTE] Notificação de atualização do PicPeak — verificação',
|
||||
body_html: `<h2>Este é um e-mail de teste</h2>
|
||||
<p>Você está recebendo esta mensagem porque um administrador clicou em
|
||||
<strong>Enviar e-mail de teste</strong> na página "Notificações de
|
||||
atualização" da sua instalação do PicPeak.</p>
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Versão instalada:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Canal:</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Endereço do destinatário:</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
<p>Se você consegue ler este e-mail, sua configuração SMTP e a lista de destinatários estão funcionando corretamente. Quando uma nova versão real estiver disponível, o PicPeak enviará uma notificação separada com notas de versão e instruções de atualização.</p>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">Nenhuma ação é necessária. Você pode excluir esta mensagem com segurança.</p>`,
|
||||
body_text: `Este é um e-mail de teste\n\nVocê está recebendo esta mensagem porque um administrador clicou em "Enviar e-mail de teste" na página "Notificações de atualização" da sua instalação do PicPeak.\n\nVersão instalada: {{current_version}}\nCanal: {{channel}}\nEndereço do destinatário: {{recipient_email}}\n\nSe você consegue ler este e-mail, sua configuração SMTP está funcionando corretamente.\n\nNenhuma ação é necessária.`,
|
||||
},
|
||||
ru: {
|
||||
subject: '[ТЕСТ] Уведомление об обновлениях PicPeak — проверка',
|
||||
body_html: `<h2>Это тестовое письмо</h2>
|
||||
<p>Вы получили это сообщение, потому что администратор нажал
|
||||
<strong>Отправить тестовое письмо</strong> на странице
|
||||
«Уведомления об обновлениях» вашей установки PicPeak.</p>
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Установленная версия:</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Канал:</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Адрес получателя:</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
<p>Если вы видите это письмо, значит ваша конфигурация SMTP и список получателей работают корректно. Когда станет доступна новая версия, PicPeak отправит отдельное уведомление с примечаниями к выпуску и инструкциями по обновлению.</p>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">Никаких действий не требуется. Можете безопасно удалить это сообщение.</p>`,
|
||||
body_text: `Это тестовое письмо\n\nВы получили это сообщение, потому что администратор нажал «Отправить тестовое письмо» на странице «Уведомления об обновлениях» вашей установки PicPeak.\n\nУстановленная версия: {{current_version}}\nКанал: {{channel}}\nАдрес получателя: {{recipient_email}}\n\nЕсли вы видите это письмо, ваша конфигурация SMTP работает корректно.\n\nНикаких действий не требуется.`,
|
||||
},
|
||||
fr: {
|
||||
subject: '[TEST] Notification de mise à jour PicPeak — vérification',
|
||||
body_html: `<h2>Ceci est un e-mail de test</h2>
|
||||
<p>Vous recevez ce message parce qu'un administrateur a cliqué sur
|
||||
<strong>Envoyer un e-mail de test</strong> sur la page « Notifications
|
||||
de mise à jour » de votre installation PicPeak.</p>
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Version installée :</strong> {{current_version}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Canal :</strong> {{channel}}</p>
|
||||
<p style="margin: 10px 0 0 0;"><strong>Adresse du destinataire :</strong> {{recipient_email}}</p>
|
||||
</div>
|
||||
<p>Si vous pouvez lire cet e-mail, votre configuration SMTP et la liste des destinataires fonctionnent correctement. Lorsqu'une nouvelle version réelle sera disponible, PicPeak enverra une notification distincte avec les notes de version et les instructions de mise à jour.</p>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">Aucune action n'est requise. Vous pouvez supprimer ce message en toute sécurité.</p>`,
|
||||
body_text: `Ceci est un e-mail de test\n\nVous recevez ce message parce qu'un administrateur a cliqué sur « Envoyer un e-mail de test » sur la page « Notifications de mise à jour » de votre installation PicPeak.\n\nVersion installée : {{current_version}}\nCanal : {{channel}}\nAdresse du destinataire : {{recipient_email}}\n\nSi vous pouvez lire cet e-mail, votre configuration SMTP fonctionne correctement.\n\nAucune action n'est requise.`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const keyRows = await knex('email_templates')
|
||||
.whereIn('template_key', Object.keys(TRANSLATIONS))
|
||||
.select('id', 'template_key');
|
||||
const keyToId = Object.fromEntries(keyRows.map((r) => [r.template_key, r.id]));
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
for (const [key, perLocale] of Object.entries(TRANSLATIONS)) {
|
||||
const templateId = keyToId[key];
|
||||
if (!templateId) continue;
|
||||
for (const [language, content] of Object.entries(perLocale)) {
|
||||
const existing = await knex('email_template_translations')
|
||||
.where({ template_id: templateId, language })
|
||||
.first();
|
||||
if (existing) { skipped += 1; continue; }
|
||||
await knex('email_template_translations').insert({
|
||||
template_id: templateId,
|
||||
language,
|
||||
subject: content.subject,
|
||||
body_html: content.body_html,
|
||||
body_text: content.body_text,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
console.log(`100_backfill_email_template_subcategory: translation rows inserted=${inserted}, skipped=${skipped}`);
|
||||
};
|
||||
|
||||
exports.down = async function() {
|
||||
// No-op. This migration is a data backfill — rolling it back would
|
||||
// require restoring the previous values, which we don't track.
|
||||
// Migration 098's down handler still owns dropping the columns.
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Migration: Add `customer_gallery_assigned` email template.
|
||||
*
|
||||
* Sent when an admin adds new gallery assignments to an existing
|
||||
* customer via the "Manage galleries" dialog on the customer detail
|
||||
* page. Digest-style — one email per save listing every newly added
|
||||
* gallery, not one email per gallery (admins often set up new clients
|
||||
* by adding several galleries in a single sitting).
|
||||
*
|
||||
* Variables:
|
||||
* - customer_name greeting name (display name / first name / email local)
|
||||
* - gallery_count integer (string) — number of newly added galleries
|
||||
* - singular "true" when count === 1 (drives intro wording)
|
||||
* - multiple "true" when count > 1
|
||||
* - gallery_list_html pre-rendered <ul> with names + dates; passes
|
||||
* through unescaped because the service builds it
|
||||
* from trusted DB fields (event_name from
|
||||
* admin-owned rows + server-rendered dates).
|
||||
* - gallery_list_text newline-separated plain-text equivalent for
|
||||
* the text/plain body.
|
||||
* - dashboard_link URL of /customer/dashboard on the configured
|
||||
* frontend origin.
|
||||
*
|
||||
* Category + flag: 'customers' + customerPortal — categorisation
|
||||
* scaffold from migration 098. When the customer portal flag is off,
|
||||
* the Templates admin UI chips this card "Feature off" but it's still
|
||||
* editable.
|
||||
*
|
||||
* Translations: en + de hand-translated; nl/pt/ru/fr machine-generated
|
||||
* and flagged for native review per project convention.
|
||||
*
|
||||
* Idempotent: skips if the template_key already exists.
|
||||
*/
|
||||
|
||||
const TRANSLATIONS = {
|
||||
en: {
|
||||
subject: 'New gallery access on your account',
|
||||
body_html: `<h2>You have new gallery access</h2>
|
||||
<p>Hi {{customer_name}},</p>
|
||||
{{#if singular}}<p>Your photographer just gave you access to a new gallery on your account:</p>{{/if}}{{#if multiple}}<p>Your photographer just gave you access to {{gallery_count}} new galleries on your account:</p>{{/if}}
|
||||
{{gallery_list_html}}
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{dashboard_link}}" class="button">Open your dashboard</a>
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #666;">If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||
body_text: `You have new gallery access
|
||||
|
||||
Hi {{customer_name}},
|
||||
|
||||
Your photographer just gave you access to {{gallery_count}} new gallery (or galleries) on your account:
|
||||
|
||||
{{gallery_list_text}}
|
||||
|
||||
Open your dashboard: {{dashboard_link}}`,
|
||||
},
|
||||
de: {
|
||||
subject: 'Neue Galerie in deinem Konto verfügbar',
|
||||
body_html: `<h2>Du hast Zugriff auf neue Galerien</h2>
|
||||
<p>Hallo {{customer_name}},</p>
|
||||
{{#if singular}}<p>Dein Fotograf hat dir gerade Zugriff auf eine neue Galerie in deinem Konto gegeben:</p>{{/if}}{{#if multiple}}<p>Dein Fotograf hat dir gerade Zugriff auf {{gallery_count}} neue Galerien in deinem Konto gegeben:</p>{{/if}}
|
||||
{{gallery_list_html}}
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{dashboard_link}}" class="button">Zum Dashboard</a>
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #666;">Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
|
||||
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||
body_text: `Du hast Zugriff auf neue Galerien
|
||||
|
||||
Hallo {{customer_name}},
|
||||
|
||||
Dein Fotograf hat dir gerade Zugriff auf {{gallery_count}} neue Galerie(n) in deinem Konto gegeben:
|
||||
|
||||
{{gallery_list_text}}
|
||||
|
||||
Zum Dashboard: {{dashboard_link}}`,
|
||||
},
|
||||
fr: {
|
||||
subject: 'Nouvel accès galerie sur votre compte',
|
||||
body_html: `<h2>Vous avez accès à de nouvelles galeries</h2>
|
||||
<p>Bonjour {{customer_name}},</p>
|
||||
{{#if singular}}<p>Votre photographe vient de vous donner accès à une nouvelle galerie sur votre compte :</p>{{/if}}{{#if multiple}}<p>Votre photographe vient de vous donner accès à {{gallery_count}} nouvelles galeries sur votre compte :</p>{{/if}}
|
||||
{{gallery_list_html}}
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{dashboard_link}}" class="button">Ouvrir mon tableau de bord</a>
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #666;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
|
||||
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||
body_text: `Vous avez accès à de nouvelles galeries
|
||||
|
||||
Bonjour {{customer_name}},
|
||||
|
||||
Votre photographe vient de vous donner accès à {{gallery_count}} nouvelle(s) galerie(s) sur votre compte :
|
||||
|
||||
{{gallery_list_text}}
|
||||
|
||||
Tableau de bord : {{dashboard_link}}`,
|
||||
},
|
||||
nl: {
|
||||
subject: 'Nieuwe galerij toegevoegd aan uw account',
|
||||
body_html: `<h2>U heeft toegang tot nieuwe galerijen</h2>
|
||||
<p>Hallo {{customer_name}},</p>
|
||||
{{#if singular}}<p>Uw fotograaf heeft u zojuist toegang gegeven tot een nieuwe galerij in uw account:</p>{{/if}}{{#if multiple}}<p>Uw fotograaf heeft u zojuist toegang gegeven tot {{gallery_count}} nieuwe galerijen in uw account:</p>{{/if}}
|
||||
{{gallery_list_html}}
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{dashboard_link}}" class="button">Open uw dashboard</a>
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #666;">Werkt de knop niet? Kopieer dan deze link in uw browser:<br>
|
||||
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||
body_text: `U heeft toegang tot nieuwe galerijen
|
||||
|
||||
Hallo {{customer_name}},
|
||||
|
||||
Uw fotograaf heeft u zojuist toegang gegeven tot {{gallery_count}} nieuwe galerij(en) in uw account:
|
||||
|
||||
{{gallery_list_text}}
|
||||
|
||||
Dashboard: {{dashboard_link}}`,
|
||||
},
|
||||
pt: {
|
||||
subject: 'Nova galeria disponível em sua conta',
|
||||
body_html: `<h2>Você tem acesso a novas galerias</h2>
|
||||
<p>Olá {{customer_name}},</p>
|
||||
{{#if singular}}<p>Seu fotógrafo acabou de lhe dar acesso a uma nova galeria em sua conta:</p>{{/if}}{{#if multiple}}<p>Seu fotógrafo acabou de lhe dar acesso a {{gallery_count}} novas galerias em sua conta:</p>{{/if}}
|
||||
{{gallery_list_html}}
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{dashboard_link}}" class="button">Abrir meu painel</a>
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #666;">Se o botão não funcionar, copie este link no navegador:<br>
|
||||
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||
body_text: `Você tem acesso a novas galerias
|
||||
|
||||
Olá {{customer_name}},
|
||||
|
||||
Seu fotógrafo acabou de lhe dar acesso a {{gallery_count}} nova(s) galeria(s) em sua conta:
|
||||
|
||||
{{gallery_list_text}}
|
||||
|
||||
Painel: {{dashboard_link}}`,
|
||||
},
|
||||
ru: {
|
||||
subject: 'Новая галерея доступна в вашем аккаунте',
|
||||
body_html: `<h2>У вас новый доступ к галереям</h2>
|
||||
<p>Здравствуйте, {{customer_name}}!</p>
|
||||
{{#if singular}}<p>Ваш фотограф только что предоставил вам доступ к новой галерее в вашем аккаунте:</p>{{/if}}{{#if multiple}}<p>Ваш фотограф только что предоставил вам доступ к {{gallery_count}} новым галереям в вашем аккаунте:</p>{{/if}}
|
||||
{{gallery_list_html}}
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{dashboard_link}}" class="button">Открыть мой кабинет</a>
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #666;">Если кнопка не работает, скопируйте эту ссылку в браузер:<br>
|
||||
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||
body_text: `У вас новый доступ к галереям
|
||||
|
||||
Здравствуйте, {{customer_name}}!
|
||||
|
||||
Ваш фотограф только что предоставил вам доступ к {{gallery_count}} новым галереям в вашем аккаунте:
|
||||
|
||||
{{gallery_list_text}}
|
||||
|
||||
Личный кабинет: {{dashboard_link}}`,
|
||||
},
|
||||
};
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
|
||||
const existing = await knex('email_templates')
|
||||
.where({ template_key: 'customer_gallery_assigned' })
|
||||
.first();
|
||||
if (existing) {
|
||||
console.log(' customer_gallery_assigned template already exists, skipping insert');
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect schema variant (legacy per-column vs normalized translations).
|
||||
// Newer installs have the email_template_translations table from
|
||||
// migration 075; older ones might still have subject_en/de/... columns
|
||||
// and NOT NULL constraints on the legacy columns. Cover both.
|
||||
const cols = await knex('email_templates').columnInfo();
|
||||
const hasTranslationsTable = await knex.schema.hasTable('email_template_translations');
|
||||
|
||||
const enContent = TRANSLATIONS.en;
|
||||
|
||||
// Build the master row. category/subcategory/feature_flag columns
|
||||
// were added in migration 098 — guard so this migration works on
|
||||
// a slightly older install too.
|
||||
const masterRow = {
|
||||
template_key: 'customer_gallery_assigned',
|
||||
variables: JSON.stringify([
|
||||
'customer_name',
|
||||
'gallery_count',
|
||||
'singular',
|
||||
'multiple',
|
||||
'gallery_list_html',
|
||||
'gallery_list_text',
|
||||
'dashboard_link',
|
||||
]),
|
||||
};
|
||||
if ('category' in cols) masterRow.category = 'customers';
|
||||
if ('subcategory' in cols) masterRow.subcategory = null;
|
||||
if ('feature_flag' in cols) masterRow.feature_flag = 'customerPortal';
|
||||
if ('created_at' in cols) masterRow.created_at = new Date();
|
||||
if ('updated_at' in cols) masterRow.updated_at = new Date();
|
||||
|
||||
// Populate any legacy subject_*/body_html_*/body_text_* columns the
|
||||
// schema still carries. Fallback content for non-en locales is the
|
||||
// English string — the translations table below has the real
|
||||
// per-locale copy. This only matters if the install hasn't run
|
||||
// migration 075 yet, which is rare but possible.
|
||||
for (const colName of Object.keys(cols)) {
|
||||
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
|
||||
masterRow[colName] = enContent.subject;
|
||||
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
|
||||
masterRow[colName] = enContent.body_html;
|
||||
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
|
||||
masterRow[colName] = enContent.body_text;
|
||||
}
|
||||
}
|
||||
|
||||
const inserted = await knex('email_templates').insert(masterRow).returning('id');
|
||||
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
if (hasTranslationsTable && templateId) {
|
||||
for (const [language, content] of Object.entries(TRANSLATIONS)) {
|
||||
await knex('email_template_translations').insert({
|
||||
template_id: templateId,
|
||||
language,
|
||||
subject: content.subject,
|
||||
body_html: content.body_html,
|
||||
body_text: content.body_text,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' customer_gallery_assigned template inserted with 6 translations');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
await knex('email_templates').where({ template_key: 'customer_gallery_assigned' }).del();
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Migration: Per-event opt-in for using the gallery hero photo as the
|
||||
* Open Graph share image (#474).
|
||||
*
|
||||
* Background: galleryOgService already serves OG/Twitter Card meta
|
||||
* tags to social-crawler User-Agents (WhatsApp, Facebook, Slack,
|
||||
* Telegram, Discord, etc.) for /gallery/<slug> URLs — see
|
||||
* frontend/nginx.conf and backend/src/services/galleryOgService.js.
|
||||
* Today the og:image is always the brand logo, with the inline
|
||||
* rationale "no protected photo content."
|
||||
*
|
||||
* #474 asks for a hero/cover photo preview. The trade-off is that
|
||||
* the og:image is fetched unauthenticated by every link-preview
|
||||
* crawler, so any opted-in image is effectively public. We ship
|
||||
* this as a per-event boolean, default FALSE, so existing galleries
|
||||
* never start surfacing photos until the admin consciously flips it
|
||||
* on per gallery.
|
||||
*
|
||||
* When set to TRUE and the event has a hero_photo_id with a
|
||||
* generated thumbnail, galleryOgService points og:image at the new
|
||||
* /og/gallery/:slug/cover endpoint. With it set to FALSE (or no
|
||||
* hero photo selected) the brand logo is used as before.
|
||||
*
|
||||
* Idempotent: re-runs are no-ops.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('events'))) return;
|
||||
if (await knex.schema.hasColumn('events', 'og_image_share_enabled')) return;
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
// Default false everywhere so an upgrade never starts leaking the
|
||||
// hero photo of a password-protected gallery without admin intent.
|
||||
table.boolean('og_image_share_enabled').notNullable().defaultTo(false);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('events'))) return;
|
||||
if (!(await knex.schema.hasColumn('events', 'og_image_share_enabled'))) return;
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('og_image_share_enabled');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Migration: Promotional banner text alignment (#482).
|
||||
*
|
||||
* Adds `branding_promo_alignment` to app_settings — admin-controlled
|
||||
* horizontal alignment for the gallery promotional banner content
|
||||
* (#440 / #482). Reuses the existing app_settings shape that
|
||||
* branding_promo_markdown / branding_promo_position already use.
|
||||
*
|
||||
* Default 'center' so the banner aligns with the gallery footer
|
||||
* (which is full-width center-aligned). The previous default left
|
||||
* the markdown left-aligned in a max-w-3xl block, which Rekoo-PS
|
||||
* reported as visually offset from the footer.
|
||||
*
|
||||
* Allowed values: 'left' | 'center' | 'right' — validated on the
|
||||
* write path in adminSettings.js, not enforced by the column type
|
||||
* (we use varchar instead of CHECK so the value can be extended
|
||||
* later — e.g. 'justify' — without another schema migration).
|
||||
*
|
||||
* Idempotent: skips the insert when the row already exists.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('app_settings'))) return;
|
||||
|
||||
const existing = await knex('app_settings')
|
||||
.where('setting_key', 'branding_promo_alignment')
|
||||
.first();
|
||||
if (existing) return;
|
||||
|
||||
await knex('app_settings').insert({
|
||||
setting_key: 'branding_promo_alignment',
|
||||
setting_value: JSON.stringify('center'),
|
||||
setting_type: 'branding',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('app_settings'))) return;
|
||||
await knex('app_settings')
|
||||
.where('setting_key', 'branding_promo_alignment')
|
||||
.del();
|
||||
};
|
||||
Generated
+336
-260
@@ -1,20 +1,19 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.5.0",
|
||||
"version": "3.42.2-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.5.0",
|
||||
"version": "3.42.2-beta.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.12.2",
|
||||
"axios": "1.15.2",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -26,7 +25,6 @@
|
||||
"express-validator": "^7.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"form-data": "^4.0.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.0.0",
|
||||
"i18next": "25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -39,13 +37,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 +57,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 +1605,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 +2574,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 +2593,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 +2705,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 +3549,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 +3640,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,14 +4020,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"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",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
@@ -4290,9 +4239,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 +4464,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",
|
||||
@@ -4969,12 +4924,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
|
||||
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.12"
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
@@ -5143,7 +5098,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 +5524,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 +5624,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 +5742,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 +5792,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 +5803,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 +5951,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.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -6030,9 +5995,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",
|
||||
@@ -6417,27 +6382,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
"neo-async": "^2.6.2",
|
||||
"source-map": "^0.6.1",
|
||||
"wordwrap": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"handlebars": "bin/handlebars"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"uglify-js": "^3.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -6656,12 +6600,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
|
||||
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.6.tgz",
|
||||
"integrity": "sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-fetch": "4.0.0"
|
||||
"cross-fetch": "4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
@@ -6787,9 +6744,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": {
|
||||
@@ -7977,9 +7934,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": {
|
||||
@@ -8000,6 +7957,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",
|
||||
@@ -8012,6 +7976,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",
|
||||
@@ -8043,6 +8014,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",
|
||||
@@ -8558,9 +8535,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",
|
||||
@@ -8616,12 +8593,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/neo-async": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.85.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
|
||||
@@ -8760,9 +8731,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"
|
||||
@@ -9006,6 +8977,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",
|
||||
@@ -9145,6 +9123,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",
|
||||
@@ -9185,12 +9178,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",
|
||||
@@ -9293,9 +9280,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9385,9 +9372,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -9579,10 +9566,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
@@ -10356,6 +10346,7 @@
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -10659,9 +10650,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",
|
||||
@@ -10759,10 +10750,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",
|
||||
@@ -11033,19 +11089,6 @@
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uglifyjs": "bin/uglifyjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
@@ -11146,9 +11189,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"
|
||||
@@ -11319,12 +11362,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wordwrap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
@@ -11472,6 +11509,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",
|
||||
@@ -11536,6 +11582,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",
|
||||
|
||||
+16
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.6.1",
|
||||
"version": "3.49.5-beta.0",
|
||||
"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.12.2",
|
||||
"axios": "1.15.2",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -31,7 +31,6 @@
|
||||
"express-validator": "^7.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"form-data": "^4.0.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.0.0",
|
||||
"i18next": "25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -44,13 +43,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 +68,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);
|
||||
});
|
||||
+179
-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,21 @@ 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,
|
||||
handleGalleryOgCover,
|
||||
} = require('./src/services/galleryOgService');
|
||||
app.get('/og/gallery/:slug', handleGalleryOgRequest);
|
||||
// Public hero-photo cover served as og:image when the admin has flipped
|
||||
// events.og_image_share_enabled (#474). Unauthenticated by design;
|
||||
// returns 404 unless the opt-in is on AND a hero_photo_id is set.
|
||||
app.get('/og/gallery/:slug/cover', handleGalleryOgCover);
|
||||
|
||||
// robots.txt endpoint (dynamic, served from DB settings)
|
||||
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
|
||||
app.get('/robots.txt', async (req, res) => {
|
||||
@@ -461,21 +530,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 +559,15 @@ 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/feature-flags', require('./src/routes/adminFeatureFlags'));
|
||||
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'));
|
||||
@@ -501,9 +576,69 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
||||
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'));
|
||||
// Customer portal (#354). The customerPortal feature flag is a
|
||||
// VISIBILITY toggle for the admin surface, not a kill switch for
|
||||
// customer access. Enforcement:
|
||||
//
|
||||
// 1. Frontend: RequireFeature guards + AdminSidebar visibility
|
||||
// hide the Clients section when the flag is off. Customer-side
|
||||
// /customer/* surfaces stay reachable.
|
||||
// 2. Backend: NO route-level gate. The admin surface is gated by
|
||||
// adminAuth + permission checks (admin still has rights to
|
||||
// manage customer records even if the section is hidden in
|
||||
// their UI). The customer surface is gated by customerAuth +
|
||||
// is_active checks on customer_accounts.
|
||||
//
|
||||
// For close-to-realtime access changes use the dedicated tools:
|
||||
// - Revoke a customer's access to ONE gallery → "Manage galleries"
|
||||
// dialog removes the event_customer_assignments row, which
|
||||
// verifyGalleryAccess re-checks on every customer-minted JWT.
|
||||
// - Lock out a customer entirely → "Deactivate" sets is_active=false
|
||||
// and bumps password_changed_at, killing every outstanding JWT.
|
||||
// - Toggle per-customer feature surfaces (calendar/quotes/bills)
|
||||
// → toggles on the customer detail page.
|
||||
//
|
||||
// Putting the global flag in the kill-switch role was a mistake — a
|
||||
// stray click in Settings → Features would lock every paying
|
||||
// customer out at once. PR-revert moved the gate back to per-record.
|
||||
//
|
||||
// `noStoreCache` belt-and-braces the cache-control story for both
|
||||
// surfaces: any response — 200, 4xx, 5xx — carries `Cache-Control:
|
||||
// no-store` so a transient error (the now-reverted #458 410, a
|
||||
// permission flip mid-session, a backend restart) can't get pinned
|
||||
// in browser or intermediate caches and outlive its cause. See the
|
||||
// PR #458 → #470 history in the middleware file for context.
|
||||
const { noStoreCache } = require('./src/middleware/noStoreCache');
|
||||
app.use('/api/admin/customers', noStoreCache, require('./src/routes/adminCustomers'));
|
||||
// Customer-side surface (#354). Strictly separate from /api/admin/* —
|
||||
// distinct token type, distinct cookie, distinct middleware.
|
||||
app.use('/api/customer/auth', noStoreCache, require('./src/routes/customerAuth'));
|
||||
app.use('/api/customer', noStoreCache, require('./src/routes/customer'));
|
||||
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
||||
app.use('/api/admin/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 +660,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 +695,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 +725,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'}`);
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Unit tests for customerAccountsService (#354).
|
||||
*
|
||||
* The service touches the DB in most call sites, so we mock the knex
|
||||
* builder. The point of these tests is to catch the assignment-diff
|
||||
* logic and the invitation guards — not to integration-test knex.
|
||||
*/
|
||||
|
||||
// --- mocks --------------------------------------------------------------
|
||||
jest.mock('../database/db', () => {
|
||||
const mockDb = jest.fn();
|
||||
mockDb.transaction = jest.fn(async (fn) => fn(mockDb));
|
||||
return { db: mockDb, logActivity: jest.fn() };
|
||||
});
|
||||
jest.mock('../utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
jest.mock('../services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../utils/passwordValidation', () => ({
|
||||
getBcryptRounds: () => 4, // fast for tests
|
||||
}));
|
||||
// frontendUrl is resolved against app_settings; mock the helper directly
|
||||
// so the test doesn't need to also stub the settings query.
|
||||
jest.mock('../utils/frontendUrl', () => ({
|
||||
getFrontendBaseUrl: jest.fn().mockResolvedValue('https://example.test'),
|
||||
}));
|
||||
jest.mock('../utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
|
||||
// Helper to make a chainable query builder mock that resolves to `result`.
|
||||
const chain = (result) => {
|
||||
const q = {};
|
||||
['where', 'whereNull', 'whereNot', 'whereRaw', 'whereIn', 'andWhere',
|
||||
'select', 'leftJoin', 'join', 'orderBy', 'limit', 'groupBy', 'first']
|
||||
.forEach((m) => { q[m] = jest.fn().mockReturnValue(q); });
|
||||
q.first = jest.fn().mockResolvedValue(result?.first);
|
||||
q.del = jest.fn().mockResolvedValue(result?.del ?? 0);
|
||||
// returning() must itself return a thenable that resolves to the
|
||||
// configured insert result, since the service awaits it directly.
|
||||
q.insert = jest.fn().mockImplementation(() => {
|
||||
const promise = Promise.resolve(result?.insert ?? []);
|
||||
promise.returning = () => Promise.resolve(result?.insert ?? []);
|
||||
return promise;
|
||||
});
|
||||
q.pluck = jest.fn().mockResolvedValue(result?.pluck ?? []);
|
||||
q.update = jest.fn().mockResolvedValue(result?.update ?? 0);
|
||||
q.then = (resolve) => Promise.resolve(result?.rows ?? []).then(resolve);
|
||||
q.catch = () => q;
|
||||
return q;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
db.mockReset();
|
||||
queueEmail.mockClear();
|
||||
db.transaction.mockImplementation(async (fn) => fn(db));
|
||||
});
|
||||
|
||||
// ---- createInvitation --------------------------------------------------
|
||||
|
||||
describe('createInvitation', () => {
|
||||
it('rejects when a customer with the email already exists', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
db.mockImplementationOnce(() => chain({ first: { id: 1, email: 'taken@example.com' } }));
|
||||
await expect(
|
||||
svc.createInvitation({ email: 'taken@example.com', invitedById: 9 })
|
||||
).rejects.toThrow(/already exists/i);
|
||||
expect(queueEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when a non-expired pending invitation exists', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
db.mockImplementationOnce(() => chain({ first: null })); // no customer
|
||||
db.mockImplementationOnce(() => chain({ first: { id: 5, email: 'pending@example.com' } }));
|
||||
await expect(
|
||||
svc.createInvitation({ email: 'pending@example.com', invitedById: 9 })
|
||||
).rejects.toThrow(/pending invitation/i);
|
||||
});
|
||||
|
||||
it('queues an invitation email on success', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
db.mockImplementationOnce(() => chain({ first: null })); // no customer
|
||||
db.mockImplementationOnce(() => chain({ first: null })); // no pending
|
||||
db.mockImplementationOnce(() => chain({ insert: [{ id: 42 }] })); // insert invitation
|
||||
|
||||
const result = await svc.createInvitation({
|
||||
email: 'new@example.com',
|
||||
invitedById: 9,
|
||||
});
|
||||
|
||||
expect(result.email).toBe('new@example.com');
|
||||
expect(result.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(queueEmail).toHaveBeenCalledTimes(1);
|
||||
const call = queueEmail.mock.calls[0];
|
||||
expect(call[2]).toBe('customer_invitation');
|
||||
expect(call[3].invite_link).toMatch(/\/customer\/invite\//);
|
||||
// Link must honour the configured frontend URL (Site Settings →
|
||||
// general_site_url, surfaced via getFrontendBaseUrl). Mocked above
|
||||
// to https://example.test — the dev-day bug was the link always
|
||||
// emitting localhost regardless of config.
|
||||
expect(call[3].invite_link.startsWith('https://example.test/')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- setAssignmentsForEvent --------------------------------------------
|
||||
|
||||
describe('setAssignmentsForEvent', () => {
|
||||
it('inserts only customers that are missing and removes those not in the wanted list', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
// existing assignments: customers 1 and 2
|
||||
const existingChain = chain({ rows: [
|
||||
{ id: 100, customer_account_id: 1 },
|
||||
{ id: 101, customer_account_id: 2 },
|
||||
] });
|
||||
// delete chain
|
||||
const deleteChain = chain({ del: 1 });
|
||||
// validity check chain — returns valid ids 3 only (99 is filtered out)
|
||||
const validityChain = chain({ pluck: [3] });
|
||||
// insert chain
|
||||
const insertChain = chain({ insert: [] });
|
||||
|
||||
db.mockImplementationOnce(() => existingChain);
|
||||
db.mockImplementationOnce(() => deleteChain);
|
||||
db.mockImplementationOnce(() => validityChain);
|
||||
db.mockImplementationOnce(() => insertChain);
|
||||
|
||||
const summary = await svc.setAssignmentsForEvent(42, [2, 3, 99], 7);
|
||||
|
||||
// Should remove customer 1 (not in wanted) and only insert valid ones.
|
||||
expect(deleteChain.whereIn).toHaveBeenCalledWith('id', [100]);
|
||||
expect(insertChain.insert).toHaveBeenCalledWith([{
|
||||
event_id: 42,
|
||||
customer_account_id: 3,
|
||||
assigned_by_admin_id: 7,
|
||||
assigned_at: expect.any(Date),
|
||||
}]);
|
||||
// `added` counts attempted-additions before the validity filter — so
|
||||
// 3 and 99 were both attempted (added: 2). The validity filter drops
|
||||
// 99 silently (logged as a warning) before the insert. This matches
|
||||
// the service contract; the test is asserting on it explicitly so a
|
||||
// future refactor can't quietly change it.
|
||||
expect(summary).toEqual({ added: 2, removed: 1 });
|
||||
});
|
||||
|
||||
it('clears all assignments when wanted list is empty', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
const existingChain = chain({ rows: [
|
||||
{ id: 100, customer_account_id: 1 },
|
||||
{ id: 101, customer_account_id: 2 },
|
||||
] });
|
||||
const deleteChain = chain({ del: 2 });
|
||||
|
||||
db.mockImplementationOnce(() => existingChain);
|
||||
db.mockImplementationOnce(() => deleteChain);
|
||||
|
||||
const summary = await svc.setAssignmentsForEvent(42, [], 7);
|
||||
expect(deleteChain.whereIn).toHaveBeenCalledWith('id', [100, 101]);
|
||||
expect(summary).toEqual({ added: 0, removed: 2 });
|
||||
});
|
||||
|
||||
it('is a no-op when wanted equals existing', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
const existingChain = chain({ rows: [
|
||||
{ id: 100, customer_account_id: 1 },
|
||||
] });
|
||||
db.mockImplementationOnce(() => existingChain);
|
||||
|
||||
const summary = await svc.setAssignmentsForEvent(42, [1], 7);
|
||||
// Only the existing-rows query was called; no del or insert chain
|
||||
// was needed because both diffs are empty.
|
||||
expect(db).toHaveBeenCalledTimes(1);
|
||||
expect(summary).toEqual({ added: 0, removed: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- customerHasAccessToEvent ------------------------------------------
|
||||
|
||||
describe('customerHasAccessToEvent', () => {
|
||||
it('returns true when an assignment row exists', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
const c = chain({ first: { id: 99 } });
|
||||
db.mockImplementationOnce(() => c);
|
||||
|
||||
const result = await svc.customerHasAccessToEvent(1, 2);
|
||||
expect(result).toBe(true);
|
||||
expect(c.where).toHaveBeenCalledWith('customer_account_id', 1);
|
||||
expect(c.where).toHaveBeenCalledWith('event_id', 2);
|
||||
});
|
||||
|
||||
it('returns false when no assignment row exists', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
db.mockImplementationOnce(() => chain({ first: undefined }));
|
||||
const result = await svc.customerHasAccessToEvent(1, 999);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- setAssignmentsForCustomer ----------------------------------------
|
||||
//
|
||||
// The inverse of setAssignmentsForEvent: takes one customer + a list of
|
||||
// event ids and reconciles the junction table. Powers the "Manage
|
||||
// galleries" dialog on the customer detail page. The
|
||||
// `verifyGalleryAccess` middleware re-checks this junction on every
|
||||
// customer-minted JWT, so getting the diff math right here is the
|
||||
// access-control story for the whole feature (#470).
|
||||
//
|
||||
// notifyCustomerOfNewAssignments() runs as fire-and-forget after the
|
||||
// transactional work and queues a follow-up email. The tests below
|
||||
// configure mocks for the calls it makes (load customer row, load
|
||||
// event rows) so it can resolve cleanly without crashing the assert
|
||||
// path — we don't assert on its body here; the email pipeline is a
|
||||
// separate seam.
|
||||
|
||||
describe('setAssignmentsForCustomer', () => {
|
||||
// The notifier issues two more db() calls after the writer returns:
|
||||
// SELECT customer_accounts and SELECT events. Provide cheap chains
|
||||
// that resolve to "no customer / no events" so it early-returns
|
||||
// without firing queueEmail. Returns a small helper so each test
|
||||
// can append it after its own writer chains.
|
||||
function appendNotifierMocks() {
|
||||
db.mockImplementationOnce(() => chain({ first: null })); // customer lookup -> not found
|
||||
db.mockImplementationOnce(() => chain({ rows: [] })); // events lookup -> empty
|
||||
}
|
||||
|
||||
it('inserts only events that are missing and removes those not in the wanted list', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
// existing assignments: customer is on events 10 and 20.
|
||||
const existingChain = chain({ rows: [
|
||||
{ id: 500, event_id: 10 },
|
||||
{ id: 501, event_id: 20 },
|
||||
] });
|
||||
const deleteChain = chain({ del: 1 });
|
||||
// Validity check returns 30 only — event 99 is filtered out
|
||||
// (archived or missing).
|
||||
const validityChain = chain({ pluck: [30] });
|
||||
const insertChain = chain({ insert: [] });
|
||||
|
||||
db.mockImplementationOnce(() => existingChain);
|
||||
db.mockImplementationOnce(() => deleteChain);
|
||||
db.mockImplementationOnce(() => validityChain);
|
||||
db.mockImplementationOnce(() => insertChain);
|
||||
appendNotifierMocks();
|
||||
|
||||
const summary = await svc.setAssignmentsForCustomer(7, [20, 30, 99], 12);
|
||||
|
||||
// Remove event 10 (not in wanted).
|
||||
expect(deleteChain.whereIn).toHaveBeenCalledWith('id', [500]);
|
||||
// Insert ONLY event 30 — event 99 was dropped by the validity filter.
|
||||
expect(insertChain.insert).toHaveBeenCalledWith([{
|
||||
event_id: 30,
|
||||
customer_account_id: 7,
|
||||
assigned_by_admin_id: 12,
|
||||
assigned_at: expect.any(Date),
|
||||
}]);
|
||||
expect(summary).toEqual({
|
||||
added: 2, // 30 and 99 were both attempted
|
||||
removed: 1, // event 10
|
||||
addedEventIds: [30], // only event 30 actually landed in the DB
|
||||
});
|
||||
});
|
||||
|
||||
it('silently filters archived/missing event ids out of the insert', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
const existingChain = chain({ rows: [] });
|
||||
// Three candidates; validity check pulls back zero -> all three are
|
||||
// archived or missing. Service should log a warning and skip the
|
||||
// insert entirely (rows.length === 0 short-circuits the .insert call).
|
||||
const validityChain = chain({ pluck: [] });
|
||||
|
||||
db.mockImplementationOnce(() => existingChain);
|
||||
db.mockImplementationOnce(() => validityChain);
|
||||
appendNotifierMocks();
|
||||
|
||||
const summary = await svc.setAssignmentsForCustomer(7, [99, 100, 101], 12);
|
||||
|
||||
expect(summary).toEqual({
|
||||
added: 3, // attempted three
|
||||
removed: 0,
|
||||
addedEventIds: [], // none landed
|
||||
});
|
||||
});
|
||||
|
||||
it('clears all assignments when wanted list is empty', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
const existingChain = chain({ rows: [
|
||||
{ id: 500, event_id: 10 },
|
||||
{ id: 501, event_id: 20 },
|
||||
] });
|
||||
const deleteChain = chain({ del: 2 });
|
||||
|
||||
db.mockImplementationOnce(() => existingChain);
|
||||
db.mockImplementationOnce(() => deleteChain);
|
||||
appendNotifierMocks();
|
||||
|
||||
const summary = await svc.setAssignmentsForCustomer(7, [], 12);
|
||||
expect(deleteChain.whereIn).toHaveBeenCalledWith('id', [500, 501]);
|
||||
expect(summary).toEqual({ added: 0, removed: 2, addedEventIds: [] });
|
||||
});
|
||||
|
||||
it('is a no-op when wanted equals existing', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
const existingChain = chain({ rows: [
|
||||
{ id: 500, event_id: 10 },
|
||||
] });
|
||||
db.mockImplementationOnce(() => existingChain);
|
||||
appendNotifierMocks();
|
||||
|
||||
const summary = await svc.setAssignmentsForCustomer(7, [10], 12);
|
||||
expect(summary).toEqual({ added: 0, removed: 0, addedEventIds: [] });
|
||||
});
|
||||
|
||||
it('coerces non-integer / negative event ids out of the wanted set', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
const existingChain = chain({ rows: [] });
|
||||
const validityChain = chain({ pluck: [10] });
|
||||
const insertChain = chain({ insert: [] });
|
||||
|
||||
db.mockImplementationOnce(() => existingChain);
|
||||
db.mockImplementationOnce(() => validityChain);
|
||||
db.mockImplementationOnce(() => insertChain);
|
||||
appendNotifierMocks();
|
||||
|
||||
// 'abc' isn't a number, -5 is negative, 0 is invalid, 10.5 is fractional.
|
||||
// Only the integer 10 should survive the Number()/Number.isFinite()
|
||||
// filter. 10.5 coerces to a finite 10.5 (Number.isFinite returns true)
|
||||
// but the validity check only returns the integer 10 so we end up
|
||||
// inserting 10. Asserting on the eventual insert payload is the
|
||||
// cleanest contract.
|
||||
await svc.setAssignmentsForCustomer(7, [10, 'abc', -5, 0, '10'], 12);
|
||||
const insertedRows = insertChain.insert.mock.calls[0][0];
|
||||
const insertedEventIds = insertedRows.map((r) => r.event_id);
|
||||
expect(insertedEventIds).toEqual([10]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Unit tests for customerAuth middleware (#354 follow-up).
|
||||
*
|
||||
* Mirrors the parity-with-adminAuth invariants the maintainer flagged
|
||||
* during the PR #403 review:
|
||||
* - Issuer-claim verify
|
||||
* - Token revocation lookup
|
||||
* - Wrong-token-type rejection
|
||||
* - Missing-customer / inactive-customer rejection
|
||||
* - password_changed_at invalidation
|
||||
* - IP drift logged but not rejected
|
||||
*
|
||||
* The middleware reaches into the DB, the JWT verifier, the revocation
|
||||
* cache and the cookie helper — all four are mocked so this stays a
|
||||
* fast unit test (no postgres, no real JWTs).
|
||||
*/
|
||||
|
||||
// --- mocks --------------------------------------------------------------
|
||||
jest.mock('../database/db', () => {
|
||||
const mockDb = jest.fn();
|
||||
return { db: mockDb, logActivity: jest.fn() };
|
||||
});
|
||||
|
||||
jest.mock('../utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('jsonwebtoken', () => ({
|
||||
verify: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/tokenRevocation', () => ({
|
||||
isTokenRevoked: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/tokenUtils', () => ({
|
||||
getCustomerTokenFromRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
const { customerAuth } = require('../middleware/customerAuth');
|
||||
|
||||
// Helper: build a minimal Express-shaped req/res/next trio. The
|
||||
// middleware reads req.headers, req.cookies, req.ip etc.; res.status()
|
||||
// returns res so the .json() chain works; next is a jest fn so we can
|
||||
// assert it was/wasn't called.
|
||||
function makeRes() {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
function makeReq({ token = 'tkn', cookies = {}, headers = {}, originalUrl = '/api/customer/foo', ip = '1.2.3.4' } = {}) {
|
||||
return { headers: { authorization: undefined, ...headers }, cookies, originalUrl, ip, connection: { remoteAddress: ip } };
|
||||
}
|
||||
|
||||
// Convenience: mock db('customer_accounts').where(...).select(...).first()
|
||||
// to return the given row. The middleware uses `.where().select().first()`.
|
||||
function mockCustomerLookup(row) {
|
||||
const q = {};
|
||||
q.where = jest.fn().mockReturnValue(q);
|
||||
q.select = jest.fn().mockReturnValue(q);
|
||||
q.first = jest.fn().mockResolvedValue(row);
|
||||
db.mockImplementationOnce(() => q);
|
||||
return q;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.mockReset();
|
||||
jwt.verify.mockReset();
|
||||
isTokenRevoked.mockReset();
|
||||
getCustomerTokenFromRequest.mockReset();
|
||||
logger.info.mockClear();
|
||||
logger.warn.mockClear();
|
||||
logger.debug.mockClear();
|
||||
logger.error.mockClear();
|
||||
});
|
||||
|
||||
// ---- no token ----------------------------------------------------------
|
||||
|
||||
describe('customerAuth — no token', () => {
|
||||
it('returns 401 with NO_TOKEN code when the helper returns null', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue(null);
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await customerAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: 'NO_TOKEN' }),
|
||||
);
|
||||
// Maintainer-flagged: must be debug-level for unauthenticated probes.
|
||||
// (info-level was the prod-noise bug.)
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- JWT verification --------------------------------------------------
|
||||
|
||||
describe('customerAuth — JWT verification', () => {
|
||||
it('returns 401 TOKEN_EXPIRED when the JWT is expired', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
const err = new Error('jwt expired');
|
||||
err.name = 'TokenExpiredError';
|
||||
jwt.verify.mockImplementation(() => { throw err; });
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'TOKEN_EXPIRED' }));
|
||||
});
|
||||
|
||||
it('returns 401 JWT_INVALID on any other JWT error', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
const err = new Error('invalid signature');
|
||||
err.name = 'JsonWebTokenError';
|
||||
jwt.verify.mockImplementation(() => { throw err; });
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'JWT_INVALID' }));
|
||||
});
|
||||
|
||||
it('passes the issuer claim to jwt.verify', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
// Make jwt.verify succeed with a customer payload so the test gets
|
||||
// past the verify step; we only care that the call shape is right.
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 1, email: 'c@example.com', display_name: null,
|
||||
first_name: null, last_name: null,
|
||||
password_changed_at: null, preferred_language: 'en',
|
||||
});
|
||||
|
||||
await customerAuth(makeReq(), makeRes(), jest.fn());
|
||||
|
||||
expect(jwt.verify).toHaveBeenCalledWith(
|
||||
'tkn',
|
||||
expect.anything(),
|
||||
expect.objectContaining({ issuer: 'picpeak-auth', complete: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- revocation --------------------------------------------------------
|
||||
|
||||
describe('customerAuth — revocation', () => {
|
||||
it('rejects revoked tokens with TOKEN_REVOKED', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(true);
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'TOKEN_REVOKED' }));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- wrong token type --------------------------------------------------
|
||||
|
||||
describe('customerAuth — token type', () => {
|
||||
it('rejects an admin token with WRONG_TOKEN_TYPE', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'admin', id: 99, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'WRONG_TOKEN_TYPE' }));
|
||||
});
|
||||
|
||||
it('rejects a gallery token with WRONG_TOKEN_TYPE', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'gallery', eventId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'WRONG_TOKEN_TYPE' }));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- customer existence + active check ---------------------------------
|
||||
|
||||
describe('customerAuth — customer lookup', () => {
|
||||
it('rejects when the customer row is missing', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup(null); // not found
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'CUSTOMER_NOT_FOUND' }));
|
||||
});
|
||||
|
||||
it('rejects when the customer is inactive (the where-clause filters them out)', async () => {
|
||||
// Active filter is part of the query (.where({ ..., is_active: true })),
|
||||
// so an inactive customer surfaces as a missing row — same code path
|
||||
// as CUSTOMER_NOT_FOUND. This test guards the active filter itself
|
||||
// by asserting the where call shape.
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
const q = mockCustomerLookup(null);
|
||||
|
||||
await customerAuth(makeReq(), makeRes(), jest.fn());
|
||||
|
||||
expect(q.where).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 1, is_active: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- password_changed_at invalidation ----------------------------------
|
||||
|
||||
describe('customerAuth — password_changed_at', () => {
|
||||
it('rejects tokens issued before password_changed_at with PASSWORD_CHANGED', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
// Token issued at unix 1000; password changed at 2000.
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 1, email: 'c@example.com', display_name: null,
|
||||
first_name: null, last_name: null,
|
||||
password_changed_at: new Date(2000 * 1000), // unix 2000
|
||||
preferred_language: 'en',
|
||||
});
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'PASSWORD_CHANGED' }));
|
||||
});
|
||||
|
||||
it('accepts tokens issued at exactly password_changed_at', async () => {
|
||||
// Boundary: iat === passwordChangedSeconds → the strict-less-than
|
||||
// check should NOT reject. Token is still valid in this edge case.
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 2000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 1, email: 'c@example.com', display_name: null,
|
||||
first_name: null, last_name: null,
|
||||
password_changed_at: new Date(2000 * 1000),
|
||||
preferred_language: 'en',
|
||||
});
|
||||
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), makeRes(), next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts tokens when password_changed_at is null', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 1, email: 'c@example.com', display_name: null,
|
||||
first_name: null, last_name: null,
|
||||
password_changed_at: null,
|
||||
preferred_language: 'en',
|
||||
});
|
||||
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), makeRes(), next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- IP drift ----------------------------------------------------------
|
||||
|
||||
describe('customerAuth — IP drift', () => {
|
||||
it('logs but does not reject when token IP differs from request IP', async () => {
|
||||
// Mirrors adminAuth: customers may roam between mobile networks
|
||||
// mid-session, so IP drift is a log-and-continue, not a denial.
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000, ip: '8.8.8.8' },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 1, email: 'c@example.com', display_name: null,
|
||||
first_name: null, last_name: null,
|
||||
password_changed_at: null,
|
||||
preferred_language: 'en',
|
||||
});
|
||||
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq({ ip: '4.4.4.4' }), makeRes(), next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
// The drift line uses logger.info on adminAuth and customerAuth;
|
||||
// we don't assert level here, just that something was logged.
|
||||
expect(logger.info).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- happy path --------------------------------------------------------
|
||||
|
||||
describe('customerAuth — happy path', () => {
|
||||
it('attaches req.customer and calls next() on a valid token', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 7, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 7,
|
||||
email: 'c@example.com',
|
||||
display_name: 'Charlie',
|
||||
first_name: 'Charlie',
|
||||
last_name: 'Customer',
|
||||
password_changed_at: null,
|
||||
preferred_language: 'de',
|
||||
});
|
||||
|
||||
const req = makeReq();
|
||||
const next = jest.fn();
|
||||
await customerAuth(req, makeRes(), next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.customer).toEqual({
|
||||
id: 7,
|
||||
email: 'c@example.com',
|
||||
displayName: 'Charlie',
|
||||
firstName: 'Charlie',
|
||||
lastName: 'Customer',
|
||||
preferredLanguage: 'de',
|
||||
});
|
||||
expect(req.token).toBe('tkn');
|
||||
});
|
||||
|
||||
it('defaults preferredLanguage to en when the column is null', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 7, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 7, email: 'c@example.com',
|
||||
display_name: null, first_name: null, last_name: null,
|
||||
password_changed_at: null,
|
||||
preferred_language: null,
|
||||
});
|
||||
|
||||
const req = makeReq();
|
||||
await customerAuth(req, makeRes(), jest.fn());
|
||||
|
||||
expect(req.customer.preferredLanguage).toBe('en');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Unit tests for the per-event social-share preview opt-in (#474).
|
||||
*
|
||||
* Pins three contracts on `buildOgMetadata`:
|
||||
* - opt-in OFF (or missing) → og:image is the brand logo
|
||||
* - opt-in ON without a hero photo → og:image is the brand logo
|
||||
* - opt-in ON + hero + thumbnail → og:image is the public
|
||||
* /og/gallery/<slug>/cover URL
|
||||
*
|
||||
* Plus the `handleGalleryOgCover` 404 path so we can't accidentally
|
||||
* widen the unauthenticated cover endpoint to expose a hero photo
|
||||
* the admin hasn't opted into sharing.
|
||||
*/
|
||||
|
||||
jest.mock('../database/db', () => {
|
||||
const mockDb = jest.fn();
|
||||
return { db: mockDb };
|
||||
});
|
||||
|
||||
jest.mock('../utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../services/imageProcessor', () => ({
|
||||
ensureThumbnail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../services/storage', () => ({
|
||||
getStorage: jest.fn(),
|
||||
}));
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { getStorage } = require('../services/storage');
|
||||
const {
|
||||
buildOgMetadata,
|
||||
handleGalleryOgCover,
|
||||
} = require('../services/galleryOgService');
|
||||
|
||||
// The service hits two tables in sequence:
|
||||
// 1. events (slug lookup → may then hit event_slug_redirects)
|
||||
// 2. app_settings (branding lookup)
|
||||
// then optionally a third query when og_image_share_enabled is true:
|
||||
// 3. photos (validate hero exists + has thumbnail)
|
||||
//
|
||||
// Each test queues responses on the shared mock in the order the
|
||||
// service calls them.
|
||||
function chain(result) {
|
||||
const q = {};
|
||||
['where', 'whereIn', 'andWhere', 'select', 'orderBy', 'limit', 'first']
|
||||
.forEach((m) => { q[m] = jest.fn().mockReturnValue(q); });
|
||||
q.first = jest.fn().mockResolvedValue(result?.first);
|
||||
q.then = (resolve) => Promise.resolve(result?.rows ?? []).then(resolve);
|
||||
q.catch = () => q;
|
||||
return q;
|
||||
}
|
||||
|
||||
function mockResolveSlug(event) {
|
||||
// events table query → return event row (or null + no redirects).
|
||||
db.mockImplementationOnce(() => chain({ first: event || null }));
|
||||
if (!event) {
|
||||
// event_slug_redirects fallback — unused here, return null.
|
||||
db.schema = db.schema || {};
|
||||
db.schema.hasTable = jest.fn().mockResolvedValue(false);
|
||||
}
|
||||
}
|
||||
|
||||
function mockBranding() {
|
||||
// app_settings → fetchBranding rows. Empty = pure defaults.
|
||||
db.mockImplementationOnce(() => chain({ rows: [] }));
|
||||
}
|
||||
|
||||
function mockHeroPhoto(photo) {
|
||||
db.mockImplementationOnce(() => chain({ first: photo }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.mockReset();
|
||||
ensureThumbnail.mockReset();
|
||||
getStorage.mockReset();
|
||||
process.env.FRONTEND_URL = 'https://gallery.example.com';
|
||||
});
|
||||
|
||||
// ---- buildOgMetadata: cover-vs-logo decision ---------------------------
|
||||
|
||||
describe('buildOgMetadata — share-image opt-in', () => {
|
||||
it('uses the brand logo when og_image_share_enabled is false (default)', async () => {
|
||||
mockResolveSlug({
|
||||
id: 1,
|
||||
slug: 'wedding-2026',
|
||||
event_name: 'Wedding 2026',
|
||||
event_date: '2026-06-12',
|
||||
welcome_message: null,
|
||||
hero_photo_id: 99, // hero IS picked
|
||||
og_image_share_enabled: false, // ...but opt-in is off
|
||||
});
|
||||
mockBranding();
|
||||
|
||||
const meta = await buildOgMetadata('wedding-2026', '/gallery/wedding-2026');
|
||||
|
||||
// Falls back to the default logo URL — the picpeak-logo asset
|
||||
// since branding has no logo configured.
|
||||
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
|
||||
// Confirm the photos table was NOT queried — opt-in off means no
|
||||
// hero lookup at all.
|
||||
expect(db).toHaveBeenCalledTimes(2); // events + app_settings only
|
||||
});
|
||||
|
||||
it('uses the brand logo when opt-in is on but no hero photo is picked', async () => {
|
||||
mockResolveSlug({
|
||||
id: 2,
|
||||
slug: 'engagement',
|
||||
event_name: 'Engagement',
|
||||
event_date: null,
|
||||
welcome_message: null,
|
||||
hero_photo_id: null, // no hero
|
||||
og_image_share_enabled: true, // opt-in IS on
|
||||
});
|
||||
mockBranding();
|
||||
|
||||
const meta = await buildOgMetadata('engagement', '/gallery/engagement');
|
||||
|
||||
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
|
||||
// photos table NOT queried — service short-circuits when hero_photo_id
|
||||
// is falsy, even with opt-in on.
|
||||
expect(db).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses the cover URL when opt-in is on AND hero exists with a thumbnail', async () => {
|
||||
mockResolveSlug({
|
||||
id: 3,
|
||||
slug: 'birthday-2026',
|
||||
event_name: 'Birthday 2026',
|
||||
event_date: '2026-04-15',
|
||||
welcome_message: null,
|
||||
hero_photo_id: 42,
|
||||
og_image_share_enabled: true,
|
||||
});
|
||||
mockBranding();
|
||||
mockHeroPhoto({
|
||||
id: 42,
|
||||
thumbnail_path: 'thumbnails/thumb_birthday_42.jpg',
|
||||
});
|
||||
|
||||
const meta = await buildOgMetadata('birthday-2026', '/gallery/birthday-2026');
|
||||
|
||||
expect(meta.image).toBe('https://gallery.example.com/og/gallery/birthday-2026/cover');
|
||||
});
|
||||
|
||||
it('falls back to the brand logo if the hero photo row is missing', async () => {
|
||||
// Defensive: hero_photo_id points to a photo that no longer
|
||||
// exists (e.g. deleted after admin enabled the toggle). The OG
|
||||
// page must still render with the logo, never a broken image
|
||||
// src in WhatsApp previews.
|
||||
mockResolveSlug({
|
||||
id: 4,
|
||||
slug: 'orphan',
|
||||
event_name: 'Orphan',
|
||||
hero_photo_id: 999,
|
||||
og_image_share_enabled: true,
|
||||
});
|
||||
mockBranding();
|
||||
mockHeroPhoto(null); // photo deleted
|
||||
|
||||
const meta = await buildOgMetadata('orphan', '/gallery/orphan');
|
||||
|
||||
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
|
||||
});
|
||||
|
||||
it('falls back to the brand logo if the hero photo has no thumbnail yet', async () => {
|
||||
// The hero exists but the background processor hasn't generated
|
||||
// its thumbnail yet (or the regenerate failed). Same fallback.
|
||||
mockResolveSlug({
|
||||
id: 5,
|
||||
slug: 'just-uploaded',
|
||||
event_name: 'Just Uploaded',
|
||||
hero_photo_id: 7,
|
||||
og_image_share_enabled: true,
|
||||
});
|
||||
mockBranding();
|
||||
mockHeroPhoto({ id: 7, thumbnail_path: null });
|
||||
|
||||
const meta = await buildOgMetadata('just-uploaded', '/gallery/just-uploaded');
|
||||
|
||||
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- handleGalleryOgCover: unauthenticated 404 contract ----------------
|
||||
|
||||
function makeRes() {
|
||||
const res = { headers: {} };
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.type = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
res.set = jest.fn((kv) => { Object.assign(res.headers, kv); return res; });
|
||||
res.setHeader = jest.fn((k, v) => { res.headers[k] = v; });
|
||||
res.end = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('handleGalleryOgCover — 404 unless explicitly opted in', () => {
|
||||
it('returns 400 on an invalid slug shape', async () => {
|
||||
const req = { params: { slug: '../../etc/passwd' }, headers: {} };
|
||||
const res = makeRes();
|
||||
await handleGalleryOgCover(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('returns 404 when the event has og_image_share_enabled = false', async () => {
|
||||
mockResolveSlug({
|
||||
id: 1,
|
||||
slug: 'wedding-2026',
|
||||
hero_photo_id: 99,
|
||||
og_image_share_enabled: false,
|
||||
});
|
||||
const req = { params: { slug: 'wedding-2026' }, headers: {} };
|
||||
const res = makeRes();
|
||||
await handleGalleryOgCover(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
// Crucial: ensureThumbnail must NOT be called — we never want to
|
||||
// touch the storage backend for a non-opted-in gallery.
|
||||
expect(ensureThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the event opts in but has no hero_photo_id', async () => {
|
||||
mockResolveSlug({
|
||||
id: 2,
|
||||
slug: 'engagement',
|
||||
hero_photo_id: null,
|
||||
og_image_share_enabled: true,
|
||||
});
|
||||
const req = { params: { slug: 'engagement' }, headers: {} };
|
||||
const res = makeRes();
|
||||
await handleGalleryOgCover(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(ensureThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Unit test for the noStoreCache middleware (#470 follow-up).
|
||||
*
|
||||
* The middleware exists because of a real production-class bug: when
|
||||
* #458 mounted a 410-returning kill-switch in front of customer
|
||||
* endpoints, browsers cached the 410 (no Cache-Control was set) and
|
||||
* kept serving it after #470 reverted the middleware. This test pins
|
||||
* the contract so a future cleanup pass doesn't quietly drop the
|
||||
* header set and re-introduce the bug.
|
||||
*/
|
||||
|
||||
const { noStoreCache } = require('../middleware/noStoreCache');
|
||||
|
||||
function makeRes() {
|
||||
const headers = {};
|
||||
return {
|
||||
setHeader: (k, v) => { headers[k] = v; },
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
describe('noStoreCache middleware', () => {
|
||||
it('sets Cache-Control: no-store + private and calls next()', () => {
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
noStoreCache({}, res, next);
|
||||
|
||||
expect(res.headers['Cache-Control']).toBe(
|
||||
'no-store, no-cache, must-revalidate, private',
|
||||
);
|
||||
// HTTP/1.0 fallbacks — old proxies in front of customer-facing
|
||||
// surfaces (corporate VPN gateways, legacy CDNs) honour these.
|
||||
expect(res.headers.Pragma).toBe('no-cache');
|
||||
expect(res.headers.Expires).toBe('0');
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('runs as middleware regardless of response status', () => {
|
||||
// The header must land on EVERY response coming from the route
|
||||
// group — including 4xx/5xx — so a stale 410 from a
|
||||
// now-reverted middleware can't get pinned in browser cache like
|
||||
// it did in the #458 → #470 sequence.
|
||||
const res = makeRes();
|
||||
noStoreCache({}, res, () => {});
|
||||
expect(res.headers['Cache-Control']).toContain('no-store');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Unit tests for verifyGalleryAccess's customer-assignment re-check
|
||||
* (PR #470). Documents the contract:
|
||||
*
|
||||
* - JWT with `via === 'customer'` and a `customerId` claim →
|
||||
* middleware re-reads event_customer_assignments and 403s with
|
||||
* code 'CUSTOMER_ASSIGNMENT_REVOKED' when the row is gone.
|
||||
* - JWT without those claims (the per-event-password flow) → no
|
||||
* re-check, no extra query, no perf cost. This is asserted
|
||||
* explicitly because a regression that silently re-checks every
|
||||
* gallery token would 403 every guest the moment a customer was
|
||||
* unassigned from any unrelated event.
|
||||
* - Re-check is wrapped in withRetry so transient DB blips don't
|
||||
* bounce a legitimate session.
|
||||
*
|
||||
* Same pattern as authSession.symmetry.test.js — every collaborator
|
||||
* mocked so the test stays a fast unit test (no postgres, no real JWTs).
|
||||
*/
|
||||
|
||||
jest.mock('../database/db', () => {
|
||||
const mockDb = jest.fn();
|
||||
// The middleware uses `withRetry(fn)` to wrap reads. For the test
|
||||
// surface we just want to invoke the callback synchronously and
|
||||
// surface whatever it returns / throws.
|
||||
const withRetry = jest.fn((fn) => fn());
|
||||
return { db: mockDb, withRetry };
|
||||
});
|
||||
|
||||
jest.mock('../utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('jsonwebtoken', () => ({
|
||||
verify: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/tokenUtils', () => ({
|
||||
getGalleryTokenFromRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
|
||||
function makeRes() {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
function makeReq(slug = 'test-event') {
|
||||
return {
|
||||
params: { slug },
|
||||
headers: {},
|
||||
cookies: {},
|
||||
query: {},
|
||||
ip: '1.2.3.4',
|
||||
get: () => 'jest',
|
||||
connection: { remoteAddress: '1.2.3.4' },
|
||||
};
|
||||
}
|
||||
|
||||
// The middleware queries the `events` table first (existence check),
|
||||
// then optionally `event_customer_assignments`. This helper queues
|
||||
// both responses on the shared db mock so each test can spell out
|
||||
// the scenario in order. Returns the assignments chain so the test
|
||||
// can assert against it.
|
||||
function mockEventAndAssignment({ event, assignment }) {
|
||||
// `db('events').where({...}).select('*').first()` — chainable.
|
||||
const eventsChain = {};
|
||||
eventsChain.where = jest.fn().mockReturnValue(eventsChain);
|
||||
eventsChain.select = jest.fn().mockReturnValue(eventsChain);
|
||||
eventsChain.first = jest.fn().mockResolvedValue(event);
|
||||
|
||||
// `db('event_customer_assignments').where({...}).first()`.
|
||||
const assignChain = {};
|
||||
assignChain.where = jest.fn().mockReturnValue(assignChain);
|
||||
assignChain.first = jest.fn().mockResolvedValue(assignment);
|
||||
|
||||
db.mockImplementationOnce(() => eventsChain)
|
||||
.mockImplementationOnce(() => assignChain);
|
||||
|
||||
return { eventsChain, assignChain };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.mockReset();
|
||||
jwt.verify.mockReset();
|
||||
getGalleryTokenFromRequest.mockReset();
|
||||
});
|
||||
|
||||
// ---- customer-minted JWT, assignment intact ----------------------------
|
||||
|
||||
describe('verifyGalleryAccess — customer-minted JWT with active assignment', () => {
|
||||
it('allows access when the event_customer_assignments row exists', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
eventId: 42,
|
||||
via: 'customer',
|
||||
customerId: 7,
|
||||
});
|
||||
const { assignChain } = mockEventAndAssignment({
|
||||
event: { id: 42, slug: 'test-event', is_active: true, is_archived: false },
|
||||
assignment: { id: 999, event_id: 42, customer_account_id: 7 },
|
||||
});
|
||||
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await verifyGalleryAccess(req, res, next);
|
||||
|
||||
expect(assignChain.where).toHaveBeenCalledWith({
|
||||
event_id: 42,
|
||||
customer_account_id: 7,
|
||||
});
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(req.event).toEqual(expect.objectContaining({ id: 42 }));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- customer-minted JWT, assignment revoked ---------------------------
|
||||
|
||||
describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
|
||||
it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
eventId: 42,
|
||||
via: 'customer',
|
||||
customerId: 7,
|
||||
});
|
||||
mockEventAndAssignment({
|
||||
event: { id: 42, slug: 'test-event', is_active: true, is_archived: false },
|
||||
assignment: undefined, // <-- the admin just removed it
|
||||
});
|
||||
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await verifyGalleryAccess(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: 'CUSTOMER_ASSIGNMENT_REVOKED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('still re-checks when the customerId is in the token but via claim is missing-but-numeric', async () => {
|
||||
// Belt-and-braces: the gate triggers on `via === 'customer'`. A
|
||||
// token with customerId but no `via` should NOT re-check (it isn't
|
||||
// a customer-minted token — could be legacy). This pins the
|
||||
// contract so a future refactor can't accidentally widen the gate
|
||||
// and start 403'ing per-event-password sessions.
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
eventId: 42,
|
||||
customerId: 7,
|
||||
// intentionally no `via` claim
|
||||
});
|
||||
// The middleware uses one db() call for events; if it tried to
|
||||
// re-check we'd see a second db() call and the test would throw
|
||||
// (no more mock implementations queued).
|
||||
const eventsChain = {};
|
||||
eventsChain.where = jest.fn().mockReturnValue(eventsChain);
|
||||
eventsChain.select = jest.fn().mockReturnValue(eventsChain);
|
||||
eventsChain.first = jest.fn().mockResolvedValue({
|
||||
id: 42, slug: 'test-event', is_active: true, is_archived: false,
|
||||
});
|
||||
db.mockImplementationOnce(() => eventsChain);
|
||||
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await verifyGalleryAccess(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(db).toHaveBeenCalledTimes(1); // events table only — no assignments query
|
||||
});
|
||||
});
|
||||
|
||||
// ---- per-event-password JWT (no `via` claim) ---------------------------
|
||||
|
||||
describe('verifyGalleryAccess — per-event-password JWT', () => {
|
||||
it('does NOT touch event_customer_assignments and passes through', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
eventId: 42,
|
||||
// No via, no customerId — this is the legacy per-event-password
|
||||
// flow where every guest mints their own JWT after entering the
|
||||
// gallery password.
|
||||
});
|
||||
// Only events table should be queried. If the middleware regresses
|
||||
// and starts querying event_customer_assignments here, the second
|
||||
// db() call would have no mock implementation and the test would
|
||||
// surface an error.
|
||||
const eventsChain = {};
|
||||
eventsChain.where = jest.fn().mockReturnValue(eventsChain);
|
||||
eventsChain.select = jest.fn().mockReturnValue(eventsChain);
|
||||
eventsChain.first = jest.fn().mockResolvedValue({
|
||||
id: 42, slug: 'test-event', is_active: true, is_archived: false,
|
||||
});
|
||||
db.mockImplementationOnce(() => eventsChain);
|
||||
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await verifyGalleryAccess(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(db).toHaveBeenCalledTimes(1);
|
||||
expect(db.mock.calls[0][0]).toBe('events');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
@@ -513,11 +554,23 @@ async function ensureGlobalCategories() {
|
||||
// Helper function to log activities
|
||||
async function logActivity(activityType, metadata = {}, eventId = null, actor = null) {
|
||||
try {
|
||||
// actor_id is integer-typed; some legacy callers pass a hex-string
|
||||
// identifier (e.g. a 16-char guest fingerprint) which makes Postgres
|
||||
// throw "invalid input syntax for type integer" and drop the entire
|
||||
// log entry. Coerce anything non-integer to null and surface the
|
||||
// string in actor_name so we don't lose the audit trail. Customer/
|
||||
// admin actors are unaffected — their ids are already numeric.
|
||||
const rawId = actor?.id;
|
||||
const actorIdInt = Number.isInteger(rawId) ? rawId
|
||||
: (typeof rawId === 'string' && /^\d+$/.test(rawId) ? Number(rawId) : null);
|
||||
const actorName = actor?.name
|
||||
|| (actorIdInt === null && rawId !== undefined && rawId !== null ? String(rawId) : null);
|
||||
|
||||
await db('activity_logs').insert({
|
||||
activity_type: activityType,
|
||||
actor_type: actor?.type || 'system',
|
||||
actor_id: actor?.id || null,
|
||||
actor_name: actor?.name || null,
|
||||
actor_id: actorIdInt,
|
||||
actor_name: actorName,
|
||||
metadata: JSON.stringify(metadata),
|
||||
event_id: eventId
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Customer Authentication Middleware
|
||||
*
|
||||
* Verifies a 'customer' JWT issued by /api/customer/auth/login. Mirrors
|
||||
* adminAuth (same revocation, IP-log, password-change invalidation flow)
|
||||
* but operates on customer_accounts rather than admin_users — so an
|
||||
* admin token cannot pass as a customer and vice versa.
|
||||
*
|
||||
* Sets `req.customer = { id, email, displayName, isActive }` on success.
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const logger = require('../utils/logger');
|
||||
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
async function customerAuth(req, res, next) {
|
||||
try {
|
||||
const token = getCustomerTokenFromRequest(req);
|
||||
if (!token) {
|
||||
// Quiet by default — unauthenticated /api/customer/* requests are
|
||||
// normal (page polling, pre-login session probes). Bump to debug
|
||||
// for noisy investigations only.
|
||||
logger.debug('[customerAuth] no token on request', {
|
||||
url: req.originalUrl,
|
||||
hasCookieHeader: !!req.headers?.cookie,
|
||||
cookieKeys: Object.keys(req.cookies || {}),
|
||||
});
|
||||
return res.status(401).json({ error: 'No token provided', code: 'NO_TOKEN' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
const verified = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true,
|
||||
});
|
||||
decoded = verified.payload;
|
||||
} catch (err) {
|
||||
logger.warn('[customerAuth] jwt verification failed', {
|
||||
url: req.originalUrl,
|
||||
errorName: err.name,
|
||||
errorMessage: err.message,
|
||||
});
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid token', code: 'JWT_INVALID' });
|
||||
}
|
||||
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
logger.warn('[customerAuth] token revoked', {
|
||||
url: req.originalUrl,
|
||||
customerId: decoded.customerId,
|
||||
tokenType: decoded.type,
|
||||
iat: decoded.iat,
|
||||
});
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
if (decoded.type !== 'customer') {
|
||||
logger.warn('[customerAuth] wrong token type', {
|
||||
url: req.originalUrl,
|
||||
tokenType: decoded.type,
|
||||
});
|
||||
return res.status(403).json({ error: 'Insufficient permissions', code: 'WRONG_TOKEN_TYPE' });
|
||||
}
|
||||
|
||||
// IP drift gets logged but doesn't reject — same lenient policy as
|
||||
// adminAuth. Customers may roam between mobile networks frequently.
|
||||
const currentIp = req.ip || req.connection.remoteAddress;
|
||||
if (decoded.ip && decoded.ip !== currentIp) {
|
||||
logger.info('Customer token used from different IP', {
|
||||
customerId: decoded.customerId,
|
||||
tokenIp: decoded.ip,
|
||||
currentIp,
|
||||
});
|
||||
}
|
||||
|
||||
const customer = await db('customer_accounts')
|
||||
.where({ id: decoded.customerId, is_active: formatBoolean(true) })
|
||||
.select('id', 'email', 'display_name', 'first_name', 'last_name', 'password_changed_at', 'preferred_language')
|
||||
.first();
|
||||
|
||||
if (!customer) {
|
||||
// Either deleted, deactivated, or the id was forged. 401 across the
|
||||
// board so the frontend session-expiry handler kicks in.
|
||||
logger.warn('[customerAuth] customer row not found / inactive', {
|
||||
url: req.originalUrl,
|
||||
customerId: decoded.customerId,
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid token', code: 'CUSTOMER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
if (customer.password_changed_at) {
|
||||
const passwordChangedSeconds = Math.floor(
|
||||
new Date(customer.password_changed_at).getTime() / 1000
|
||||
);
|
||||
if (decoded.iat < passwordChangedSeconds) {
|
||||
logger.warn('[customerAuth] token rejected: password_changed_at', {
|
||||
url: req.originalUrl,
|
||||
customerId: decoded.customerId,
|
||||
iat: decoded.iat,
|
||||
passwordChangedSeconds,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: 'Token invalid due to password change',
|
||||
code: 'PASSWORD_CHANGED',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
req.customer = {
|
||||
id: customer.id,
|
||||
email: customer.email,
|
||||
displayName: customer.display_name,
|
||||
firstName: customer.first_name,
|
||||
lastName: customer.last_name,
|
||||
preferredLanguage: customer.preferred_language || 'en',
|
||||
};
|
||||
req.token = token;
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Customer auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { customerAuth };
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,11 +120,41 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
|
||||
// Customer-minted gallery JWTs (#354): when the customer obtained
|
||||
// this token via /api/customer/events/:slug/access-token, the
|
||||
// payload carries `via:'customer'` and `customerId`. The admin
|
||||
// can revoke the customer's access at any time by removing the
|
||||
// event_customer_assignments row from the "Manage galleries"
|
||||
// dialog on the customer detail page. Re-check that row here so
|
||||
// the revocation takes effect on the customer's very next
|
||||
// request — no token-blacklisting machinery required.
|
||||
if (decoded.via === 'customer' && decoded.customerId) {
|
||||
const assignment = await withRetry(async () => {
|
||||
return await db('event_customer_assignments')
|
||||
.where({
|
||||
event_id: event.id,
|
||||
customer_account_id: decoded.customerId,
|
||||
})
|
||||
.first();
|
||||
});
|
||||
if (!assignment) {
|
||||
logger.info('[verifyGalleryAccess] Customer assignment revoked, rejecting token', {
|
||||
customerId: decoded.customerId,
|
||||
eventId: event.id,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: 'Access to this gallery has been revoked',
|
||||
code: 'CUSTOMER_ASSIGNMENT_REVOKED',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||
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 +172,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,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user