Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Nothaft e94e440858 screenshot: admin github button (#778) 2026-07-10 09:50:18 +02:00
150 changed files with 948 additions and 9101 deletions
-5
View File
@@ -106,11 +106,6 @@ VITE_API_URL=/api
# DB_PORT=5432
# REDIS_PORT=6379
# File watcher (watch-folder auto-import, local storage only)
# Max photos processed in parallel — raise on hosts with memory headroom,
# lower to 1 on very small hosts. Default: 2
# FILE_WATCHER_CONCURRENCY=2
# Release Channel
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
# 'stable' uses the :stable tag (same as :latest on main)
+4 -97
View File
@@ -95,15 +95,6 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Prepare platform pair
run: |
@@ -242,15 +233,6 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Download digest artifacts
uses: actions/download-artifact@v4
@@ -284,24 +266,11 @@ jobs:
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
if: env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Backend
id: meta-backend
uses: docker/metadata-action@v5
with:
# GHCR always; Docker Hub (picpeak/backend) added on the canonical repo so
# the same tag scheme is mirrored to both registries. metadata-action drops
# the blank second line on forks → GHCR-only there.
images: |
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/backend' || '' }}
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Backend
org.opencontainers.image.description=PicPeak photo sharing platform backend service
@@ -313,10 +282,6 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
# so users can pin the same string as the GitHub release. metadata-action's
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
type=ref,event=tag
type=sha,format=short
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
@@ -333,15 +298,10 @@ jobs:
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest (GHCR)
- name: Inspect manifest
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
- name: Inspect manifest (Docker Hub)
if: env.DOCKERHUB_ENABLED == 'true'
run: |
docker buildx imagetools inspect docker.io/picpeak/backend:${{ steps.meta-backend.outputs.version }}
# -----------------------------------------------------------------------------
# Frontend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
@@ -371,15 +331,6 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Prepare platform pair
run: |
@@ -499,15 +450,6 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Download digest artifacts
uses: actions/download-artifact@v4
@@ -541,24 +483,11 @@ jobs:
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
if: env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
# GHCR always; Docker Hub (picpeak/frontend) added on the canonical repo so
# the same tag scheme is mirrored to both registries. metadata-action drops
# the blank second line on forks → GHCR-only there.
images: |
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/frontend' || '' }}
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Frontend
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
@@ -570,10 +499,6 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
# so users can pin the same string as the GitHub release. metadata-action's
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
type=ref,event=tag
type=sha,format=short
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
@@ -590,15 +515,10 @@ jobs:
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest (GHCR)
- name: Inspect manifest
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
- name: Inspect manifest (Docker Hub)
if: env.DOCKERHUB_ENABLED == 'true'
run: |
docker buildx imagetools inspect docker.io/picpeak/frontend:${{ steps.meta-frontend.outputs.version }}
summary:
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
if: always()
@@ -612,15 +532,6 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Build Summary
run: |
@@ -659,10 +570,6 @@ jobs:
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
if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
-1
View File
@@ -25,7 +25,6 @@ jobs:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
target-branch: stable
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
+2 -2
View File
@@ -17,9 +17,9 @@ name: Tests
on:
push:
branches: [main, beta, stable]
branches: [main, beta]
pull_request:
branches: [main, beta, stable]
branches: [main, beta]
workflow_dispatch:
permissions:
+2 -3
View File
@@ -130,6 +130,5 @@ docker-compose.dev.yml
# New layout development files
new-layouts/
# Backend runtime storage (generated media, previews, thumbnails,
# CRM/accounting documents) — never commit
backend/storage/
# Generated CRM/accounting documents (runtime) — never commit
backend/storage/business-docs/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.92.2-beta.0"
".": "3.82.4-beta.0"
}
+3 -1
View File
@@ -1 +1,3 @@
{".":"3.44.0"}
{
".": "2.6.1"
}
-203
View File
@@ -5,209 +5,6 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.92.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.92.1-beta.0...v3.92.2-beta.0) (2026-07-19)
### Bug Fixes
* **file-watcher:** bound concurrent photo processing ([#846](https://github.com/PicPeak/picpeak/issues/846)) ([8337a71](https://github.com/PicPeak/picpeak/commit/8337a716b169e66f8edf8619c64622e6853dae81))
* **security:** read the password-complexity key the settings UI writes ([#843](https://github.com/PicPeak/picpeak/issues/843)) ([8060fed](https://github.com/PicPeak/picpeak/commit/8060fedf6aaea5359c3bf04696fd00ec8500b51a))
* **uploads:** keep videos when thumbnail generation fails ([#845](https://github.com/PicPeak/picpeak/issues/845)) ([0310c46](https://github.com/PicPeak/picpeak/commit/0310c46fdd5b03274f761abfb4c8b552e2f8b666))
## [3.92.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.92.0-beta.0...v3.92.1-beta.0) (2026-07-19)
### Bug Fixes
* **uploads:** support configured raw formats ([f7fd893](https://github.com/PicPeak/picpeak/commit/f7fd89387be80ea9b3b5c11d06828a4c1a0d4af5))
## [3.92.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.91.0-beta.0...v3.92.0-beta.0) (2026-07-18)
### Features
* **uploads:** DNG / camera-RAW support via embedded-preview extraction ([#821](https://github.com/PicPeak/picpeak/issues/821)) ([8c260c4](https://github.com/PicPeak/picpeak/commit/8c260c4eebedb69f349505d0befbbb5afb182b2c))
## [3.91.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.2-beta.0...v3.91.0-beta.0) (2026-07-18)
### Features
* **uploads:** HEIC/HEIF support + dynamic format hint on guest upload ([#821](https://github.com/PicPeak/picpeak/issues/821)) ([ee9d2f7](https://github.com/PicPeak/picpeak/commit/ee9d2f70d3342d65edb795a688f0f5f611429964))
### Bug Fixes
* **gallery:** serve JPEG preview for non-displayable originals in lightbox (codex review of [#832](https://github.com/PicPeak/picpeak/issues/832)) ([808d305](https://github.com/PicPeak/picpeak/commit/808d3055497bb4e4a372acafa49ef9baf257f008))
* **uploads:** register HEIC/HEIF with the file validator + fix admin format hint (codex review of [#832](https://github.com/PicPeak/picpeak/issues/832)) ([c9b64d9](https://github.com/PicPeak/picpeak/commit/c9b64d9c1a8744c9ee5e068366a500ae0dab36bc))
## [3.90.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.1-beta.0...v3.90.2-beta.0) (2026-07-17)
### Bug Fixes
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([0245e44](https://github.com/PicPeak/picpeak/commit/0245e445cafd165ada3c5a15abb258ae2c1c857e))
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([b97b130](https://github.com/PicPeak/picpeak/commit/b97b130cadebaef38e59cc227fa6578ac886110f))
* **update:** target docker-compose.production.yml in dashboard update steps ([51a505e](https://github.com/PicPeak/picpeak/commit/51a505e3798895e544f943673e81a365265f319c))
* **update:** target docker-compose.production.yml in dashboard update steps + gate mailhog ([2a0361a](https://github.com/PicPeak/picpeak/commit/2a0361a83b4ca0a600bb4fd447e338533ce63420))
* **uploads:** apply configured max file size to guest uploads ([#613](https://github.com/PicPeak/picpeak/issues/613) follow-up) ([29f1d23](https://github.com/PicPeak/picpeak/commit/29f1d23a0a645208f22453e62d99fe79b55c7db4))
* **uploads:** apply configured max file size to guest uploads ([#613](https://github.com/PicPeak/picpeak/issues/613) follow-up) ([1e38d84](https://github.com/PicPeak/picpeak/commit/1e38d84808ee2a2b176c75d5ec4975fba710e63c))
* **uploads:** tighten guest max-file-size setting (codex review of [#823](https://github.com/PicPeak/picpeak/issues/823)) ([43c6d22](https://github.com/PicPeak/picpeak/commit/43c6d22bdd93179865703da6350094c9b95388d8))
* **uploads:** tighten guest max-file-size setting (codex review of [#823](https://github.com/PicPeak/picpeak/issues/823)) ([e03d13e](https://github.com/PicPeak/picpeak/commit/e03d13efde843c7a7275cd41c855b402538756e7))
## [3.90.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.0-beta.0...v3.90.1-beta.0) (2026-07-17)
### Bug Fixes
* **security:** remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq) ([e7ca8bd](https://github.com/PicPeak/picpeak/commit/e7ca8bdb7f30d999039125c0f0ef89bdc92d5a69))
* **security:** remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq) ([6cd546e](https://github.com/PicPeak/picpeak/commit/6cd546e86ae38819c0fdc24044f86106503fa020))
## [3.90.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.89.0-beta.0...v3.90.0-beta.0) (2026-07-16)
### Features
* **auth:** OIDC SSO for admin users — phase 1 ([f12606b](https://github.com/PicPeak/picpeak/commit/f12606b4e0d2fbe4f2f57a345b393448063d6614))
## [3.89.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.88.1-beta.0...v3.89.0-beta.0) (2026-07-16)
### Features
* **security:** harden .picpeak restore robustness — sessions, roles, sequences ([a77c2c2](https://github.com/PicPeak/picpeak/commit/a77c2c2c573a79f0194ff2b911acaa5f46c11f26))
* **security:** harden .picpeak restore robustness — sessions, roles, sequences ([340d91b](https://github.com/PicPeak/picpeak/commit/340d91bdd53a595694edfa6f3d691b240a2babcd))
### Bug Fixes
* **security:** close 4 open security advisories (backup takeover, share-login bypass, ZIP slip, chunked-upload traversal) ([7ebc232](https://github.com/PicPeak/picpeak/commit/7ebc2326204ad0572e6a1fc121b5d232da06cec3))
* **security:** harden .picpeak restore operator-preservation (GHSA-qxfx follow-up) ([38fd41a](https://github.com/PicPeak/picpeak/commit/38fd41aad3fcb12a249aaa2eb3d98fbffbde537a))
* **security:** preserve current admin on .picpeak restore (GHSA-qxfx-4493-4v8f) ([348894e](https://github.com/PicPeak/picpeak/commit/348894efefa5a7b49d32feb22a98045b93076138))
* **security:** reject ZIP-slip entries in archive/backup restore (GHSA-jfhw-fj23-fx6x) ([9cd6b08](https://github.com/PicPeak/picpeak/commit/9cd6b08441e8633751b9fb73daca5ca0555c950b))
* **security:** sanitize chunked-upload filename (GHSA-pc72-jf53-w28j) ([31bc01c](https://github.com/PicPeak/picpeak/commit/31bc01cb4bbf65b48b3a5c3c94ad35e487df9fcc))
* **security:** share-login must not bypass gallery password (GHSA-9hmx-68vc-qpqw) ([7dace04](https://github.com/PicPeak/picpeak/commit/7dace044dcc1c3b5a13c4704510c87616632618c))
## [3.88.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.88.0-beta.0...v3.88.1-beta.0) (2026-07-16)
### Bug Fixes
* **security:** mask backup credentials on read + unblock MFA login during maintenance ([eadf282](https://github.com/PicPeak/picpeak/commit/eadf282755829cb51e6ea37221be31d8c9af41c5))
* **security:** mask backup credentials on read + unblock MFA login during maintenance ([07f2c90](https://github.com/PicPeak/picpeak/commit/07f2c900556738e993fb63764210b541d7692c9d))
## [3.88.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.87.0-beta.0...v3.88.0-beta.0) (2026-07-15)
### Features
* **setup:** event-types step in first-run wizard + un-hardcode event type dependencies ([109aba8](https://github.com/PicPeak/picpeak/commit/109aba859820bf80440d056baf183ecf2657fee3))
* **setup:** event-types step in first-run wizard + un-hardcode event type deps ([#800](https://github.com/PicPeak/picpeak/issues/800)) ([7eb6357](https://github.com/PicPeak/picpeak/commit/7eb6357b4a9bf3914674a63afa386a5fcf8c2161))
### Bug Fixes
* **event-types:** harden setup window + catalog validation (codex review) ([f8ba669](https://github.com/PicPeak/picpeak/commit/f8ba6697163b4d9aa0fa0014cb5b0810371c04ae))
* **event-types:** un-hardcode event type dependencies in v1 API and CRM ([d64eef8](https://github.com/PicPeak/picpeak/commit/d64eef8abf2915230b3cdd38a3bbb8af1a12c6d2))
* **event-types:** un-hardcode event type dependencies in v1 API and CRM ([#800](https://github.com/PicPeak/picpeak/issues/800)) ([5da1c3a](https://github.com/PicPeak/picpeak/commit/5da1c3a12f603a230091426b1d7be0eac83da22c))
* **gallery:** show feedback filter chips on desktop for galleries without categories ([0751a08](https://github.com/PicPeak/picpeak/commit/0751a08aa661a430c1609cd8c118347291cbaa14))
* **gallery:** show feedback filter chips on desktop for galleries without categories ([#802](https://github.com/PicPeak/picpeak/issues/802)) ([b928338](https://github.com/PicPeak/picpeak/commit/b9283386a57431ac8bd395347f9acb9bbdf82e8e))
## [3.87.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.86.0-beta.0...v3.87.0-beta.0) (2026-07-11)
### Features
* **invoices:** configurable VAT note under MwSt. line + fix multi-page page-number overlap ([#794](https://github.com/PicPeak/picpeak/issues/794)) ([ffd4a7e](https://github.com/PicPeak/picpeak/commit/ffd4a7eee64b6418df1c9cc6843d86dc0f41d2ec))
* **invoices:** configurable VAT/free-text note + fix multi-page page-number overlap ([#794](https://github.com/PicPeak/picpeak/issues/794)) ([1476884](https://github.com/PicPeak/picpeak/commit/1476884dd04202f5f18d50d458b6176b0535c71b))
## [3.86.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.85.0-beta.0...v3.86.0-beta.0) (2026-07-10)
### Features
* **categories:** per-event category ordering — global default + override ([#782](https://github.com/PicPeak/picpeak/issues/782)) ([d51112e](https://github.com/PicPeak/picpeak/commit/d51112e761d2fd83f1939841fbf4c05e625fc34d))
* **categories:** per-event category ordering — global default + override ([#782](https://github.com/PicPeak/picpeak/issues/782)) ([4698402](https://github.com/PicPeak/picpeak/commit/4698402b5493cfbdb1e2b6d81c6f58829e17a703))
### Bug Fixes
* **categories:** address PR [#790](https://github.com/PicPeak/picpeak/issues/790) review — event ownership, migration renumber, nits ([a4b4485](https://github.com/PicPeak/picpeak/commit/a4b4485d322514690c5400ca7ab9a91bc25c3e48))
## [3.85.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.84.1-beta.0...v3.85.0-beta.0) (2026-07-10)
### Features
* **slideshow:** per-event play order + category filter ([#202](https://github.com/PicPeak/picpeak/issues/202)) ([5467642](https://github.com/PicPeak/picpeak/commit/54676424f2f7ed50e74cb8e144cbdaa5a96e65c3))
## [3.84.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.84.0-beta.0...v3.84.1-beta.0) (2026-07-10)
### Bug Fixes
* **ci:** publish v-prefixed image tags via type=ref,event=tag ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([1f3bc3c](https://github.com/PicPeak/picpeak/commit/1f3bc3c3430414b5b6cb2141d887a8b5855a04af))
* **ci:** publish v-prefixed image tags via type=ref,event=tag ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([39db7bf](https://github.com/PicPeak/picpeak/commit/39db7bf6cb5c39fcdf71c875a4aaf704f34447fa))
## [3.84.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.83.1-beta.0...v3.84.0-beta.0) (2026-07-10)
### Features
* **admin:** GitHub repo button in the sidebar footer ([#778](https://github.com/PicPeak/picpeak/issues/778)) ([279e047](https://github.com/PicPeak/picpeak/commit/279e0472c71c6a37ba091a9c7a31f5571c0a8df6))
* **admin:** GitHub repo button in the sidebar footer ([#778](https://github.com/PicPeak/picpeak/issues/778)) ([d3d7df4](https://github.com/PicPeak/picpeak/commit/d3d7df46f214028ba89063079d356bc0430083f5))
### Bug Fixes
* **ci:** publish v-prefixed image tags so :vX.Y.Z resolves ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([2ee4146](https://github.com/PicPeak/picpeak/commit/2ee4146d9a6fd026e7b7be3ba774de9a0cf6e96a))
* **ci:** publish v-prefixed image tags so :vX.Y.Z resolves ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([784d059](https://github.com/PicPeak/picpeak/commit/784d059c3da5b36e2b6794ebf2e34bc15c8a9824))
### Documentation
* **releasing:** align stable version to main on promote (Option A) ([df5aeab](https://github.com/PicPeak/picpeak/commit/df5aeaba416726cc0123f32ddf88e4a30dc28908))
* **releasing:** align stable version to main on promote (Option A) ([5dea0c9](https://github.com/PicPeak/picpeak/commit/5dea0c969558f50833973ff742257780f5842612))
## [3.83.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.83.0-beta.0...v3.83.1-beta.0) (2026-07-09)
### Bug Fixes
* **release:** target stable in release-please + undo bogus 2.7.0 bump ([274ef0c](https://github.com/PicPeak/picpeak/commit/274ef0cd731765b057a5d62d5f41c14cb3a1564b))
* **release:** target stable in release-please.yml + undo the bogus 2.7.0 bump ([65ac6ed](https://github.com/PicPeak/picpeak/commit/65ac6eddacb79857e9a9651d3c869e7bfdd92887))
## [3.83.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.6-beta.0...v3.83.0-beta.0) (2026-07-08)
### Features
* **messages:** create/select quote, contract, invoice, gallery from a message ([0dbf863](https://github.com/PicPeak/picpeak/commit/0dbf863f60b919560b766f78b107ebac9612bd9d))
* **messages:** search bar + Archive/Delete with Archived & Deleted folders ([99d5996](https://github.com/PicPeak/picpeak/commit/99d5996561a2dcff2d431692d5bab5c7286d1f6f))
* **messages:** unified Messages email client (flag-gated, default off) ([a71b9b5](https://github.com/PicPeak/picpeak/commit/a71b9b5ed721df17b61062ae3a2361d448c95cf7))
### Bug Fixes
* **messages:** PR [#769](https://github.com/PicPeak/picpeak/issues/769) nits — server-side search, bare-email recipient, DE i18n ([1e08a4f](https://github.com/PicPeak/picpeak/commit/1e08a4fb156d34ee8ddff69b0a7612001aa6d67e))
* **messages:** PR [#769](https://github.com/PicPeak/picpeak/issues/769) review — escape reply sender (XSS), gate backend routes, exact customer match ([bb235e7](https://github.com/PicPeak/picpeak/commit/bb235e72e58359f55f8aeccf2f22c671584fdbd7))
* **messages:** show the resolved customer's name in the doc-action modal ([2c5c1d5](https://github.com/PicPeak/picpeak/commit/2c5c1d561bbe567b9d7615e7c2d071d08bb6d63c))
## [3.82.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.5-beta.0...v3.82.6-beta.0) (2026-07-07)
### Bug Fixes
* **workflows:** backfill existing invoices + anchor dunning grace to due date when enabled ([#750](https://github.com/PicPeak/picpeak/issues/750)) ([9596342](https://github.com/PicPeak/picpeak/commit/9596342d6a9ef107193cfc123487a8061f4a91ca))
* **workflows:** scope dunning backfill to its own flow via targetWorkflowId ([da3a77d](https://github.com/PicPeak/picpeak/commit/da3a77dac40a892158167aec939a1458d488a951))
## [3.82.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.4-beta.0...v3.82.5-beta.0) (2026-07-07)
### Bug Fixes
* **admin:** stop the event-date field crashing the page on backspace ([760a201](https://github.com/PicPeak/picpeak/commit/760a201b6070a4edfe8192bcddce948c5f0c3fec))
## [3.82.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.3-beta.0...v3.82.4-beta.0) (2026-07-07)
+4 -18
View File
@@ -52,19 +52,13 @@ The actual mechanics, in order:
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
5. **Pin the stable version to match `main` (number alignment — see Versioning).** Determine `X.Y.Z` = the `main` tip's **base** version (its `vX.Y.Z-beta.N`, dropping the `-beta.N` suffix), and add an empty commit on the release branch:
```bash
git commit --allow-empty -m "chore: release X.Y.Z" -m "Release-As: X.Y.Z"
```
The `Release-As:` footer forces release-please to cut exactly `X.Y.Z`. Without it, release-please computes the next MINOR from the *previous stable* tag (e.g. `3.45.0` → `3.46.0`) while `main` is already at `3.84.x`, so the stable number drifts ever further behind for the same code.
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
6. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
7. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
8. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
9. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
8. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
## Hotfix path (backport to current stable)
@@ -89,14 +83,6 @@ PicPeak follows [Semantic Versioning](https://semver.org/) with one project-spec
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
### Stable ↔ pre-release number alignment
The two channels run **independent** release-please counters: `main` bumps on every merge (racing ahead), while `stable` only bumps on a promotion. Left to itself, `stable` computes each promotion as the next MINOR from the *previous stable tag*, so the two drift far apart — e.g. `main` at `v3.83.x-beta.0` while `stable` sat at `v3.45.0` for the **same code**, which reads as "stable is 38 versions behind" when it isn't.
To keep the numbers legible, **a promotion sets the stable version to the current `main` base version** (the `X.Y.Z` of the `main` tip's `vX.Y.Z-beta.N`, minus the suffix). Promoting a `main` at `v3.84.2-beta.0` therefore cuts stable `v3.84.2`, and the stable number tracks `main` instead of lagging. This is forced with the `Release-As:` commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. `3.45.0 → 3.84.x`) is expected and happens only on the first aligned promotion.
> **Release-engineering note (2026-07):** `release-please.yml` (the stable workflow) *must* keep `target-branch: stable`. Without it, release-please defaults to the repo's default branch (`main`), reads `main`'s stale `.release-please-manifest.json`, and cuts a wrong/regressed version — this is what produced a bogus `v2.7.0` once. If a promotion ever yields an unexpected version, check that first.
## Things that don't go through this process
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
+3 -1
View File
@@ -1,7 +1,9 @@
node_modules
npm-debug.log
.env
storage
storage/events/active/*
storage/events/archived/*
storage/thumbnails/*
data/*.db
logs/*
coverage
-6
View File
@@ -106,12 +106,6 @@ ARCHIVE_PATH=/app/storage/events/archived
# EVENTS_PATH=./storage/events
# ARCHIVE_PATH=./storage/events/archived
# File watcher (auto-import from the events/active folder, local storage only)
# Max photos processed in parallel by the watcher. The boot scan and bulk
# folder drops fire one handler per file — this bound keeps thumbnail
# generation from exhausting memory on small hosts. Default: 2
# FILE_WATCHER_CONCURRENCY=2
# Analytics Backend Configuration (OPTIONAL)
# Used for server-side tracking only
# Primary configuration should be done through Admin UI > Settings > Analytics
+2 -12
View File
@@ -27,15 +27,8 @@ FROM node:22-alpine
WORKDIR /app
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
# stage's declaration never reached this stage. Consuming it in the RUN below
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
# image always picks up current Alpine security updates instead of reusing a
# stale cached upgrade layer.
ARG CACHEBUST=1
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
RUN apk upgrade --no-cache
# Upgrade the npm CLI in the final image so its bundled deps are patched
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
@@ -67,11 +60,8 @@ RUN npm install -g npm@11
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
# documents (see docs/accounting-inbound-invoices.md).
# exiftool extracts the embedded full-res JPEG preview from RAW/DNG uploads
# (Apple ProRAW etc.) — sharp's libvips has no raw loader, so the pipeline
# thumbnails/displays that preview while keeping the original for download.
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
fontconfig ttf-dejavu ttf-liberation poppler-utils && \
fc-cache -f
# Create non-root user
+1 -4
View File
@@ -8,10 +8,7 @@ RUN apk upgrade --no-cache
# 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.
# exiftool: extract embedded JPEG previews from RAW/DNG uploads (#821) — kept in
# sync with the production Dockerfile so dev/native runtimes don't accept a DNG
# and then fail it with ENOENT.
RUN apk add --no-cache dumb-init ffmpeg exiftool
RUN apk add --no-cache dumb-init ffmpeg
# Copy package files
COPY package*.json ./
@@ -1,108 +0,0 @@
/**
* Backup credential exposure regression tests.
*
* The generic settings reads (GET /admin/settings, GET /admin/settings/:type)
* masked the recaptcha/umami/rybbit keys but returned backup_s3_secret_key
* and backup_rsync_ssh_key (an SSH PRIVATE KEY) in plaintext to any
* settings.view holder; GET /admin/backup/config returned them too. Both now
* mask, and PUT /admin/backup/config skips the mask sentinel so the edit
* form round-trips without clobbering stored credentials.
*/
const request = require('supertest');
const express = require('express');
const { bootCrmDb } = require('./helpers/crmDb');
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => {
req.admin = { id: 1, username: 'test-admin' };
next();
},
}));
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
describe('backup credential masking', () => {
let db;
let cleanup;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Upsert: several backup_* keys are pre-seeded by the backup migrations.
const seed = [
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('s3'), setting_type: 'backup' },
{ setting_key: 'backup_s3_endpoint', setting_value: JSON.stringify('https://s3.example.com'), setting_type: 'backup' },
{ setting_key: 'backup_s3_bucket', setting_value: JSON.stringify('backups'), setting_type: 'backup' },
{ setting_key: 'backup_s3_access_key', setting_value: JSON.stringify('AKIAEXAMPLE'), setting_type: 'backup' },
{ setting_key: 'backup_s3_secret_key', setting_value: JSON.stringify('super-secret-s3-key'), setting_type: 'backup' },
{ setting_key: 'backup_rsync_ssh_key', setting_value: JSON.stringify('-----BEGIN OPENSSH PRIVATE KEY-----abc'), setting_type: 'backup' },
];
for (const row of seed) {
await db('app_settings').insert(row).onConflict('setting_key').merge();
}
app = express();
app.use(express.json());
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('masks the credentials in GET /admin/backup/config', async () => {
const res = await request(app).get('/api/admin/backup/config').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
// Non-secret fields stay readable for the form.
expect(res.body.backup_s3_bucket).toBe('backups');
});
it('masks the credentials in the generic GET /admin/settings/:type read', async () => {
const res = await request(app).get('/api/admin/settings/backup').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
});
it('masks the credentials in the generic GET /admin/settings read', async () => {
const res = await request(app).get('/api/admin/settings').expect(200);
expect(res.body.backup_s3_secret_key).toBe('••••••••');
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
});
it('PUT /admin/backup/config keeps the stored secret when the sentinel round-trips', async () => {
await request(app)
.put('/api/admin/backup/config')
.send({
backup_destination_type: 's3',
backup_s3_endpoint: 'https://s3.example.com',
backup_s3_bucket: 'renamed-bucket',
backup_s3_access_key: 'AKIAEXAMPLE',
backup_s3_secret_key: '••••••••',
backup_rsync_ssh_key: '••••••••',
})
.expect(200);
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
expect(JSON.parse(secret.setting_value)).toBe('super-secret-s3-key');
const sshKey = await db('app_settings').where({ setting_key: 'backup_rsync_ssh_key' }).first();
expect(JSON.parse(sshKey.setting_value)).toBe('-----BEGIN OPENSSH PRIVATE KEY-----abc');
const bucket = await db('app_settings').where({ setting_key: 'backup_s3_bucket' }).first();
expect(JSON.parse(bucket.setting_value)).toBe('renamed-bucket');
});
it('PUT /admin/backup/config stores a genuinely new secret', async () => {
await request(app)
.put('/api/admin/backup/config')
.send({ backup_s3_secret_key: 'rotated-s3-key' })
.expect(200);
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
expect(JSON.parse(secret.setting_value)).toBe('rotated-s3-key');
});
});
@@ -1,211 +0,0 @@
/**
* Layered per-event category ordering (#782).
*
* Two ordering layers, resolved per event:
* - GLOBAL default — photo_categories.display_order (migration 159),
* set via POST /reorder-global; applies everywhere.
* - PER-EVENT override — event_category_order (migration 160), set via
* POST /reorder; overrides the default for one gallery.
* - DELETE /reorder/:eventId clears an event's override.
*
* Verified against a real SQLite DB with the full core-migration set applied.
*/
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('category ordering (#782)', () => {
let db;
let cleanup;
let token;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const auth = (r) => r.set('Authorization', `Bearer ${token}`);
async function insertEvent(slug) {
await db('events').insert({
event_type: 'wedding', password_hash: 'x',
expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug, share_link: slug,
event_name: slug, event_date: '2026-01-01',
});
return (await db('events').where({ slug }).first()).id;
}
async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) {
const res = await db('photo_categories').insert({
name,
slug: name.toLowerCase().replace(/\s+/g, '-'),
is_global: is_global ? 1 : 0,
event_id,
display_order,
}).returning('id');
return res[0]?.id ?? res[0];
}
const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200);
describe('migration 159 backfill', () => {
it('seeds display_order from alphabetical order, scoped per event', async () => {
const eventId = await insertEvent('backfill-ev');
await insertCat('Reception', { event_id: eventId });
await insertCat('Ceremony', { event_id: eventId });
await insertCat('Pre-Ceremony', { event_id: eventId });
// Re-run the migration: addColumn is guarded (no-op); the backfill loop
// re-runs and assigns per-scope alphabetical order — what an upgrade does.
await require('../../migrations/core/159_add_category_display_order').up(db);
const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc');
expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']);
expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]);
});
});
describe('global default order (POST /reorder-global)', () => {
it('reverses the global order and every non-customised event follows it', async () => {
const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body;
expect(before.length).toBeGreaterThan(1);
const reversedIds = before.map((c) => c.id).reverse();
const res = await auth(request(app).post('/api/admin/categories/reorder-global'))
.send({ orderedIds: reversedIds })
.expect(200);
expect(res.body.map((c) => c.id)).toEqual(reversedIds);
// A fresh event (no override) shows globals in the new global order.
const eventId = await insertEvent('follows-global');
const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id);
expect(globalsInEvent).toEqual(reversedIds);
});
});
describe('per-event override (POST /reorder)', () => {
it('pins a custom order for one event without affecting another', async () => {
const eventA = await insertEvent('override-a');
const eventB = await insertEvent('override-b');
const a1 = await insertCat('A-Ceremony', { event_id: eventA });
const a2 = await insertCat('A-Reception', { event_id: eventA });
// Current resolved list for A (globals + A's two categories).
const listA = (await getEvent(eventA)).body;
// Put A-Reception first, then A-Ceremony, then the globals in their order.
const globalsA = listA.filter((c) => c.is_global).map((c) => c.id);
const desired = [a2, a1, ...globalsA];
const res = await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventA, orderedIds: desired })
.expect(200);
expect(res.body.map((c) => c.id)).toEqual(desired);
// override_position is set on every row for a customised event.
expect(res.body.every((c) => c.override_position != null)).toBe(true);
// Event B is untouched — no override, follows the global default.
const listB = (await getEvent(eventB)).body;
expect(listB.every((c) => c.override_position == null)).toBe(true);
});
it('accepts global ids but rejects another events category', async () => {
const eventId = await insertEvent('scope-ev');
const own = await insertCat('Own', { event_id: eventId });
const global = (await db('photo_categories').where('is_global', 1).first()).id;
const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') });
// A global id is allowed (globals can be arranged per event).
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [own, global] })
.expect(200);
// A foreign event's category is out of scope.
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [own, foreign] })
.expect(400);
});
});
describe('reset (DELETE /reorder/:eventId)', () => {
it('clears the override and reverts to the global default', async () => {
const eventId = await insertEvent('reset-ev');
const c1 = await insertCat('R-One', { event_id: eventId });
const list = (await getEvent(eventId)).body;
const globals = list.filter((c) => c.is_global).map((c) => c.id);
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [c1, ...globals] })
.expect(200);
expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true);
const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200);
expect(res.body.every((c) => c.override_position == null)).toBe(true);
expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined();
});
});
describe('event ownership (PR #790 review)', () => {
let limitedToken;
let foreignEventId;
beforeAll(async () => {
const bcrypt = require('bcrypt');
// A non-super_admin role that DOES hold settings.view + settings.edit —
// the exact case the review flagged (settings.edit is grantable).
const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id');
const roleId = roleRes[0]?.id ?? roleRes[0];
const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id');
await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id })));
const a2 = await db('admin_users').insert({
username: 'limited', email: 'limited@example.com',
password_hash: await bcrypt.hash('x', 4), role_id: roleId,
must_change_password: false, created_at: new Date(),
}).returning('id');
limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]);
// An event owned by a DIFFERENT admin (the seeded super_admin).
const owner = (await db('admin_users').where({ username: 'tester' }).first()).id;
await db('events').insert({
event_type: 'wedding', password_hash: 'x',
expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev',
event_name: 'Owned', event_date: '2026-01-01', created_by: owner,
});
foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id;
});
const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`);
it('blocks a non-owner from reading, reordering or resetting another event', async () => {
await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403);
await limitedAuth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: foreignEventId, orderedIds: [1] }).expect(403);
await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403);
});
});
describe('POST / (create) appends to the end of its scope', () => {
it('assigns display_order = max + 1 within the event', async () => {
const eventId = await insertEvent('append-ev');
await insertCat('First', { event_id: eventId, display_order: 1 });
await insertCat('Second', { event_id: eventId, display_order: 2 });
const res = await auth(request(app).post('/api/admin/categories'))
.send({ name: 'Third', is_global: false, event_id: eventId })
.expect(200);
expect(res.body.display_order).toBe(3);
});
});
});
@@ -1,50 +0,0 @@
/**
* Catalog-driven event-type defaults (#800 follow-up).
*
* The contract→event conversion used to hardcode `event_type: 'wedding'` and
* the v1 API validated against a fixed whitelist. Both now follow the live
* event_types catalog; these tests pin the shared resolver.
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('resolveDefaultEventType follows the catalog', () => {
let db;
let cleanup;
let eventTypeService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Require AFTER bootCrmDb so the service shares this db instance
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
eventTypeService = require('../../src/services/eventTypeService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it("prefers the 'other' catch-all while it is active", async () => {
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
});
it('falls over to the first active type when other is deactivated', async () => {
const other = await db('event_types').where({ slug_prefix: 'other' }).first();
await db('event_types').where({ id: other.id }).update({ is_active: 0 });
const resolved = await eventTypeService.resolveDefaultEventType();
expect(resolved).not.toBe('other');
expect(await db('event_types').where({ slug_prefix: resolved }).first()).toBeTruthy();
await db('event_types').where({ id: other.id }).update({ is_active: 1 });
});
it("returns the literal 'other' only for an empty catalog", async () => {
const rows = await db('event_types').select('*');
await db('event_types').del();
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
await db('event_types').insert(rows);
});
});
@@ -1,133 +0,0 @@
/**
* Setup-window event type deletion (#800).
*
* The first-run setup wizard may delete the seeded SYSTEM event types —
* but ONLY while the `setup_wizard_completed` flag is unset (migration 161
* seeds it false on a fresh install, true when an admin already exists).
* These tests pin the whole contract:
*
* - fresh install → flag false → system types deletable (in-use checks
* still apply), and the per-type reminder template goes with the type
* - reminder-template self-heal does NOT resurrect templates for slugs
* that no longer exist in the catalog
* - after markSetupWizardCompleted() → system deletion is refused again
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('event type deletion during the setup window (#800)', () => {
let db;
let cleanup;
let eventTypeService;
let setupService;
let ensureEventReminderTemplatesSeeded;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Require AFTER bootCrmDb so every service shares this db instance
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
eventTypeService = require('../../src/services/eventTypeService');
setupService = require('../../src/services/setupService');
({ ensureEventReminderTemplatesSeeded } = require('../../src/services/eventReminderTemplates'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('migration 161 seeds the flag false on a fresh (admin-less) install', async () => {
const row = await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).first();
expect(row).toBeTruthy();
expect(JSON.parse(row.setting_value)).toBe(false);
expect(await setupService.isSetupWizardCompleted()).toBe(false);
});
it('refuses to delete a system type that events already use, even in the window', async () => {
const corporate = await db('event_types').where({ slug_prefix: 'corporate' }).first();
await db('events').insert({
slug: 'corporate-test-2026-01-01',
event_name: 'Test',
event_type: 'corporate',
event_date: '2026-01-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: 'share-corporate-test',
expires_at: new Date(Date.now() + 86400000),
});
await expect(eventTypeService.deleteEventType(corporate.id))
.rejects.toMatchObject({ code: 'IN_USE' });
});
it('deletes an unused system type in the window, taking its reminder template along', async () => {
// Seed the per-type reminder templates first so there is something to clean up.
await ensureEventReminderTemplatesSeeded(db);
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeTruthy();
const wedding = await db('event_types').where({ slug_prefix: 'wedding' }).first();
expect(wedding.is_system).toBeTruthy();
const result = await eventTypeService.deleteEventType(wedding.id);
expect(result.success).toBe(true);
expect(await db('event_types').where({ slug_prefix: 'wedding' }).first()).toBeFalsy();
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
// The deleted slug must NOT stay creatable through the legacy fallback —
// the live catalog is authoritative while it has rows.
expect(await eventTypeService.isValidEventType('wedding')).toBe(false);
expect(await eventTypeService.isValidEventType('birthday')).toBe(true);
});
it('does not resurrect reminder templates for deleted types on the next self-heal pass', async () => {
// The seeder caches success per process — reset the module to force a
// genuine second pass, exactly what a backend restart would run.
jest.resetModules();
const fresh = require('../../src/services/eventReminderTemplates');
await fresh.ensureEventReminderTemplatesSeeded(db);
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
// Types still in the catalog keep their templates.
expect(await db('email_templates').where({ template_key: 'event_reminder_birthday' }).first()).toBeTruthy();
expect(await db('email_templates').where({ template_key: 'event_reminder_default' }).first()).toBeTruthy();
});
it('re-locks system types once the wizard is marked complete', async () => {
await setupService.markSetupWizardCompleted();
expect(await setupService.isSetupWizardCompleted()).toBe(true);
const birthday = await db('event_types').where({ slug_prefix: 'birthday' }).first();
await expect(eventTypeService.deleteEventType(birthday.id))
.rejects.toMatchObject({ code: 'SYSTEM_TYPE' });
// Custom (non-system) types remain deletable as before.
const custom = await eventTypeService.createEventType({ name: 'Family', slug_prefix: 'family' });
const result = await eventTypeService.deleteEventType(custom.id);
expect(result.success).toBe(true);
});
it('fails closed when the completion marker row is missing', async () => {
// A portable-backup restore can replace app_settings with a set that
// predates migration 161 (which will not rerun) — absence must mean
// "configured instance", never an open deletion window.
await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
expect(await setupService.isSetupWizardCompleted()).toBe(true);
await setupService.markSetupWizardCompleted();
});
it('refuses to delete the last remaining event type', async () => {
// Reduce the catalog to a single custom type via direct db writes (the
// service paths are already covered above), then hit the guard.
const solo = await eventTypeService.createEventType({ name: 'Solo', slug_prefix: 'solo' });
await db('events').del();
await db('event_types').whereNot('id', solo.id).del();
await expect(eventTypeService.deleteEventType(solo.id))
.rejects.toMatchObject({ code: 'LAST_TYPE' });
// Deactivating it would empty the ACTIVE catalog just the same.
await expect(eventTypeService.updateEventType(solo.id, { is_active: false }))
.rejects.toMatchObject({ code: 'LAST_ACTIVE' });
});
});
@@ -1,165 +0,0 @@
/**
* Minimal in-process OIDC provider for integration tests (#798).
*
* Serves just enough of the spec for openid-client's full validation to
* pass: discovery, JWKS (RS256), authorization endpoint (immediate redirect,
* no login UI), and token endpoint (authorization_code + PKCE). Claims for
* the next login are scripted per test via `setNextUser()`.
*
* Runs on an ephemeral localhost port over plain http — the service allows
* that in NODE_ENV=test only.
*/
const http = require('http');
const crypto = require('crypto');
const { URL } = require('url');
function b64url(input) {
return Buffer.from(input).toString('base64url');
}
class MockOidcProvider {
constructor() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
this.privateKey = privateKey;
this.publicJwk = publicKey.export({ format: 'jwk' });
this.publicJwk.kid = 'test-key-1';
this.publicJwk.alg = 'RS256';
this.publicJwk.use = 'sig';
this.clientId = 'picpeak-test';
this.clientSecret = 'test-client-secret';
this.codes = new Map(); // code -> { nonce, redirectUri, codeChallenge, user }
this.nextUser = { sub: 'user-1', email: 'sso@example.com', email_verified: true };
// Test hooks:
this.tamperNonce = false; // sign the ID token with a WRONG nonce
this.emailViaUserinfoOnly = false; // omit email from the ID token; serve it on /userinfo
this.accessTokens = new Map(); // access_token -> user (for /userinfo)
this.server = null;
this.issuer = null;
}
setNextUser(user) {
this.nextUser = user;
}
signIdToken({ sub, nonce, extraClaims = {} }) {
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'RS256', kid: this.publicJwk.kid, typ: 'JWT' };
const payload = {
iss: this.issuer,
aud: this.clientId,
sub,
iat: now,
exp: now + 300,
nonce,
...extraClaims,
};
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), this.privateKey);
return `${signingInput}.${signature.toString('base64url')}`;
}
async start() {
this.server = http.createServer((req, res) => this.handle(req, res));
await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve));
this.issuer = `http://127.0.0.1:${this.server.address().port}`;
return this.issuer;
}
async stop() {
if (this.server) await new Promise((resolve) => this.server.close(resolve));
}
handle(req, res) {
const url = new URL(req.url, this.issuer);
const json = (status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (url.pathname === '/.well-known/openid-configuration') {
return json(200, {
issuer: this.issuer,
authorization_endpoint: `${this.issuer}/authorize`,
token_endpoint: `${this.issuer}/token`,
userinfo_endpoint: `${this.issuer}/userinfo`,
jwks_uri: `${this.issuer}/jwks`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
});
}
if (url.pathname === '/jwks') {
return json(200, { keys: [this.publicJwk] });
}
if (url.pathname === '/authorize') {
// "Log in" instantly: mint a code bound to this request's params and
// bounce back to the redirect_uri like a real IdP would.
const code = crypto.randomBytes(16).toString('base64url');
this.codes.set(code, {
nonce: url.searchParams.get('nonce'),
redirectUri: url.searchParams.get('redirect_uri'),
codeChallenge: url.searchParams.get('code_challenge'),
user: this.nextUser,
});
const back = new URL(url.searchParams.get('redirect_uri'));
back.searchParams.set('code', code);
back.searchParams.set('state', url.searchParams.get('state'));
res.writeHead(302, { location: back.href });
return res.end();
}
if (url.pathname === '/token' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const params = new URLSearchParams(body);
const stored = this.codes.get(params.get('code'));
if (!stored) return json(400, { error: 'invalid_grant' });
this.codes.delete(params.get('code'));
// PKCE check — S256(code_verifier) must match the challenge.
const verifier = params.get('code_verifier') || '';
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
if (challenge !== stored.codeChallenge) {
return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
}
const { sub, ...extraClaims } = stored.user;
// Spec-compliant providers may keep profile/email claims OFF the ID
// token and serve them from /userinfo only — this hook simulates that.
const idTokenClaims = this.emailViaUserinfoOnly ? {} : extraClaims;
const idToken = this.signIdToken({
sub,
nonce: this.tamperNonce ? 'tampered-nonce' : stored.nonce,
extraClaims: idTokenClaims,
});
const accessToken = crypto.randomBytes(16).toString('base64url');
this.accessTokens.set(accessToken, stored.user);
return json(200, {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 300,
id_token: idToken,
});
});
return undefined;
}
if (url.pathname === '/userinfo') {
const auth = req.headers.authorization || '';
const user = this.accessTokens.get(auth.replace(/^Bearer\s+/i, ''));
if (!user) return json(401, { error: 'invalid_token' });
return json(200, { ...user });
}
return json(404, { error: 'not_found' });
}
}
module.exports = { MockOidcProvider };
@@ -1,302 +0,0 @@
/**
* OIDC SSO integration tests (#798, phase 1).
*
* Full-stack over a mock in-process IdP (mockOidcProvider): supertest drives
* the real /admin/sso/login and /admin/sso/callback routes on a fresh-SQLite
* database, openid-client does genuine discovery/JWKS/PKCE/ID-token
* validation against the mock issuer. Pins:
*
* - happy path: JIT provisioning creates an admin and sets the session cookie
* - JIT off → not_provisioned redirect, no row created
* - repeat login matches by sub, not email (email change ≠ new account)
* - verified-email one-time link onto an existing local admin
* - unverified email must NOT link (falls through to JIT/or error)
* - deactivated admin → inactive redirect
* - missing/forged state cookie → state redirect
* - nonce tamper from the IdP → idp redirect
* - settings endpoints: secret write-only, generic /general upsert cannot
* clobber oidc_client_secret
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb } = require('./helpers/crmDb');
const { MockOidcProvider } = require('./helpers/mockOidcProvider');
describe('OIDC SSO (#798)', () => {
let db;
let cleanup;
let app;
let idp;
let oidcService;
const agentCookies = {};
beforeAll(async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-test-secret';
// The redirect_uri derives from the public base URL — pin it explicitly:
// CI has no backend/.env, and getFrontendBaseUrl() returning '' makes
// buildAuthorizationRequest fail (by design) with OIDC_BAD_CONFIG.
process.env.FRONTEND_URL = 'http://localhost:5199';
({ db, cleanup } = await bootCrmDb());
idp = new MockOidcProvider();
const issuer = await idp.start();
// Require AFTER bootCrmDb so services share this db instance.
oidcService = require('../../src/services/oidcService');
await oidcService.saveOidcSettings({
oidc_enabled: true,
oidc_issuer_url: issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
oidc_autoprovision: true,
oidc_default_role: 'viewer',
});
const authRouter = require('../../src/routes/auth');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRouter);
}, 120000);
afterAll(async () => {
if (idp) await idp.stop();
if (cleanup) await cleanup();
});
/** Drive login → IdP → callback like a browser; returns the callback response. */
async function ssoRoundTrip({ mutateState } = {}) {
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
const idpUrl = loginRes.headers.location;
expect(idpUrl.startsWith(idp.issuer)).toBe(true);
let stateCookie = (loginRes.headers['set-cookie'] || [])
.find((c) => c.startsWith('oidc_state='));
expect(stateCookie).toBeTruthy();
stateCookie = stateCookie.split(';')[0];
if (mutateState === 'drop') stateCookie = null;
if (mutateState === 'forge') {
stateCookie = `oidc_state=${jwt.sign({ type: 'oidc_state', s: 'x', n: 'y', cv: 'z' }, 'wrong-secret', { issuer: 'picpeak-auth' })}`;
}
// "Browser" follows the redirect to the IdP, which instantly bounces back.
const idpRes = await fetch(idpUrl, { redirect: 'manual' });
expect(idpRes.status).toBe(302);
const back = new URL(idpRes.headers.get('location'));
let cb = request(app).get(`${back.pathname}?${back.searchParams.toString()}`);
if (stateCookie) cb = cb.set('Cookie', stateCookie);
return cb.expect(302);
}
it('JIT-provisions an unknown user and establishes an admin session', async () => {
idp.setNextUser({ sub: 'sub-jit-1', email: 'jit@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='));
expect(adminCookie).toBeTruthy();
const row = await db('admin_users').where({ email: 'jit@example.com' }).first();
expect(row).toBeTruthy();
expect(row.auth_provider).toBe('oidc');
expect(row.external_subject).toBe('sub-jit-1');
const role = await db('roles').where('id', row.role_id).first();
expect(role.name).toBe('viewer');
// The session JWT must be a normal admin token.
const token = adminCookie.split(';')[0].replace('admin_token=', '');
const decoded = jwt.verify(decodeURIComponent(token), process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
expect(decoded.type).toBe('admin');
expect(decoded.id).toBe(row.id);
agentCookies.jitAdminId = row.id;
});
it('matches repeat logins by sub even when the email changed at the IdP', async () => {
idp.setNextUser({ sub: 'sub-jit-1', email: 'renamed@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
// No second row — resolved via external_subject.
expect(await db('admin_users').where({ email: 'renamed@example.com' }).first()).toBeFalsy();
const byId = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
expect(byId.external_subject).toBe('sub-jit-1');
});
it('links an existing local admin one-time via VERIFIED email and stamps the sub', async () => {
const role = await db('roles').where({ name: 'admin' }).first();
const [localId] = await db('admin_users').insert({
username: 'local-admin',
email: 'local@example.com',
password_hash: await bcrypt.hash('LocalPass123', 4),
role_id: role.id,
is_active: 1,
auth_provider: 'local',
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
idp.setNextUser({ sub: 'sub-local-1', email: 'local@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const row = await db('admin_users').where({ id: localId }).first();
expect(row.external_subject).toBe('sub-local-1');
expect(row.auth_provider).toBe('local'); // password keeps working
});
it('does NOT link by unverified email — provisions a separate account instead', async () => {
const role = await db('roles').where({ name: 'admin' }).first();
await db('admin_users').insert({
username: 'victim-admin',
email: 'victim@example.com',
password_hash: await bcrypt.hash('VictimPass123', 4),
role_id: role.id,
is_active: 1,
auth_provider: 'local',
created_at: new Date(),
updated_at: new Date(),
});
idp.setNextUser({ sub: 'sub-attacker', email: 'victim@example.com', email_verified: false });
// JIT would need this email but the victim row owns it (unique) — the
// insert fails and the flow must land on an error, never on the
// victim's session.
const res = await ssoRoundTrip();
expect(res.headers.location).toMatch(/sso_error=/);
const victim = await db('admin_users').where({ email: 'victim@example.com' }).first();
expect(victim.external_subject).toBeNull();
});
it('refuses a deactivated admin with sso_error=inactive', async () => {
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 0 });
idp.setNextUser({ sub: 'sub-jit-1', email: 'renamed@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=inactive');
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 1 });
});
it('rejects a callback without the state cookie', async () => {
const res = await ssoRoundTrip({ mutateState: 'drop' });
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=state');
});
it('rejects a forged state cookie (wrong signing key)', async () => {
const res = await ssoRoundTrip({ mutateState: 'forge' });
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=state');
});
it('rejects an ID token whose nonce does not match', async () => {
idp.tamperNonce = true;
idp.setNextUser({ sub: 'sub-nonce', email: 'nonce@example.com', email_verified: true });
const res = await ssoRoundTrip();
idp.tamperNonce = false;
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=idp');
expect(await db('admin_users').where({ email: 'nonce@example.com' }).first()).toBeFalsy();
});
it('blocks JIT with sso_error=not_provisioned when autoprovision is off', async () => {
await oidcService.saveOidcSettings({ oidc_autoprovision: false });
idp.setNextUser({ sub: 'sub-new-user', email: 'new@example.com', email_verified: true });
const res = await ssoRoundTrip();
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=not_provisioned');
expect(await db('admin_users').where({ email: 'new@example.com' }).first()).toBeFalsy();
await oidcService.saveOidcSettings({ oidc_autoprovision: true });
});
it('stores the client secret encrypted and survives a config round-trip', async () => {
const row = await db('app_settings').where({ setting_key: 'oidc_client_secret' }).first();
const stored = JSON.parse(row.setting_value);
expect(stored).not.toContain(idp.clientSecret);
expect(oidcService.decryptSecret(stored)).toBe(idp.clientSecret);
const cfg = await oidcService.getOidcConfig();
expect(cfg.clientSecret).toBe(idp.clientSecret);
});
it('refuses local password login for OIDC-owned accounts', async () => {
// Give the JIT admin a KNOWN password hash directly in the DB — the
// auth_provider check must reject the login even with valid credentials
// (otherwise a password reset would mint an IdP-bypassing local login).
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({
password_hash: await bcrypt.hash('KnownPass123', 4),
});
const row = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
const res = await request(app)
.post('/api/auth/admin/login')
.send({ username: row.email, password: 'KnownPass123' });
expect(res.status).toBe(401);
});
it('returns 404 from /sso/login when SSO is disabled', async () => {
await oidcService.saveOidcSettings({ oidc_enabled: false });
await request(app).get('/api/auth/admin/sso/login').expect(404);
await oidcService.saveOidcSettings({ oidc_enabled: true });
});
it('merges email from the UserInfo endpoint when the ID token omits it', async () => {
idp.emailViaUserinfoOnly = true;
idp.setNextUser({ sub: 'sub-userinfo', email: 'userinfo@example.com', email_verified: true });
const res = await ssoRoundTrip();
idp.emailViaUserinfoOnly = false;
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
const row = await db('admin_users').where({ email: 'userinfo@example.com' }).first();
expect(row).toBeTruthy();
expect(row.external_subject).toBe('sub-userinfo');
});
it('binds identities per ISSUER — a sub collision on a new IdP must not inherit the old account', async () => {
// The JIT admin from the first test is bound to (issuer A, 'sub-jit-1').
const boundAdmin = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
expect(boundAdmin.external_issuer).toBe(idp.issuer);
// Same sub, DIFFERENT issuer: a second IdP the instance switches to.
const idp2 = new MockOidcProvider();
await idp2.start();
try {
await oidcService.saveOidcSettings({
oidc_issuer_url: idp2.issuer,
oidc_client_id: idp2.clientId,
oidc_client_secret: idp2.clientSecret,
});
idp2.setNextUser({ sub: 'sub-jit-1', email: 'colliding@example.com', email_verified: true });
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
const stateCookie = (loginRes.headers['set-cookie'] || [])
.find((c) => c.startsWith('oidc_state=')).split(';')[0];
const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' });
const back = new URL(idpRes.headers.get('location'));
const res = await request(app)
.get(`${back.pathname}?${back.searchParams.toString()}`)
.set('Cookie', stateCookie)
.expect(302);
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
// A NEW row bound to issuer B — the issuer-A admin is untouched and
// its role was not inherited.
const collider = await db('admin_users').where({ email: 'colliding@example.com' }).first();
expect(collider).toBeTruthy();
expect(collider.id).not.toBe(agentCookies.jitAdminId);
expect(collider.external_issuer).toBe(idp2.issuer);
const original = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
expect(original.external_issuer).toBe(idp.issuer);
} finally {
await idp2.stop();
await oidcService.saveOidcSettings({
oidc_issuer_url: idp.issuer,
oidc_client_id: idp.clientId,
oidc_client_secret: idp.clientSecret,
});
}
});
});
@@ -1,190 +0,0 @@
/**
* PostgreSQL integration tests for the .picpeak restore robustness fixes.
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway Postgres DB,
* e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_restore_test" \
* npx jest __tests__/integration/picpeakRestorePg.test.js
*
* Validates the Postgres-specific paths that SQLite can't exercise: identity
* sequences left stale by explicit-id inserts, pg_get_serial_sequence raising on
* id-less tables, reinject/role-recreate explicit-id inserts, and FK integrity.
*/
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('picpeak restore on Postgres', () => {
let pgDb;
let svc;
beforeAll(async () => {
pgDb = knex({ client: 'pg', connection: PG_URL });
await pgDb.raw('DROP TABLE IF EXISTS role_permissions, events, admin_users, roles, permissions, app_settings CASCADE');
await pgDb.schema.createTable('roles', (t) => {
t.increments('id');
t.string('name', 50).notNullable().unique();
t.string('display_name', 100);
t.integer('priority').defaultTo(0);
t.boolean('is_system').defaultTo(false);
});
await pgDb.schema.createTable('permissions', (t) => {
t.increments('id');
t.string('name', 100).notNullable().unique();
t.string('display_name', 150);
t.string('category', 50);
});
await pgDb.schema.createTable('role_permissions', (t) => {
t.integer('role_id').notNullable().references('id').inTable('roles').onDelete('CASCADE');
t.integer('permission_id').notNullable().references('id').inTable('permissions').onDelete('CASCADE');
t.primary(['role_id', 'permission_id']);
});
await pgDb.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('username').notNullable().unique();
t.string('email').notNullable().unique();
t.string('password_hash');
t.boolean('is_active').defaultTo(true);
t.boolean('must_change_password').defaultTo(false);
t.integer('role_id').references('id').inTable('roles').onDelete('SET NULL');
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
t.boolean('two_factor_enabled').defaultTo(false);
t.string('two_factor_secret');
t.text('two_factor_recovery_codes');
});
await pgDb.schema.createTable('events', (t) => {
t.increments('id');
t.string('slug');
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
});
await pgDb.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.json('setting_value');
t.string('setting_type');
t.timestamp('updated_at').defaultTo(pgDb.fn.now());
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
svc = require('../../src/services/picpeakImportService');
});
afterAll(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
if (pgDb) await pgDb.destroy();
});
beforeEach(async () => {
await pgDb('role_permissions').del();
await pgDb('events').del();
await pgDb('admin_users').del();
await pgDb('roles').del();
await pgDb('permissions').del();
});
test('resyncSequences fast-forwards stale sequences and skips id-less tables', async () => {
// Simulate a restore: explicit-id inserts leave the sequence at 1.
await pgDb('roles').insert([{ id: 5, name: 'super_admin', display_name: 'SA' }]);
await pgDb('admin_users').insert([{ id: 9, username: 'a', email: 'a@x.io', password_hash: 'h' }]);
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
await pgDb('role_permissions').insert([{ role_id: 5, permission_id: 3 }]); // id-less table
// Must not throw on role_permissions (no `id` column → pg_get_serial_sequence raises unguarded).
await expect(svc.resyncSequences(['roles', 'admin_users', 'permissions', 'role_permissions'])).resolves.toBeUndefined();
// Natural inserts (no explicit id) now avoid the restored ids.
const [adminId] = await pgDb('admin_users').insert({ username: 'b', email: 'b@x.io', password_hash: 'h' }).returning('id');
expect(Number(adminId.id || adminId)).toBe(10); // max(9)+1, no duplicate-key error
const [roleId] = await pgDb('roles').insert({ name: 'editor', display_name: 'Ed' }).returning('id');
expect(Number(roleId.id || roleId)).toBe(6);
});
test('reinjectCurrentAdmin insert branch works with a stale sequence (explicit max+1)', async () => {
await pgDb('admin_users').insert({ id: 9, username: 'backup', email: 'backup@x.io', password_hash: 'h' });
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, created_by: 42 };
await pgDb.transaction((trx) => svc.reinjectCurrentAdmin(trx, operator));
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
expect(op.id).toBe(10); // max(9)+1
expect(op.password_hash).toBe('OP');
expect(op.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
});
test('preserveOperatorRole re-creates a missing role on Postgres and keeps FK integrity', async () => {
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
await pgDb('roles').insert([{ id: 2, name: 'viewer', display_name: 'V' }]);
await pgDb('admin_users').insert({ id: 1, username: 'admin', email: 'op@x.io', password_hash: 'h', role_id: null });
const snapshot = { role: { name: 'super_admin', display_name: 'SA', priority: 100, is_system: true }, permissions: ['events.create', 'missing.perm'] };
await pgDb.transaction((trx) => svc.preserveOperatorRole(trx, 1, snapshot));
await svc.resyncSequences(['roles']); // post-commit, mirrors importFromPicpeak
const role = await pgDb('roles').where({ name: 'super_admin' }).first();
expect(role).toBeTruthy();
const op = await pgDb('admin_users').where({ id: 1 }).first();
expect(op.role_id).toBe(role.id); // FK valid, operator not downgraded
const grants = await pgDb('role_permissions').where({ role_id: role.id }).pluck('permission_id');
expect(grants).toEqual([3]); // existing perm granted, missing.perm skipped
});
test('full replaceAllTables: cross-instance backup preserves the operator, role, FKs, and sequences', async () => {
// A backup from ANOTHER instance: omits the operator's email AND their
// super_admin role; uses explicit ids that leave sequences stale.
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pgtest-'));
const dataDir = path.join(staging, 'data');
fs.mkdirSync(dataDir);
const write = (t, rows) => fs.writeFileSync(path.join(dataDir, `${t}.ndjson`), rows.map((r) => JSON.stringify(r)).join('\n'));
write('roles', [{ id: 5, name: 'admin', display_name: 'Admin', priority: 50, is_system: true }]);
write('permissions', [{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
write('role_permissions', [{ role_id: 5, permission_id: 3 }]);
write('admin_users', [{ id: 9, username: 'backupadmin', email: 'backup@x.io', password_hash: 'h', role_id: 5, is_active: true }]);
write('events', [{ id: 2, slug: 'restored-ev', created_by: 9 }]);
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, role_id: 999, created_by: null };
const roleSnapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
const tables = ['roles', 'permissions', 'role_permissions', 'admin_users', 'events'];
// replaceAllTables isn't exported, so drive its exact transaction sequence
// (suspend FKs, wipe, batchInsert, reinject, preserve role) through the
// exported units against real Postgres.
const importSvc = svc;
await pgDb.transaction(async (trx) => {
await trx.raw('SET session_replication_role = \'replica\'');
for (const t of tables) await trx(t).del();
for (const t of tables) {
const rows = fs.readFileSync(path.join(dataDir, `${t}.ndjson`), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
if (rows.length) await trx.batchInsert(t, rows, 100);
}
const opId = await importSvc.reinjectCurrentAdmin(trx, operator);
await importSvc.preserveOperatorRole(trx, opId, roleSnapshot);
await trx.raw('SET session_replication_role = \'origin\'');
});
await importSvc.resyncSequences(tables);
// Operator preserved (inserted, since email absent from backup).
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
expect(op).toBeTruthy();
expect(op.password_hash).toBe('OP');
// super_admin role re-created and the operator bound to it.
const sa = await pgDb('roles').where({ name: 'super_admin' }).first();
expect(sa).toBeTruthy();
expect(op.role_id).toBe(sa.id);
expect(await pgDb('role_permissions').where({ role_id: sa.id }).pluck('permission_id')).toEqual([3]);
// Restored event's created_by FK to the backup admin still valid.
const ev = await pgDb('events').where({ slug: 'restored-ev' }).first();
expect(ev.created_by).toBe(9);
// Sequences resynced → natural inserts don't collide.
const [newAdmin] = await pgDb('admin_users').insert({ username: 'fresh', email: 'fresh@x.io', password_hash: 'h' }).returning('id');
expect(Number(newAdmin.id || newAdmin)).toBeGreaterThan(op.id);
fs.rmSync(staging, { recursive: true, force: true });
});
});
@@ -239,7 +239,7 @@ describe('workflow engine', () => {
expect(again.already).toBe(true);
});
test('seeds the invoice-dunning built-in as the delegation graph (disabled for first beta)', async () => {
test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
@@ -248,7 +248,7 @@ describe('workflow engine', () => {
expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(7);
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
@@ -273,7 +273,7 @@ describe('workflow engine', () => {
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const reseeded = await db('workflows').where({ id: wf.id }).first();
expect(reseeded.version).toBe(wf.version + 1); // bumped
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(7);
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(6);
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
@@ -180,27 +180,6 @@ describe('admin events CRUD endpoints (smoke)', () => {
});
expect(res.status).toBe(404);
});
// #822 — hero_logo_visible/position are nullable (null = "inherit the global
// branding toggle"), but the validator used .optional() without
// { nullable: true }, so an explicit null was rejected with 400.
it('accepts hero_logo_visible: null and stores NULL (inherit)', async () => {
const id = await insertEvent(db, adminId, { hero_logo_visible: 1 });
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: null,
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.hero_logo_visible).toBeNull();
});
it('still rejects a non-boolean hero_logo_visible', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: 'maybe',
});
expect(res.status).toBe(400);
});
});
describe('DELETE /:id', () => {
@@ -32,17 +32,9 @@ jest.mock('../../src/database/db', () => {
if (table === 'admin_users') {
let rowFilter = () => true;
return {
// The session route joins roles for the adminUser payload (#798);
// fake rows carry no role fields, so the join is a pass-through.
leftJoin() {
return this;
},
where(criteria) {
rowFilter = (row) => {
return Object.entries(criteria).every(([rawKey, v]) => {
// Joined queries prefix columns ('admin_users.id') — the fake
// rows use bare names.
const k = rawKey.replace(/^admin_users\./, '');
return Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
return row[k] === v;
});
@@ -58,12 +50,7 @@ jest.mock('../../src/database/db', () => {
if (!row) return undefined;
if (!this._cols) return row;
const out = {};
for (const c of this._cols) {
// Support 'table.col' and 'table.col as alias' shapes.
const [source, alias] = c.split(/\s+as\s+/i);
const bare = source.includes('.') ? source.split('.').pop() : source;
out[alias || bare] = row[bare];
}
for (const c of this._cols) out[c] = row[c];
return out;
},
};
@@ -1,127 +0,0 @@
/**
* Regression test for GHSA-9hmx-68vc-qpqw — share-link login must not bypass
* the gallery password.
*
* POST /auth/gallery/share-login validates only the share token. For a
* password-protected gallery it previously minted a full `type:'gallery'`
* access token on the share token alone, letting anyone holding the share URL
* read the gallery without the password. The fix: when the gallery requires a
* password, return `{ requires_password: true }` with NO token and NO cookie.
*/
const express = require('express');
const request = require('supertest');
process.env.JWT_SECRET = 'share-login-test-secret';
const events = [];
jest.mock('../../src/database/db', () => {
function dbFn(table) {
if (table === 'events') {
let filter = () => true;
return {
where(criteria) {
filter = (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 events.find(filter); },
};
}
return { where() { return this; }, async first() { return undefined; } };
}
dbFn.raw = async () => {};
return { db: dbFn, logActivity: async () => {} };
});
// Share token is stored plainly on the fake event row.
jest.mock('../../src/services/shareLinkService', () => ({
getEventShareToken: (event) => event.share_token,
resolveShareIdentifier: async () => ({ event: null }),
}));
const mockSetGalleryAuthCookies = jest.fn();
jest.mock('../../src/utils/tokenUtils', () => ({
setGalleryAuthCookies: (...args) => mockSetGalleryAuthCookies(...args),
clearGalleryAuthCookies: jest.fn(),
getGalleryTokenFromRequest: jest.fn(),
setAdminAuthCookies: jest.fn(),
}));
jest.mock('../../src/utils/authSecurity', () => ({
trackFailedAttempt: jest.fn(async () => {}),
trackSuccessfulLogin: jest.fn(async () => {}),
checkAccountLockout: jest.fn(async () => ({ isLocked: false })),
resetLockout: jest.fn(async () => {}),
}));
// Collaborators the router imports at load but the share-login path doesn't hit.
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: async () => true }));
jest.mock('../../src/services/mfaService', () => ({}));
jest.mock('../../src/middleware/sessionTimeout', () => ({ endSession: jest.fn(), sessionTimeoutMiddleware: (req, res, next) => next() }));
jest.mock('../../src/utils/tokenRevocation', () => ({ revokeToken: jest.fn(async () => {}), isTokenRevoked: async () => false }));
const authRouter = require('../../src/routes/auth');
function makeApp() {
const app = express();
app.use(express.json());
app.use('/auth', authRouter);
return app;
}
const SHARE_TOKEN = 'a'.repeat(64);
beforeEach(() => {
events.length = 0;
mockSetGalleryAuthCookies.mockClear();
});
describe('POST /auth/gallery/share-login password enforcement', () => {
it('does NOT mint a token for a password-protected gallery', async () => {
events.push({
id: 1, slug: 'private-gallery', is_active: 1, is_archived: 0,
require_password: 1, share_token: SHARE_TOKEN, event_name: 'Private',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'private-gallery', token: SHARE_TOKEN });
expect(res.status).toBe(200);
expect(res.body.requires_password).toBe(true);
expect(res.body.token).toBeUndefined();
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
});
it('mints a token for a public (no-password) gallery', async () => {
events.push({
id: 2, slug: 'public-gallery', is_active: 1, is_archived: 0,
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'public-gallery', token: SHARE_TOKEN });
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
expect(res.body.event).toBeDefined();
expect(mockSetGalleryAuthCookies).toHaveBeenCalledTimes(1);
});
it('rejects a wrong share token regardless of password setting', async () => {
events.push({
id: 3, slug: 'public-gallery', is_active: 1, is_archived: 0,
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'public-gallery', token: 'b'.repeat(64) });
expect(res.status).toBe(401);
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
});
});
@@ -1,119 +0,0 @@
/**
* Regression test for GHSA-4j34-x562-5vfq — broken access control in the legacy
* /api/events router.
*
* The legacy router exposed create/list/update/delete/extend guarded by
* adminAuth ALONE (no requirePermission, no requireEventOwnership), so any
* back-office account — down to a read-only viewer — could read every gallery's
* password_hash/share_token and take over any gallery. The fix removes that
* router entirely and migrates its one UI-used route (POST /:id/extend) to the
* canonical /api/admin/events mount, where it inherits the permission +
* ownership guards.
*
* This test pins two invariants:
* 1. The legacy source file is gone (nothing can re-mount it).
* 2. The migrated extend route enforces ownership — a non-owning editor gets
* 403, the owner succeeds.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-legacy-acl-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'legacy-acl-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
async function insertEvent(db, ownerId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Owner Gallery',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_by: ownerId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('GHSA-4j34: legacy /api/events router removed + extend guarded', () => {
it('the legacy events router source file no longer exists', () => {
expect(fs.existsSync(path.join(__dirname, '../../src/routes/events.js'))).toBe(false);
});
describe('POST /api/admin/events/:id/extend ownership enforcement', () => {
let db; let cleanup; let app;
let ownerId; let ownerToken;
let editorId; let editorToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: ownerId } = await seedMinimal(db));
await assignAdminRole(db, ownerId, 'super_admin');
ownerToken = mintAdminToken(ownerId);
// A second, non-owning account with the low-trust editor role.
[editorId] = await db('admin_users').insert({
username: 'editor1', email: 'editor1@example.com',
password_hash: 'x', is_active: 1,
}).returning('id');
editorId = editorId?.id ?? editorId;
await assignAdminRole(db, editorId, 'editor');
editorToken = mintAdminToken(editorId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { await cleanup(); });
it('lets the owner extend their own gallery', async () => {
const id = await insertEvent(db, ownerId, { expires_at: '2026-06-01T00:00:00.000Z' });
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ days: 10 });
expect(res.status).toBe(200);
expect(new Date(res.body.expires_at).toISOString()).toBe('2026-06-11T00:00:00.000Z');
});
it('403s a non-owning editor trying to extend someone else\'s gallery', async () => {
const id = await insertEvent(db, ownerId); // owned by the super_admin
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ days: 30 });
expect(res.status).toBe(403); // requireEventOwnership blocks it
});
it('validates the days field', async () => {
const id = await insertEvent(db, ownerId);
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ days: 9999 });
expect(res.status).toBe(400);
});
});
});
@@ -1,52 +0,0 @@
const path = require('path');
const os = require('os');
const fs = require('fs').promises;
// Point storage at a throwaway temp dir before requiring the service so the
// module-level getStoragePath() picks it up if evaluated.
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-test-${process.pid}`);
const chunkedUpload = require('../../src/services/chunkedUploadService');
describe('chunkedUploadService.initializeUpload filename sanitisation (GHSA-pc72-jf53-w28j)', () => {
afterAll(async () => {
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
});
it('strips directory-traversal components from the stored filename', async () => {
const { uploadId } = await chunkedUpload.initializeUpload({
filename: '../../uploads/logos/evil.svg',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
});
const meta = chunkedUpload.getUploadStatus(uploadId);
// basename('../../uploads/logos/evil.svg') === 'evil.svg' — the traversal
// is gone, so path.join(tempDir, filename) can no longer escape tempDir.
expect(meta.filename).toBe('evil.svg');
});
it('keeps a normal filename intact', async () => {
const { uploadId } = await chunkedUpload.initializeUpload({
filename: 'clip.mp4',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
});
expect(uploadId).toBeTruthy();
});
it('rejects a filename that collapses to nothing', async () => {
await expect(
chunkedUpload.initializeUpload({
filename: '../',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
})
).rejects.toThrow(/Invalid filename/);
});
});
@@ -1,58 +0,0 @@
/**
* Regression tests for the Docker update instructions (environmentService).
*
* A production install (docker-compose.production.yml) must get `-f
* docker-compose.production.yml` in every update command — bare `docker compose`
* targets docker-compose.yml, a different build-based stack that also starts the
* dev-only mailhog, which left production users stranded on the old version
* (reported against 3.44.0 → 3.45.2).
*/
const { detectEnvironment, generateUpdateInstructions } = require('../../src/services/environmentService');
describe('detectEnvironment — production compose detection', () => {
const orig = process.env.PICPEAK_RELEASE_CHANNEL;
afterEach(() => {
if (orig === undefined) delete process.env.PICPEAK_RELEASE_CHANNEL;
else process.env.PICPEAK_RELEASE_CHANNEL = orig;
});
it('flags isProductionCompose when PICPEAK_RELEASE_CHANNEL is set', async () => {
process.env.PICPEAK_RELEASE_CHANNEL = 'stable';
const env = await detectEnvironment();
expect(env.isProductionCompose).toBe(true);
});
it('does not flag it when the var is absent (default docker-compose.yml)', async () => {
delete process.env.PICPEAK_RELEASE_CHANNEL;
const env = await detectEnvironment();
expect(env.isProductionCompose).toBe(false);
});
});
describe('generateUpdateInstructions — Docker commands', () => {
const cmds = (env) => generateUpdateInstructions(env, '3.45.2').steps.map((s) => s.command);
it('targets docker-compose.production.yml for a production install', () => {
const commands = cmds({ isDocker: true, isProductionCompose: true });
expect(commands).toEqual([
'docker compose -f docker-compose.production.yml pull',
'docker compose -f docker-compose.production.yml up -d',
'docker compose -f docker-compose.production.yml logs -f backend',
]);
// And the warning tells them where to run it.
const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: true }, '3.45.2');
expect(warnings.join(' ')).toMatch(/docker-compose\.production\.yml/);
});
it('uses bare commands + a hint when not a production compose', () => {
const commands = cmds({ isDocker: true, isProductionCompose: false });
expect(commands).toEqual([
'docker compose pull',
'docker compose up -d',
'docker compose logs -f backend',
]);
const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: false }, '3.45.2');
// Still nudges production users to add -f in case detection missed.
expect(warnings.join(' ')).toMatch(/-f docker-compose\.production\.yml/);
});
});
@@ -1,125 +0,0 @@
/**
* Regression tests for the file-watcher concurrency bound.
*
* chokidar fires 'add' once per file — with no ignoreInitial option the boot
* scan fires it for every existing file, and a bulk drop fires it for every
* new one at once. Unbounded handlers each run DB lookups plus a full sharp
* pipeline (sharp.concurrency(2) only caps libvips threads WITHIN one
* operation), which can OOM small hosts. Both 'add' and 'unlink' must go
* through the shared p-limit gate.
*
* Adapted from the filpgame fork (426ca491), extended to cover 'unlink'.
*/
const mockLimit = jest.fn((operation) => Promise.resolve().then(operation));
const mockPLimit = jest.fn(() => mockLimit);
const mockHandlers = {};
const mockWatcher = {
on: jest.fn((event, handler) => {
mockHandlers[event] = handler;
return mockWatcher;
}),
};
// Shared instances captured by the mock factories: jest.isolateModules re-runs
// each factory in a fresh registry, so the factories must return these same
// objects for the test to observe calls made inside the isolated module.
const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
// Chainable no-row query — enough for removePhoto's lookup/delete calls.
const mockDb = jest.fn(() => ({
where: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue(null),
delete: jest.fn().mockResolvedValue(0),
}));
jest.mock('p-limit', () => mockPLimit);
jest.mock('chokidar', () => ({
watch: jest.fn(() => mockWatcher),
}));
jest.mock('../../src/database/db', () => ({ db: mockDb }));
jest.mock('../../src/utils/logger', () => mockLogger);
jest.mock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(),
generateVideoPlaceholder: jest.fn(),
}));
jest.mock('../../src/services/videoProcessor', () => ({
isVideoMimeType: jest.fn(() => false),
}));
jest.mock('../../src/services/downloadZipService', () => ({ invalidate: jest.fn() }));
jest.mock('../../src/utils/dbCompat', () => ({
formatBoolean: jest.fn((value) => value),
}));
const loadFileWatcher = () => {
let fileWatcher;
jest.isolateModules(() => {
fileWatcher = require('../../src/services/fileWatcher');
});
return fileWatcher;
};
describe('fileWatcher concurrency bound', () => {
const originalBackend = process.env.STORAGE_BACKEND;
const originalConcurrency = process.env.FILE_WATCHER_CONCURRENCY;
beforeEach(() => {
jest.clearAllMocks();
Object.keys(mockHandlers).forEach((key) => delete mockHandlers[key]);
process.env.STORAGE_BACKEND = 'local';
delete process.env.FILE_WATCHER_CONCURRENCY;
});
afterAll(() => {
if (originalBackend === undefined) delete process.env.STORAGE_BACKEND;
else process.env.STORAGE_BACKEND = originalBackend;
if (originalConcurrency === undefined) delete process.env.FILE_WATCHER_CONCURRENCY;
else process.env.FILE_WATCHER_CONCURRENCY = originalConcurrency;
});
it.each([
[undefined, 2], // default
['3', 3], // explicit
['0', 1], // floored to 1
['-4', 1], // floored to 1
['invalid', 2], // falls back to default
])('configures the limiter with FILE_WATCHER_CONCURRENCY=%s as %i', (configured, expected) => {
if (configured === undefined) delete process.env.FILE_WATCHER_CONCURRENCY;
else process.env.FILE_WATCHER_CONCURRENCY = configured;
loadFileWatcher().startFileWatcher();
expect(mockPLimit).toHaveBeenCalledWith(expected);
});
it('routes add events through the shared limiter', async () => {
loadFileWatcher().startFileWatcher();
expect(mockHandlers.add).toEqual(expect.any(Function));
mockHandlers.add('/outside-watch-root'); // early-returns inside processNewPhoto
expect(mockLimit).toHaveBeenCalledTimes(1);
expect(mockLimit).toHaveBeenCalledWith(expect.any(Function));
await mockLimit.mock.results[0].value;
});
it('routes unlink events through the same limiter', async () => {
loadFileWatcher().startFileWatcher();
expect(mockHandlers.unlink).toEqual(expect.any(Function));
mockHandlers.unlink('/outside-watch-root'); // early-returns inside removePhoto
expect(mockLimit).toHaveBeenCalledTimes(1);
await mockLimit.mock.results[0].value;
});
it('logs instead of rejecting when a queued handler throws', async () => {
loadFileWatcher().startFileWatcher();
const failure = new Error('boom');
mockLimit.mockImplementationOnce(() => Promise.reject(failure));
mockHandlers.add('/whatever');
await new Promise(process.nextTick);
expect(mockLogger.error).toHaveBeenCalledWith('Error processing new photo:', failure);
});
});
@@ -1,30 +0,0 @@
/**
* Locks the process-wide Sharp memory guards. The file-watcher concurrency
* bound (FILE_WATCHER_CONCURRENCY) assumes these caps stay in place — they
* limit libvips threads/cache WITHIN one operation while p-limit bounds the
* number of parallel pipelines. From the filpgame fork (426ca491).
*/
const mockSharp = jest.fn();
mockSharp.cache = jest.fn();
mockSharp.concurrency = jest.fn();
jest.mock('sharp', () => mockSharp);
jest.mock('../../src/utils/logger', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
describe('imageProcessor Sharp configuration', () => {
it('disables the Sharp cache and caps libvips concurrency', () => {
jest.isolateModules(() => {
require('../../src/services/imageProcessor');
});
expect(mockSharp.cache).toHaveBeenCalledWith(false);
expect(mockSharp.concurrency).toHaveBeenCalledWith(2);
});
});
@@ -1,50 +0,0 @@
/**
* Unit tests for the RAW/DNG handling helpers (#821). The actual exiftool
* extraction can only be exercised in the built image (exiftool isn't a dev
* dependency), so these cover the gating logic: which files are treated as RAW,
* and that ordinary images pass through untouched (zero cost / no extraction).
*/
const path = require('path');
const { isRawFilename, withProcessableImage, RAW_EXTENSIONS } = require('../../src/services/imageProcessor');
describe('isRawFilename', () => {
it('recognises common RAW / DNG extensions', () => {
for (const ext of ['dng', 'cr2', 'cr3', 'nef', 'arw', 'raf', 'rw2', 'orf']) {
expect(isRawFilename(`IMG_1234.${ext}`)).toBe(true);
expect(isRawFilename(`IMG_1234.${ext.toUpperCase()}`)).toBe(true); // case-insensitive
}
});
it('does not treat ordinary images/videos as RAW', () => {
for (const name of ['photo.jpg', 'photo.jpeg', 'photo.png', 'photo.webp', 'clip.mp4', 'clip.mov', 'photo.heic']) {
expect(isRawFilename(name)).toBe(false);
}
});
it('is null/empty safe', () => {
expect(isRawFilename(null)).toBe(false);
expect(isRawFilename('')).toBe(false);
expect(isRawFilename('noextension')).toBe(false);
});
it('RAW_EXTENSIONS includes dng (Apple ProRAW)', () => {
expect(RAW_EXTENSIONS.has('dng')).toBe(true);
});
});
describe('withProcessableImage', () => {
it('passes ordinary images through with no extraction and a no-op cleanup', async () => {
const localPath = '/tmp/whatever/photo.jpg';
const proc = await withProcessableImage(localPath, 'photo.jpg');
expect(proc.path).toBe(localPath); // unchanged — sharp reads it directly
expect(proc.outputBasename).toBeUndefined(); // generators keep their default naming
await expect(Promise.resolve(proc.cleanup())).resolves.toBeUndefined();
});
it('routes RAW files to extraction (which fails cleanly without exiftool/preview)', async () => {
// In the dev sandbox exiftool isn't installed, so extraction throws — the
// caller turns that into a normal processing failure. In the built image
// (exiftool present) this instead returns the embedded JPEG preview.
await expect(withProcessableImage('/tmp/whatever/IMG_1234.dng', 'IMG_1234.dng')).rejects.toThrow();
});
});
@@ -234,66 +234,3 @@ describe('renderInvoiceToBuffer — Storno branch', () => {
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
});
});
// VAT free-text note (#794) + multi-page page-number placement. Same
// constraint as the Storno tests: PDFKit Flate-compresses content streams,
// so we can't grep the note text — but the page-TREE objects are NOT
// compressed, so `/Type /Page` (not `/Pages`) is countable to assert
// pagination, and a byte-size delta proves the note actually rendered.
describe('renderInvoiceToBuffer — VAT note + multi-page footer (#794)', () => {
function baseCtx(overrides = {}) {
return {
locale: 'de', currency: 'CHF',
issuer: { companyName: 'AcmeCo' },
recipient: {
companyName: 'KundenCo', addressLine1: 'Strasse 1',
city: 'Bern', postalCode: '3000',
},
lineItems: [{
quantity: 1, description: 'Photo session',
unitPriceMinor: 30000, lineTotalMinor: 30000,
parentLineItemId: null, parentPosition: null,
}],
totals: {
netAmountMinor: 30000, vatRate: 0, vatAmountMinor: 0,
shippingAmountMinor: 0, totalAmountMinor: 30000,
},
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
qrFormat: 'none',
paymentTerm: { netDays: 30 },
...overrides,
};
}
const pageCount = (buf) => (buf.toString('latin1').match(/\/Type\s*\/Page(?![s])/g) || []).length;
const VAT_NOTE = 'Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).';
it('renders the VAT note on a single-page invoice (adds content, valid PDF)', async () => {
const withNote = await pdfService.renderInvoiceToBuffer(baseCtx({ vatNote: VAT_NOTE }));
const without = await pdfService.renderInvoiceToBuffer(baseCtx());
expect(withNote.slice(0, 4).toString('ascii')).toBe('%PDF');
expect(pageCount(withNote)).toBe(1);
expect(withNote.length).toBeGreaterThan(without.length);
});
it('paginates a long invoice (with the note) across multiple pages without a stray blank page', async () => {
const manyItems = Array.from({ length: 60 }, (_, i) => ({
quantity: 1, description: `Position ${i + 1} — fotografische Leistung`,
unitPriceMinor: 3225, lineTotalMinor: 3225,
parentLineItemId: null, parentPosition: null,
}));
const buf = await pdfService.renderInvoiceToBuffer(baseCtx({
lineItems: manyItems,
totals: {
netAmountMinor: 193500, vatRate: 0, vatAmountMinor: 0,
shippingAmountMinor: 0, totalAmountMinor: 193500,
},
vatNote: VAT_NOTE,
}));
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
const pages = pageCount(buf);
expect(pages).toBeGreaterThanOrEqual(2);
// 60 short rows fit in 23 pages; a stray blank page (the old margin
// bug) or a runaway loop would blow past this.
expect(pages).toBeLessThanOrEqual(3);
});
});
@@ -71,24 +71,15 @@ jest.mock('../../src/services/imageProcessor', () => {
const mockExtractCaptureDate = jest.fn();
return {
generateThumbnail: mockGenerateThumbnail,
generateVideoPlaceholder: jest.fn(async (filename) => `thumbnails/thumb_${filename.replace(/\.[^.]+$/, '')}.jpg`),
extractCaptureDate: mockExtractCaptureDate,
withLocalCopy: jest.fn(async (key, fn) =>
fn(`/tmp/local-copy-${require('path').basename(key)}`)
),
// Pass-through for ordinary (non-RAW) images: returns the path unchanged
// with a no-op cleanup, matching the real helper's behaviour for jpg/png.
withProcessableImage: jest.fn(async (localPath) => ({
path: localPath,
outputBasename: undefined,
cleanup: () => {},
})),
};
});
jest.mock('../../src/services/videoProcessor', () => ({
processUploadedVideo: jest.fn(),
extractVideoMetadata: jest.fn(),
isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'),
}));
@@ -214,44 +205,6 @@ describe('photoProcessor.processPhoto', () => {
expect(watermarkService.generateForPhoto).not.toHaveBeenCalled();
});
it('keeps a video complete with a placeholder thumbnail when ffmpeg fails', async () => {
dbModule.__setPhoto({
id: 203,
event_id: 9,
filename: 'drone-clip.mp4',
original_filename: 'drone.mp4',
mime_type: 'video/mp4',
media_type: 'video',
size_bytes: 12345,
captured_at: null,
});
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
// ffmpeg thumbnail pipeline throws (e.g. unsupported pixel format)…
videoProcessor.processUploadedVideo.mockRejectedValueOnce(new Error('ffmpeg exited with code 1'));
// …but a plain probe still works.
videoProcessor.extractVideoMetadata.mockResolvedValueOnce({
duration: 42,
videoCodec: 'hevc',
audioCodec: 'aac',
width: 3840,
height: 2160,
});
const { processPhoto } = require('../../src/services/photoProcessor');
await processPhoto(203);
const finalUpdate = dbModule.__recorded().updateCalls.pop();
// The row must complete — 'failed' rows are invisible to guests.
expect(finalUpdate.data.processing_status).toBe('complete');
// Placeholder instead of NULL: a completed video without thumbnail would
// make the grid fetch the original video file for the tile (#845 review).
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_drone-clip.jpg');
expect(imageProcessor.generateVideoPlaceholder).toHaveBeenCalledWith('drone-clip.mp4');
expect(finalUpdate.data.duration).toBe(42);
expect(finalUpdate.data.video_codec).toBe('hevc');
});
it('throws when the photo row no longer exists', async () => {
dbModule.__setPhoto(null);
dbModule.__setEvent({ id: 1 });
@@ -1,111 +0,0 @@
/**
* Regression tests for reinjectCurrentAdmin — the operator-preservation step of
* the .picpeak restore (GHSA-qxfx-4493-4v8f follow-up). Runs against a real
* in-memory SQLite DB so the UNIQUE(email)/UNIQUE(username) constraints behave
* as in production. Reconciliation is non-destructive (update-in-place / rename,
* never delete) so restored rows referenced by FKs keep their ids.
*/
const knex = require('knex');
let db;
let reinjectCurrentAdmin;
beforeAll(() => {
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }), { virtual: false });
reinjectCurrentAdmin = require('../../src/services/picpeakImportService').reinjectCurrentAdmin;
});
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('username').notNullable().unique();
t.string('email').notNullable().unique();
t.string('password_hash');
t.boolean('is_active').defaultTo(true);
t.boolean('must_change_password').defaultTo(false);
t.integer('role_id');
t.integer('created_by');
t.boolean('two_factor_enabled').defaultTo(false);
t.string('two_factor_secret');
t.text('two_factor_recovery_codes');
});
});
afterEach(async () => { await db.destroy(); });
const operator = {
id: 1, username: 'admin', email: 'op@example.com',
password_hash: 'OP_HASH', is_active: 1, must_change_password: 0, role_id: 1, created_by: 99,
two_factor_enabled: 1, two_factor_secret: 'OP_SECRET', two_factor_recovery_codes: '["a","b"]',
};
test('restores login + MFA in place, keeping the row id and its FK columns (FK-safe)', async () => {
await db('admin_users').insert({
id: 7, username: 'someoneelse', email: 'OP@example.com',
password_hash: 'ATTACKER', is_active: 1, must_change_password: 0, role_id: 4, created_by: 5,
two_factor_enabled: 0, two_factor_secret: 'ATTACKER_SECRET', two_factor_recovery_codes: null,
});
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
const rows = await db('admin_users');
expect(rows).toHaveLength(1);
const row = rows[0];
expect(row.id).toBe(7); // id preserved → FK refs hold
expect(row.username).toBe('admin');
expect(row.password_hash).toBe('OP_HASH');
expect(Boolean(row.two_factor_enabled)).toBe(true);
expect(row.two_factor_secret).toBe('OP_SECRET'); // attacker MFA secret gone
expect(row.two_factor_recovery_codes).toBe('["a","b"]');
// Relationship/audit FKs are NOT forced from the operator snapshot (avoids
// dangling role_id/created_by on a cross-instance restore) — the restored
// row keeps its own already-valid values.
expect(row.role_id).toBe(4);
expect(row.created_by).toBe(5);
});
test('renames (not deletes) a different row holding the operator username', async () => {
await db('admin_users').insert({
id: 3, username: 'admin', email: 'other@instance.test',
password_hash: 'OTHER', is_active: 1, role_id: 4,
});
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // the other admin survives (FK-safe)
const other = rows.find((r) => r.id === 3);
expect(other.username).toBe('admin__restored_3'); // renamed, id kept
expect(other.email).toBe('other@instance.test');
const op = rows.find((r) => r.username === 'admin');
expect(op.password_hash).toBe('OP_HASH');
});
test('reconciles email and username colliding with DIFFERENT rows without deleting either', async () => {
await db('admin_users').insert([
{ id: 4, username: 'someoneelse', email: 'op@example.com', password_hash: 'A', role_id: 4 },
{ id: 5, username: 'admin', email: 'other@instance.test', password_hash: 'B', role_id: 4 },
]);
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // both rows survive
const opRow = rows.find((r) => r.id === 4); // email match updated in place
expect(opRow.username).toBe('admin');
expect(opRow.password_hash).toBe('OP_HASH');
const renamed = rows.find((r) => r.id === 5); // username holder renamed, not deleted
expect(renamed.username).toBe('admin__restored_5');
});
test('inserts the operator with a non-colliding id when neither key exists in the backup', async () => {
await db('admin_users').insert({
id: 9, username: 'backupadmin', email: 'backup@instance.test', password_hash: 'B', role_id: 1,
});
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // backup admin untouched
const opRow = rows.find((r) => r.username === 'admin');
expect(opRow.password_hash).toBe('OP_HASH');
expect(opRow.id).toBe(10); // max(9)+1, no collision
expect(opRow.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
});
@@ -1,105 +0,0 @@
/**
* Tests for preserveOperatorRole — re-establishing the operator's authorization
* after a restore replaces the roles / permissions / role_permissions tables.
* Real in-memory SQLite so the joins and inserts behave as in production.
*/
const knex = require('knex');
let db;
let svc;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('roles', (t) => {
t.increments('id');
t.string('name').notNullable().unique();
t.string('display_name');
t.integer('priority').defaultTo(0);
t.boolean('is_system').defaultTo(false);
});
await db.schema.createTable('permissions', (t) => {
t.increments('id');
t.string('name').notNullable().unique();
t.string('display_name');
t.string('category');
});
await db.schema.createTable('role_permissions', (t) => {
t.integer('role_id').notNullable();
t.integer('permission_id').notNullable();
t.primary(['role_id', 'permission_id']);
});
await db.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('email');
t.integer('role_id');
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }));
jest.doMock('../../src/database/db', () => ({ db }));
svc = require('../../src/services/picpeakImportService');
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
await db.destroy();
});
test('captureOperatorRole returns the role + its permission names', async () => {
await db('roles').insert({ id: 1, name: 'super_admin', display_name: 'Super Admin', priority: 100 });
await db('permissions').insert([
{ id: 1, name: 'events.create', display_name: 'Create', category: 'events' },
{ id: 2, name: 'users.manage', display_name: 'Manage', category: 'users' },
]);
await db('role_permissions').insert([{ role_id: 1, permission_id: 1 }, { role_id: 1, permission_id: 2 }]);
const snap = await svc.captureOperatorRole(1);
expect(snap.role.name).toBe('super_admin');
expect(snap.permissions.sort()).toEqual(['events.create', 'users.manage']);
});
test('preserveOperatorRole binds to a restored role of the same NAME (ids remapped)', async () => {
const snapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
// Simulate post-restore RBAC where super_admin now has a DIFFERENT id.
await db('roles').insert({ id: 7, name: 'super_admin', display_name: 'Super Admin (restored)', priority: 100 });
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, snapshot));
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBe(7); // bound to restored super_admin by name
expect(await db('roles').count({ c: '*' }).first()).toEqual({ c: 1 }); // no duplicate role created
});
test('preserveOperatorRole re-creates the role + grants when the backup omits it', async () => {
const snapshot = {
role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true },
permissions: ['events.create', 'users.manage', 'gone.permission'],
};
// Post-restore RBAC WITHOUT super_admin; only some permissions exist.
await db('roles').insert({ id: 2, name: 'viewer', display_name: 'Viewer', priority: 10 });
await db('permissions').insert([
{ id: 5, name: 'events.create', display_name: 'Create', category: 'events' },
{ id: 6, name: 'users.manage', display_name: 'Manage', category: 'users' },
]);
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, snapshot));
const recreated = await db('roles').where({ name: 'super_admin' }).first();
expect(recreated).toBeTruthy(); // role re-created, not left missing
expect(recreated.id).toBe(3); // max(2)+1
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBe(recreated.id); // operator not locked out / downgraded
const grants = await db('role_permissions').where({ role_id: recreated.id }).pluck('permission_id');
expect(grants.sort()).toEqual([5, 6]); // existing perms re-granted; 'gone.permission' skipped
});
test('preserveOperatorRole no-ops when the operator had no role', async () => {
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, null));
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBeNull();
});
@@ -1,51 +0,0 @@
const fs = require('fs');
const path = require('path');
const {
EXTENSION_TO_MIME,
extensionsToMimeTypes,
} = require('../../src/services/uploadSettings');
const { validateFileType } = require('../../src/utils/fileSecurityUtils');
const RAW_AND_HEIF_TYPES = {
dng: 'image/x-adobe-dng',
heic: 'image/heic',
heif: 'image/heif',
};
function getFrontendExtensionMap() {
const source = fs.readFileSync(
path.join(__dirname, '../../../frontend/src/utils/fileTypes.ts'),
'utf8'
);
const match = source.match(/const EXTENSION_TO_MIME[^=]*= \{([\s\S]*?)\n\};/);
if (!match) throw new Error('Could not find frontend EXTENSION_TO_MIME');
// Parse `key: 'mime',` entries — quoted keys and trailing `//` comments are
// tolerated; any other non-blank, non-comment line inside the map is a parse
// failure, so a syntax the parser can't read fails loudly instead of silently
// dropping the entry from the comparison.
const entries = [];
for (const line of match[1].split('\n')) {
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('//')) continue;
const entry = trimmed.match(/^'?(\w+)'?\s*:\s*'([^']+)'\s*,?\s*(?:\/\/.*)?$/);
if (!entry) throw new Error(`Unparsable EXTENSION_TO_MIME line in frontend fileTypes.ts: "${trimmed}"`);
entries.push([entry[1], entry[2]]);
}
return Object.fromEntries(entries);
}
describe('configured upload file types', () => {
test('supports configured DNG, HEIC, and HEIF uploads', () => {
expect(extensionsToMimeTypes('dng,heic,heif')).toEqual(Object.values(RAW_AND_HEIF_TYPES));
for (const [extension, mimeType] of Object.entries(RAW_AND_HEIF_TYPES)) {
expect(validateFileType(`image.${extension}`, mimeType, [mimeType])).toBe(true);
}
});
test('uses the same extension-to-MIME map as the frontend', () => {
expect(getFrontendExtensionMap()).toEqual(EXTENSION_TO_MIME);
});
});
@@ -1,65 +0,0 @@
/**
* Unit tests for the per-file upload size limit getter (general_max_file_size_mb),
* added so the admin's "Max File Size (MB)" setting applies to guest uploads
* (#613 follow-up — mat1990dj). Real in-memory SQLite app_settings so the
* read/parse/cache path runs exactly as in production.
*/
const knex = require('knex');
let db;
let svc;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.text('setting_value');
t.string('setting_type');
t.timestamp('updated_at');
});
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
svc = require('../../src/services/uploadSettings');
svc.clearMaxFileSizeCache();
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
await db.destroy();
});
async function setLimit(mb) {
await db('app_settings')
.insert({ setting_key: 'general_max_file_size_mb', setting_value: JSON.stringify(mb), setting_type: 'general', updated_at: new Date() })
.onConflict('setting_key').merge({ setting_value: JSON.stringify(mb) });
svc.clearMaxFileSizeCache();
}
test('defaults to 50MB when the setting is absent', async () => {
expect(await svc.getMaxFileSizeMb()).toBe(50);
expect(await svc.getMaxFileSizeBytes()).toBe(50 * 1024 * 1024);
});
test('honours a configured value (e.g. 500MB video)', async () => {
await setLimit(500);
expect(await svc.getMaxFileSizeMb()).toBe(500);
expect(await svc.getMaxFileSizeBytes()).toBe(500 * 1024 * 1024);
});
test('clamps a nonsense value to the default and caps absurd values at the ceiling', async () => {
await setLimit(0);
expect(await svc.getMaxFileSizeMb()).toBe(50); // 0 → default
await setLimit(99_999_999);
expect(await svc.getMaxFileSizeMb()).toBe(svc.MAX_ALLOWED_FILE_SIZE_MB); // ceiling
});
test('caches for the TTL — a mid-window DB change is not seen until the cache is cleared', async () => {
await setLimit(200);
expect(await svc.getMaxFileSizeMb()).toBe(200);
// change the DB but do NOT clear cache
await db('app_settings').where({ setting_key: 'general_max_file_size_mb' }).update({ setting_value: JSON.stringify(300) });
expect(await svc.getMaxFileSizeMb()).toBe(200); // still cached
svc.clearMaxFileSizeCache();
expect(await svc.getMaxFileSizeMb()).toBe(300); // refreshed
});
@@ -1,61 +0,0 @@
/**
* Regression tests for the password-complexity setting read path.
*
* Bug 1 (key mismatch): the settings UI saves the admin's choice as
* `security_password_complexity` (useSettingsState.ts prefixes every
* security field with `security_`), but getPasswordComplexitySettings()
* queried `security_password_complexity_level` — a key nothing writes —
* so the configured level was silently ignored.
*
* Bug 2 (driver shape, codex review of #843): on SQLite the TEXT column
* returns the JSON-stringified value ('"very_strong"'), but on Postgres
* (production default) `setting_value` is a json column and comes back
* already decoded ('very_strong'). A bare JSON.parse throws on the
* decoded shape and the outer catch fell back to 'moderate' — the
* setting stayed unenforced on Postgres even with the right key.
*/
const mockQueriedKeys = [];
let mockStoredValue;
jest.mock('../../src/database/db', () => ({
db: () => ({
where(_col, key) {
mockQueriedKeys.push(key);
return this;
},
first() {
return Promise.resolve(
mockQueriedKeys[mockQueriedKeys.length - 1] === 'security_password_complexity'
? { setting_key: 'security_password_complexity', setting_value: mockStoredValue }
: undefined
);
},
}),
withRetry: (fn) => fn(),
}));
const { getPasswordComplexitySettings } = require('../../src/utils/passwordValidation');
describe('getPasswordComplexitySettings', () => {
beforeEach(() => { mockQueriedKeys.length = 0; });
it('reads the key the settings UI actually writes (SQLite shape: JSON-stringified)', async () => {
mockStoredValue = JSON.stringify('very_strong'); // '"very_strong"'
const level = await getPasswordComplexitySettings();
expect(mockQueriedKeys).toContain('security_password_complexity');
expect(level).toBe('very_strong');
});
it('accepts the Postgres json-column shape (already decoded, no quotes)', async () => {
mockStoredValue = 'very_strong'; // pg driver auto-parses the json column
const level = await getPasswordComplexitySettings();
expect(level).toBe('very_strong');
});
it('falls back to moderate on an empty value', async () => {
mockStoredValue = '';
const level = await getPasswordComplexitySettings();
expect(level).toBe('moderate');
});
});
@@ -1,41 +0,0 @@
const path = require('path');
const { assertZipEntriesWithin } = require('../../src/utils/safePath');
describe('assertZipEntriesWithin (ZIP-slip guard, GHSA-jfhw-fj23-fx6x)', () => {
const root = path.join('/tmp', 'picpeak-extract-root');
it('accepts entries that stay within the extraction root', () => {
const entries = [
{ name: 'photo.jpg' },
{ name: 'category/nested/photo.png' },
{ name: 'photos_manifest.json' },
{ name: 'subdir/' },
];
expect(() => assertZipEntriesWithin(entries, root)).not.toThrow();
});
it('rejects a parent-traversal entry', () => {
const entries = [{ name: '../../uploads/logos/evil.svg' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
it('rejects an absolute-path entry', () => {
const entries = [{ name: '/etc/cron.d/evil' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
it('rejects when a safe entry is mixed with a traversal entry', () => {
const entries = [{ name: 'ok.jpg' }, { name: '../escape.txt' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
it('tolerates empty / nameless entries', () => {
expect(() => assertZipEntriesWithin([{}, { name: '' }, null], root)).not.toThrow();
});
it('does not treat a sibling prefix directory as inside the root', () => {
// root is .../picpeak-extract-root; ../picpeak-extract-root-evil must not pass
const entries = [{ name: '../picpeak-extract-root-evil/x' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
});
@@ -1,56 +0,0 @@
/**
* Unit tests for the global session cutoff (utils/sessionCutoff.js). Uses a
* real in-memory SQLite `app_settings` table so the read/write/parse path is
* exercised exactly as in production.
*/
const knex = require('knex');
let db;
let cutoff;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.text('setting_value');
t.string('setting_type');
t.timestamp('updated_at');
});
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
cutoff = require('../../src/utils/sessionCutoff');
cutoff._resetCache();
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
await db.destroy();
});
test('no cutoff set → nothing is invalidated', async () => {
expect(await cutoff.getSessionsValidAfter()).toBe(0);
expect(await cutoff.isTokenBeforeCutoff({ iat: 1000 })).toBe(false);
});
test('token issued before the cutoff is rejected, at/after is accepted', async () => {
await cutoff.setSessionsValidAfter(2000);
expect(await cutoff.isTokenBeforeCutoff({ iat: 1999 })).toBe(true); // pre-restore session
expect(await cutoff.isTokenBeforeCutoff({ iat: 2000 })).toBe(false); // same second → kept
expect(await cutoff.isTokenBeforeCutoff({ iat: 2001 })).toBe(false); // post-restore login
});
test('setSessionsValidAfter upserts a single row and refreshes the cache', async () => {
await cutoff.setSessionsValidAfter(1000);
await cutoff.setSessionsValidAfter(3000);
const rows = await db('app_settings').where('setting_key', 'security_sessions_valid_after');
expect(rows).toHaveLength(1);
cutoff._resetCache();
expect(await cutoff.getSessionsValidAfter()).toBe(3000);
});
test('a token without iat is never treated as before the cutoff', async () => {
await cutoff.setSessionsValidAfter(2000);
expect(await cutoff.isTokenBeforeCutoff({})).toBe(false);
expect(await cutoff.isTokenBeforeCutoff(null)).toBe(false);
});
@@ -1,52 +0,0 @@
/**
* Messages Phase 2 — additional inbound mailboxes + captured message bodies.
*
* `mail_accounts` holds inbound mailboxes BEYOND the primary accounting IMAP
* that already lives in `email_configs` (e.g. the customer `hello@` mailbox).
* The intake poller (emailIntakeService) polls the accounting mailbox AND every
* enabled row here; customer mail is logged with its body but not routed to the
* accounting inbox.
*
* The new `received_emails` columns capture the parsed message so the Messages
* reading pane can show it: `account_key` tags which mailbox it came from,
* `body_html`/`body_text` hold the (server-sanitized) body, `to_address` the
* envelope recipient. All additive + guarded.
*/
exports.up = async function up(knex) {
const hasAccounts = await knex.schema.hasTable('mail_accounts');
if (!hasAccounts) {
await knex.schema.createTable('mail_accounts', (t) => {
t.increments('id').primary();
t.string('account_key', 64).notNullable().unique(); // e.g. 'customers'
t.string('label', 120);
t.string('imap_host', 255);
t.integer('imap_port').defaultTo(993);
t.boolean('imap_secure').defaultTo(true);
t.string('imap_user', 255);
t.string('imap_pass', 512);
t.string('imap_folder', 255).defaultTo('INBOX');
t.boolean('enabled').defaultTo(false);
t.timestamp('created_at').defaultTo(knex.fn.now());
t.timestamp('updated_at').defaultTo(knex.fn.now());
});
}
const cols = [
['account_key', (t) => t.string('account_key', 64)],
['to_address', (t) => t.string('to_address', 512)],
['body_html', (t) => t.text('body_html')],
['body_text', (t) => t.text('body_text')],
];
for (const [name, add] of cols) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn('received_emails', name);
// eslint-disable-next-line no-await-in-loop
if (!has) await knex.schema.alterTable('received_emails', add);
}
};
exports.down = async function down(knex) {
// Non-destructive on the audit log: leave the added columns in place (they're
// nullable and harmless). Only drop the new table.
await knex.schema.dropTableIfExists('mail_accounts');
};
@@ -1,25 +0,0 @@
/**
* Messages Phase 3 — distinguish human-composed sends from system mail.
*
* `origin` is 'system' for everything the app queues automatically (invoices,
* reminders, gallery notices — the Automated stream) and 'manual' for emails an
* admin composed/edited in the Messages composer (replies + document messages —
* the Customers ▸ Sent stream). Existing rows default to 'system'.
*/
exports.up = async function up(knex) {
const has = await knex.schema.hasColumn('email_queue', 'origin');
if (!has) {
await knex.schema.alterTable('email_queue', (t) => {
t.string('origin', 16).defaultTo('system');
});
}
};
exports.down = async function down(knex) {
const has = await knex.schema.hasColumn('email_queue', 'origin');
if (has) {
await knex.schema.alterTable('email_queue', (t) => {
t.dropColumn('origin');
});
}
};
@@ -1,34 +0,0 @@
/**
* Messages Phase 3 follow-up — outgoing (SMTP) settings per mail account.
*
* The customer mailbox (hello@) needs BOTH incoming (IMAP, migration 154) and
* outgoing (SMTP) config, so replies to customers send from hello@ instead of
* the global no-reply@ identity. All additive/guarded.
*/
exports.up = async function up(knex) {
const cols = [
['smtp_host', (t) => t.string('smtp_host', 255)],
['smtp_port', (t) => t.integer('smtp_port')],
['smtp_secure', (t) => t.boolean('smtp_secure').defaultTo(false)],
['smtp_user', (t) => t.string('smtp_user', 255)],
['smtp_pass', (t) => t.string('smtp_pass', 512)],
['from_email', (t) => t.string('from_email', 255)],
['from_name', (t) => t.string('from_name', 120)],
];
for (const [name, add] of cols) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn('mail_accounts', name);
// eslint-disable-next-line no-await-in-loop
if (!has) await knex.schema.alterTable('mail_accounts', add);
}
};
exports.down = async function down(knex) {
const cols = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass', 'from_email', 'from_name'];
for (const name of cols) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn('mail_accounts', name);
// eslint-disable-next-line no-await-in-loop
if (has) await knex.schema.alterTable('mail_accounts', (t) => t.dropColumn(name));
}
};
@@ -1,33 +0,0 @@
/**
* Messages — Archive / Delete (trash) support.
*
* `mailbox_state` on both mail tables: 'active' (normal folders), 'archived'
* (Archived folder), or 'deleted' (Deleted/trash folder). Delete is soft — the
* row moves to 'deleted' and is only removed for good when purged FROM the
* Deleted folder. Legacy rows have NULL, treated as 'active'. Additive/guarded.
*/
exports.up = async function up(knex) {
for (const table of ['email_queue', 'received_emails']) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn(table, 'mailbox_state');
// eslint-disable-next-line no-await-in-loop
if (!has) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable(table, (t) => {
t.string('mailbox_state', 16).defaultTo('active');
});
}
}
};
exports.down = async function down(knex) {
for (const table of ['email_queue', 'received_emails']) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn(table, 'mailbox_state');
// eslint-disable-next-line no-await-in-loop
if (has) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable(table, (t) => { t.dropColumn('mailbox_state'); });
}
}
};
@@ -1,37 +0,0 @@
/**
* Migration 158: per-event slideshow ordering + category filter (#202).
*
* - `show_order` — 'chronological' (default, upload order) | 'random'
* (client-side shuffle). Lets the Live Slideshow play
* photos in a varied order during an event.
* - `show_category_id`— optional FK into `photo_categories`. When set, the
* slideshow only shows photos in that category (NULL =
* all visible photos, the existing behaviour).
*
* Both additive + guarded. Defaults preserve today's behaviour (chronological,
* all photos), so existing slideshows are unchanged.
*/
exports.up = async function up(knex) {
const hasOrder = await knex.schema.hasColumn('events', 'show_order');
if (!hasOrder) {
await knex.schema.alterTable('events', (t) => {
t.string('show_order', 20).defaultTo('chronological');
});
}
const hasCat = await knex.schema.hasColumn('events', 'show_category_id');
if (!hasCat) {
await knex.schema.alterTable('events', (t) => {
t.integer('show_category_id').nullable();
});
}
};
exports.down = async function down(knex) {
for (const col of ['show_order', 'show_category_id']) {
// eslint-disable-next-line no-await-in-loop
if (await knex.schema.hasColumn('events', col)) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable('events', (t) => t.dropColumn(col));
}
}
};
@@ -1,57 +0,0 @@
/**
* Migration 159: per-event category ordering (#782).
*
* Adds a `display_order` integer to `photo_categories` so photographers can
* arrange an event's categories in the flow of the day (Pre-Ceremony →
* Ceremony → Reception …) instead of the hard-coded AZ order. Mirrors the
* `display_order` column + reorder pattern already used by `event_types`.
*
* Preserve existing galleries: backfill `display_order` from the CURRENT
* (alphabetical) order, scoped — globals numbered together, event-specific
* numbered per event — so nothing reshuffles on upgrade. A custom order is
* opt-in via the admin reorder controls. See feedback: migrations should pin
* previously-implicit defaults onto existing rows.
*
* Backfill runs in JS (not a SQL window function) to stay portable across
* SQLite (dev) and Postgres (prod).
*
* Additive + hasColumn-guarded.
*/
async function addColumn(knex, table, column, builder) {
if (!(await knex.schema.hasColumn(table, column))) {
await knex.schema.alterTable(table, builder);
}
}
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
await addColumn(knex, 'photo_categories', 'display_order', (t) => {
t.integer('display_order').notNullable().defaultTo(0);
t.index('display_order');
});
// Backfill from the current alphabetical order, per scope, so existing
// galleries render exactly as before until an admin reorders.
const cats = await knex('photo_categories')
.select('id', 'name', 'is_global', 'event_id')
.orderBy('name', 'asc');
const counters = {};
for (const c of cats) {
const scope = c.is_global ? 'global' : `event:${c.event_id}`;
counters[scope] = (counters[scope] || 0) + 1;
await knex('photo_categories')
.where('id', c.id)
.update({ display_order: counters[scope] });
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
if (await knex.schema.hasColumn('photo_categories', 'display_order')) {
await knex.schema.alterTable('photo_categories', (t) =>
t.dropColumn('display_order')
);
}
};
@@ -1,46 +0,0 @@
/**
* Migration 160: per-event category order override (#782).
*
* Builds on migration 159 (photo_categories.display_order = the GLOBAL default
* order) by adding a per-event OVERRIDE layer. Global categories are shared
* across every event, so a single display_order can only express one order for
* them. This table lets a single gallery arrange its categories — globals AND
* event-specific, interleaved into the flow of the day — independently of the
* global default.
*
* Resolution (see adminCategories / gallery):
* 1. if the event has override rows -> use override.position;
* 2. else fall back to photo_categories.display_order (the global default);
* 3. else name.
*
* An event is either "using the default" (no rows here) or "customised" (a row
* per category it shows). No backfill: every existing event starts on the
* default order, so nothing reshuffles — a custom order is opt-in per event.
*
* Additive + hasTable-guarded.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
if (await knex.schema.hasTable('event_category_order')) return;
await knex.schema.createTable('event_category_order', (t) => {
t.increments('id').primary();
t.integer('event_id').notNullable()
.references('id').inTable('events').onDelete('CASCADE');
t.integer('category_id').notNullable()
.references('id').inTable('photo_categories').onDelete('CASCADE');
t.integer('position').notNullable().defaultTo(0);
t.timestamp('created_at').defaultTo(knex.fn.now());
// At most one position per (event, category).
t.unique(['event_id', 'category_id']);
// Ordered reads are always scoped to one event.
t.index(['event_id', 'position']);
});
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('event_category_order')) {
await knex.schema.dropTable('event_category_order');
}
};
@@ -1,43 +0,0 @@
/**
* Migration 161: `setup_wizard_completed` app setting (#800).
*
* The setup wizard gains an event-types step that may rename or DELETE the
* seeded system event types. That is only safe on a pristine install, so the
* backend gates system-type deletion on this flag being unset (plus zero
* usage — see eventTypeService.deleteEventType).
*
* Backfill rule: any install that already has an admin account predates the
* wizard step (or already finished the wizard), so it is marked completed
* here — the deletion window never opens on existing setups. A genuinely
* fresh install runs this migration BEFORE its first admin is created, so
* the flag starts false and the wizard's finish call flips it to true.
*
* Idempotent: skips when the key already exists. Values are JSON-stringified
* to match getAppSetting's JSON.parse on read.
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
const existing = await knex('app_settings')
.where({ setting_key: 'setup_wizard_completed' })
.first();
if (existing) return;
let hasAdmin = false;
if (await knex.schema.hasTable('admin_users')) {
const row = await knex('admin_users').count({ c: '*' }).first();
hasAdmin = Number(row?.c || 0) > 0;
}
await knex('app_settings').insert({
setting_key: 'setup_wizard_completed',
setting_value: JSON.stringify(hasAdmin),
setting_type: 'boolean',
updated_at: new Date(),
});
};
exports.down = async function down(knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
await knex('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
};
@@ -1,51 +0,0 @@
/**
* Migration 162: OIDC identity binding for admin users (#798).
*
* - `auth_provider` — 'local' (default) or 'oidc'. Which authority owns the
* account's credentials.
* - `external_issuer` — the validated `iss` of the IdP that owns the subject.
* OIDC only guarantees `sub` uniqueness WITHIN an
* issuer, so bindings match on (iss, sub) — otherwise
* switching `oidc_issuer_url` could map a new
* provider's user onto an old provider's admin when
* their subjects collide.
* - `external_subject` — the IdP's stable subject identifier (OIDC `sub`).
* SSO logins match on (external_issuer,
* external_subject), NEVER on email alone —
* email-matching is an account-takeover vector with
* IdPs that don't verify addresses. Nullable: local
* accounts have neither.
*
* Composite unique index so one IdP identity can't map to two admin rows.
* Additive + guarded; existing rows keep working untouched ('local', NULL).
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasColumn('admin_users', 'auth_provider'))) {
await knex.schema.alterTable('admin_users', (t) => {
t.string('auth_provider', 20).notNullable().defaultTo('local');
});
}
if (!(await knex.schema.hasColumn('admin_users', 'external_issuer'))) {
await knex.schema.alterTable('admin_users', (t) => {
t.string('external_issuer', 512).nullable();
});
}
if (!(await knex.schema.hasColumn('admin_users', 'external_subject'))) {
await knex.schema.alterTable('admin_users', (t) => {
t.string('external_subject', 255).nullable();
t.unique(['external_issuer', 'external_subject'], {
indexName: 'admin_users_issuer_subject_unique',
});
});
}
};
exports.down = async function down(knex) {
for (const col of ['external_subject', 'external_issuer', 'auth_provider']) {
// eslint-disable-next-line no-await-in-loop
if (await knex.schema.hasColumn('admin_users', col)) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable('admin_users', (t) => t.dropColumn(col));
}
}
};
+4 -64
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "3.92.1-beta.0",
"version": "3.80.0-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.92.1-beta.0",
"version": "3.80.0-beta.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -40,9 +40,7 @@
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
"openid-client": "^5.7.1",
"otplib": "^12.0.1",
"p-limit": "^3.1.0",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
@@ -7907,15 +7905,6 @@
"@sideway/pinpoint": "^2.0.0"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jpeg-exif": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
@@ -9341,15 +9330,6 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -9362,15 +9342,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -9433,39 +9404,6 @@
"license": "MIT",
"peer": true
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -9499,6 +9437,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
@@ -12511,6 +12450,7 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.92.2-beta.0",
"version": "3.82.4-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -11,7 +11,6 @@
"generate:watermarks": "node scripts/generate-watermarks.js",
"test": "jest",
"test:s3": "SKIP_S3_TESTS=false jest __tests__/integration/backup-s3",
"test:pg": "jest __tests__/integration/picpeakRestorePg",
"lint": "eslint src/"
},
"dependencies": {
@@ -47,9 +46,7 @@
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
"openid-client": "^5.7.1",
"otplib": "^12.0.1",
"p-limit": "^3.1.0",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
+3 -1
View File
@@ -38,6 +38,7 @@ const {
// Import routes
const authRoutes = require('./src/routes/auth');
const eventRoutes = require('./src/routes/events');
const galleryRoutes = require('./src/routes/gallery');
const adminRoutes = require('./src/routes/admin');
const adminAuthRoutes = require('./src/routes/adminAuth');
@@ -694,7 +695,8 @@ app.get('/health', async (req, res) => {
// Routes
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
app.use('/api/auth', authRoutes);
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
app.use('/api/events', eventRoutes);
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
// Gallery routes - main routes first, then feedback routes
app.use('/api/gallery', galleryRoutes);
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
@@ -33,13 +33,6 @@ jest.mock('../utils/tokenRevocation', () => ({
isTokenRevoked: jest.fn(),
}));
// The global session cutoff (added for .picpeak restore invalidation) queries
// app_settings; stub it to "no cutoff" so it doesn't consume this suite's
// one-shot db() mock. Its own behaviour is covered by utils/sessionCutoff.test.js.
jest.mock('../utils/sessionCutoff', () => ({
isTokenBeforeCutoff: jest.fn().mockResolvedValue(false),
}));
jest.mock('../utils/tokenUtils', () => ({
getCustomerTokenFromRequest: jest.fn(),
}));
+1 -19
View File
@@ -2,7 +2,6 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
@@ -39,13 +38,6 @@ async function adminAuth(req, res, next) {
});
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject any session issued before the global cutoff (set by a .picpeak
// restore, which can reassign admin ids). Forces every pre-restore admin
// session to re-authenticate against the restored data.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Verify token type
if (decoded.type !== 'admin') {
@@ -165,12 +157,7 @@ async function galleryAuth(req, res, next) {
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Verify token type
if (decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid access token' });
@@ -234,11 +221,6 @@ async function photoAuth(req, res, next) {
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Allow both admin and gallery tokens
if (decoded.type === 'admin') {
const admin = await db('admin_users')
-6
View File
@@ -13,7 +13,6 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const logger = require('../utils/logger');
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
@@ -62,11 +61,6 @@ async function customerAuth(req, res, next) {
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
if (decoded.type !== 'customer') {
logger.warn('[customerAuth] wrong token type', {
url: req.originalUrl,
-9
View File
@@ -73,15 +73,6 @@ async function maintenanceMiddleware(req, res, next) {
// entries here matched nothing, which is exactly why the lockout happened).
const skipPaths = [
'/api/auth/admin/login',
// The second factor is part of the same login — without this, any
// MFA-enrolled admin gets a 503 on the verify step and cannot sign in
// at all while maintenance mode is on.
'/api/auth/admin/login/mfa',
// SSO variants of the admin login (#798) — same reasoning: an SSO-only
// (JIT-provisioned) admin has no password, so blocking these would make
// maintenance mode admin-proof for them.
'/api/auth/admin/sso/login',
'/api/auth/admin/sso/callback',
'/api/auth/session',
'/api/public/settings',
'/health'
-11
View File
@@ -9,7 +9,6 @@ const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
const StreamZip = require('node-stream-zip');
const { requireEventOwnership } = require('../middleware/ownership');
const { assertZipEntriesWithin } = require('../utils/safePath');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
const router = express.Router();
@@ -184,16 +183,6 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
const entries = Object.values(await zip.entries());
logger.info(`Archive contains ${entries.length} entries`);
// Reject ZIP-slip entries before writing anything to disk — extract()
// does not neutralise `../` in entry names (GHSA-jfhw-fj23-fx6x).
try {
assertZipEntriesWithin(entries, eventDir);
} catch (slipErr) {
await zip.close();
logger.warn(`Refusing archive restore — unsafe entry path: ${slipErr.message}`);
return res.status(400).json({ error: 'Archive contains invalid entry paths' });
}
// Stream-extract everything to disk
await zip.extract(null, eventDir);
await zip.close();
+2 -43
View File
@@ -2,8 +2,6 @@ const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
const { revokeToken } = require('../utils/tokenRevocation');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
const logger = require('../utils/logger');
const { errorResponse, getPagination } = require('../utils/routeHelpers');
@@ -31,13 +29,7 @@ router.get('/config', adminAuth, requirePermission('backup.view'), async (req, r
config[setting.setting_key] = setting.setting_value;
}
});
// Never return the stored credentials — mask like the email/WhatsApp
// config endpoints do. The PUT below skips the mask sentinel, so the
// form round-trips without clobbering the real values.
if (config.backup_s3_secret_key) config.backup_s3_secret_key = '••••••••';
if (config.backup_rsync_ssh_key) config.backup_rsync_ssh_key = '••••••••';
res.json(config);
} catch (error) {
errorResponse(res, error, 500, 'Failed to get backup configuration');
@@ -73,11 +65,6 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
// Update settings
for (const [key, value] of Object.entries(updates)) {
// An unchanged secret round-trips as the GET mask sentinel — keep the
// stored value instead of overwriting it with bullets.
if (value === '••••••••') {
continue;
}
if (key.startsWith('backup_')) {
await db('app_settings')
.insert({
@@ -191,40 +178,12 @@ router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), p
const picpeakPath = req.file.path;
try {
const { importFromPicpeak } = require('../services/picpeakImportService');
// adminAuth populates req.admin, not req.user. Passing req.user.id here
// left currentAdminId undefined, so reinjectCurrentAdmin() had no account
// to preserve and the admin_users table was fully replaced by the backup —
// letting a crafted .picpeak take over every admin account (GHSA-qxfx-4493-4v8f).
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.admin && req.admin.id });
// The restore rewrote admin_users, so ids may have shifted. importFromPicpeak
// already stamped a GLOBAL session cutoff (see setSessionsValidAfter), so
// every JWT issued before the restore — admin, customer, gallery — now fails
// auth. Here we additionally give the importing admin an immediate, clean
// logout: revoke this token and clear the cookie so their browser drops the
// session at once rather than on the next 401. Cookie clear is the
// unconditional guarantee; revokeToken() swallows DB errors and returns
// false, so check the result and log loudly if the denylist write didn't
// land (the operator still re-logs-in, which the cookie clear forces).
let tokenRevoked = false;
try {
if (req.token) {
tokenRevoked = await revokeToken(req.token, 'picpeak-import', { adminId: req.admin && req.admin.id });
}
} catch (revokeErr) {
logger.warn('[picpeak-import] failed to revoke session token after restore', { error: revokeErr.message });
}
if (req.token && !tokenRevoked) {
logger.warn('[picpeak-import] session token was NOT added to the revocation denylist after restore; relying on cookie clear to force re-login');
}
clearAdminAuthCookie(res);
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.user && req.user.id });
res.json({
success: true,
tables: result.tables,
filesRestored: result.filesRestored,
usesExternalMedia: result.usesExternalMedia,
sessionInvalidated: true,
});
} catch (error) {
const status = error.statusCode || 500;
+14 -154
View File
@@ -4,8 +4,6 @@ const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const logger = require('../utils/logger');
const router = express.Router();
@@ -14,9 +12,8 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
try {
const categories = await db('photo_categories')
.where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc')
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
logger.error('Error fetching categories:', error);
@@ -24,12 +21,19 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
}
});
// Get categories for a specific event (global + event-specific), resolved to
// the event's effective order: per-event override, else global default, else
// name (#782). Each row carries `override_position` (null when not customised).
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), requireEventOwnership, async (req, res) => {
// Get categories for a specific event (global + event-specific)
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const categories = await getEventCategoriesOrdered(req.params.eventId);
const { eventId } = req.params;
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', formatBoolean(true))
.orWhere('event_id', eventId);
})
.orderBy('is_global', 'desc')
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
logger.error('Error fetching event categories:', error);
@@ -77,27 +81,12 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
return res.status(400).json({ error: 'Category with this slug already exists' });
}
// Append to the end of its scope so a new category doesn't jump to the
// top of an admin-defined order (#782).
const maxRow = await db('photo_categories')
.where(function() {
if (is_global) {
this.where('is_global', formatBoolean(true));
} else {
this.where('event_id', event_id);
}
})
.max('display_order as maxOrder')
.first();
const nextOrder = (maxRow?.maxOrder || 0) + 1;
// Create category
const insertResult = await db('photo_categories').insert({
name,
slug: categorySlug,
is_global,
event_id: is_global ? null : event_id,
display_order: nextOrder
event_id: is_global ? null : event_id
}).returning('id');
const categoryId = insertResult[0]?.id || insertResult[0];
@@ -265,133 +254,4 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
}
});
// Set a per-event category order override (#782). The client sends the full
// ordered id list for THIS event — globals + event-specific, interleaved — and
// we replace the event's override rows in one transaction. This overrides the
// global default order for this gallery only.
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
body('event_id').isInt().withMessage('event_id must be an integer'),
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const eventId = parseInt(req.body.event_id, 10);
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
// Event ownership (event_id comes from the body, so requireEventOwnership —
// which reads req.params — can't be used here). Mirror it: super_admins
// bypass; other admins may only reorder events they own (ownerless
// legacy/system events allowed).
if (req.admin.roleName !== 'super_admin') {
const event = await db('events').where('id', eventId).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (event.created_by && event.created_by !== req.admin.id) {
return res.status(403).json({ error: 'Access denied' });
}
}
// Every id must be a category available to this event: a shared global OR
// one of the event's own categories. Anything else is out of scope.
const available = await db('photo_categories')
.where(function() {
this.where('is_global', formatBoolean(true)).orWhere('event_id', eventId);
})
.pluck('id');
const availableSet = new Set(available);
const invalid = orderedIds.filter((id) => !availableSet.has(id));
if (invalid.length > 0) {
return res.status(400).json({ error: 'One or more categories are not available for this event' });
}
await db.transaction(async (trx) => {
await trx('event_category_order').where('event_id', eventId).del();
await trx('event_category_order').insert(
orderedIds.map((id, i) => ({ event_id: eventId, category_id: id, position: i + 1 }))
);
});
// Log activity after commit (avoids a SQLite in-transaction global write).
await logActivity('event_category_order_set',
{ eventId, count: orderedIds.length },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(await getEventCategoriesOrdered(eventId));
} catch (error) {
logger.error('Error reordering categories:', error);
res.status(500).json({ error: 'Failed to reorder categories' });
}
});
// Clear an event's override — revert this gallery to the global default order.
router.delete('/reorder/:eventId', adminAuth, requirePermission('settings.edit'), requireEventOwnership, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId, 10);
await db('event_category_order').where('event_id', eventId).del();
await logActivity('event_category_order_reset',
{ eventId },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(await getEventCategoriesOrdered(eventId));
} catch (error) {
logger.error('Error resetting category order:', error);
res.status(500).json({ error: 'Failed to reset category order' });
}
});
// Set the GLOBAL default order for shared (global) categories (#782). Applies
// to every gallery that hasn't set its own override. Rewrites display_order.
router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
const globals = await db('photo_categories').where('is_global', formatBoolean(true)).pluck('id');
const globalsSet = new Set(globals);
const invalid = orderedIds.filter((id) => !globalsSet.has(id));
if (invalid.length > 0) {
return res.status(400).json({ error: 'One or more categories are not global' });
}
await db.transaction(async (trx) => {
for (let i = 0; i < orderedIds.length; i += 1) {
await trx('photo_categories').where('id', orderedIds[i]).update({ display_order: i + 1 });
}
});
await logActivity('global_category_order_set',
{ count: orderedIds.length },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
const categories = await db('photo_categories')
.where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc')
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
logger.error('Error reordering global categories:', error);
res.status(500).json({ error: 'Failed to reorder global categories' });
}
});
module.exports = router;
+3 -303
View File
@@ -4,10 +4,6 @@ const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
// Gate the NEW Messages routes on the `messaging` flag (per-route, NOT the whole
// /email mount — the pre-existing config/queue/received endpoints stay ungated).
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const messagingGate = requireFeatureFlag('messaging');
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
const { errorResponse } = require('../utils/routeHelpers');
const logger = require('../utils/logger');
@@ -264,196 +260,16 @@ router.get('/received', adminAuth, requirePermission('email.view'), async (req,
try {
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 25));
const account = req.query.account ? String(req.query.account) : null;
// mailbox_state filter: no param → active (+ legacy NULL); else exact.
const state = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active';
// Optional full-table search (sender / subject) so results aren't truncated
// to the first page before matching.
const q = req.query.q ? String(req.query.q).trim().slice(0, 255) : '';
// 'accounting' matches legacy rows too (account_key was NULL before mig 154).
const applyAccount = (qb) => {
if (account === 'accounting') qb.where((b) => b.where('account_key', 'accounting').orWhereNull('account_key'));
else if (account) qb.where('account_key', account);
if (state === 'active') qb.where((b) => b.where('mailbox_state', 'active').orWhereNull('mailbox_state'));
else qb.where('mailbox_state', state);
if (q) qb.where((b) => b.where('from_address', 'like', `%${q}%`).orWhere('subject', 'like', `%${q}%`));
return qb;
};
const countRow = await applyAccount(db('received_emails')).count({ c: '*' }).first();
const base = db('received_emails');
const countRow = await base.clone().count({ c: '*' }).first();
const total = parseInt(countRow?.c || 0, 10);
// Bodies are excluded from the list (can be large); fetched per-message.
const items = await applyAccount(db('received_emails'))
.select('id', 'message_id', 'account_key', 'from_address', 'to_address', 'subject',
'received_at', 'attachment_count', 'status', 'inbound_document_id', 'error')
.orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize);
const items = await base.clone().orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize);
res.json({ items, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } });
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch received emails');
}
});
// Single received email WITH its captured (server-sanitized) body — Messages
// reading pane. body_html was already sanitized on ingest; the viewer renders
// it in a script-less sandboxed iframe as well.
router.get('/received/:id', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
const row = await db('received_emails').where({ id }).first();
if (!row) return res.status(404).json({ error: 'Email not found' });
res.json(row);
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch email');
}
});
// Move an email between mailbox states: Archive / Delete (soft) or Restore
// (back to active). kind = 'queue' | 'received'. Delete is a soft move to the
// trash; the row is only removed for good by the DELETE handler below.
router.post('/item/:kind/:id/state', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null;
if (!table) return res.status(400).json({ error: 'Invalid kind' });
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
const state = String(req.body?.state || '');
if (!['active', 'archived', 'deleted'].includes(state)) return res.status(400).json({ error: 'Invalid state' });
const n = await db(table).where({ id }).update({ mailbox_state: state });
if (!n) return res.status(404).json({ error: 'Not found' });
res.json({ ok: true });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update email');
}
});
// Permanently delete an email row — only offered from the Deleted folder.
router.delete('/item/:kind/:id', adminAuth, messagingGate, requirePermission('email.edit'), async (req, res) => {
try {
const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null;
if (!table) return res.status(400).json({ error: 'Invalid kind' });
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
await db(table).where({ id }).del();
res.json({ ok: true });
} catch (error) {
errorResponse(res, error, 500, 'Failed to delete email');
}
});
// Additional inbound mailboxes (beyond the primary accounting IMAP in
// email_configs) — e.g. the customer hello@ box. Passwords are masked out.
router.get('/accounts', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const rows = await db('mail_accounts').orderBy('id');
res.json({ items: rows.map((a) => ({
...a,
imap_pass: a.imap_pass ? '********' : '',
smtp_pass: a.smtp_pass ? '********' : '',
})) });
} catch (error) {
errorResponse(res, error, 500, 'Failed to load mail accounts');
}
});
// Resolved sender/mailbox addresses for the Messages UI — so the sidebar shows
// the REAL configured addresses instead of hardcoded placeholders. Accounting =
// the primary IMAP login (rechnungen@); customers = the hello@ mailbox; the
// automated stream sends from the global SMTP from-address.
router.get('/identities', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const cfg = await db('email_configs').first();
let customers = null;
try {
const cust = await db('mail_accounts').where({ account_key: 'customers' }).first();
customers = cust?.imap_user || cust?.from_email || null;
} catch (_) { customers = null; }
res.json({
automated: cfg?.from_email || null,
accounting: cfg?.imap_user || null,
customers,
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to load mail identities');
}
});
// Upsert a mailbox by account_key. A masked password ('********') keeps the
// stored value so the admin never has to re-type it.
router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit'), async (req, res) => {
try {
const b = req.body || {};
if (!b.account_key) return res.status(400).json({ error: 'account_key is required' });
// SSRF guard — mirror /config + /incoming-config: neither the IMAP nor the
// SMTP host may point at a private/internal address.
const { isPrivateIP } = require('../utils/networkValidation');
if (b.imap_host && isPrivateIP(b.imap_host)) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
if (b.smtp_host && isPrivateIP(b.smtp_host)) {
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
}
const patch = {
label: b.label || null,
imap_host: b.imap_host || null,
imap_port: b.imap_port ? parseInt(b.imap_port, 10) : 993,
imap_secure: b.imap_secure !== false,
imap_user: b.imap_user || null,
imap_folder: b.imap_folder || 'INBOX',
// Outgoing (SMTP) identity — replies from this mailbox send from here.
smtp_host: b.smtp_host || null,
smtp_port: b.smtp_port ? parseInt(b.smtp_port, 10) : 587,
smtp_secure: b.smtp_secure === true,
smtp_user: b.smtp_user || null,
from_email: b.from_email || null,
from_name: b.from_name || null,
enabled: !!b.enabled,
updated_at: new Date(),
};
if (b.imap_pass && b.imap_pass !== '********') patch.imap_pass = b.imap_pass;
if (b.smtp_pass && b.smtp_pass !== '********') patch.smtp_pass = b.smtp_pass;
const existing = await db('mail_accounts').where({ account_key: b.account_key }).first();
if (existing) {
await db('mail_accounts').where({ account_key: b.account_key }).update(patch);
} else {
await db('mail_accounts').insert({
account_key: b.account_key,
imap_pass: (b.imap_pass && b.imap_pass !== '********') ? b.imap_pass : '',
smtp_pass: (b.smtp_pass && b.smtp_pass !== '********') ? b.smtp_pass : '',
created_at: new Date(),
...patch,
});
}
res.json({ ok: true });
} catch (error) {
errorResponse(res, error, 500, 'Failed to save mail account');
}
});
// Test an inbound mailbox's IMAP connection (before or after saving). Resolves
// a masked/blank password from the stored row for the given account_key.
router.post('/accounts/test', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const b = req.body || {};
const { isPrivateIP } = require('../utils/networkValidation');
if (b.imap_host && isPrivateIP(b.imap_host)) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
let pass = b.imap_pass;
if ((!pass || pass === '********') && b.account_key) {
const stored = await db('mail_accounts').where({ account_key: b.account_key }).first();
pass = stored?.imap_pass || '';
}
const emailIntakeService = require('../services/emailIntakeService');
const result = await emailIntakeService.testConnection({
host: b.imap_host, port: b.imap_port, secure: b.imap_secure,
user: b.imap_user, pass, folder: b.imap_folder || 'INBOX',
});
res.json(result);
} catch (error) {
res.status(422).json({ ok: false, error: `Mailbox test failed (${error.message}).` });
}
});
// Test email configuration
router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => {
try {
@@ -622,8 +438,6 @@ router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (r
router.get('/queue', adminAuth, requirePermission('email.view'), [
query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']),
query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
query('origin').optional({ values: 'falsy' }).isIn(['system', 'manual']),
query('state').optional({ values: 'falsy' }).isIn(['active', 'archived', 'deleted']),
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
query('from').optional({ values: 'falsy' }).isISO8601(),
query('to').optional({ values: 'falsy' }).isISO8601(),
@@ -642,13 +456,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
const applyFilters = (qb) => {
if (req.query.status) qb.where('email_queue.status', req.query.status);
if (req.query.emailType) qb.where('email_queue.email_type', req.query.emailType);
// 'system' includes legacy rows (origin was NULL before migration 155).
if (req.query.origin === 'manual') qb.where('email_queue.origin', 'manual');
else if (req.query.origin === 'system') qb.where((b) => b.where('email_queue.origin', 'system').orWhereNull('email_queue.origin'));
// mailbox_state: default active (+ legacy NULL); Archived/Deleted folders pass it explicitly.
const st = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active';
if (st === 'active') qb.where((b) => b.where('email_queue.mailbox_state', 'active').orWhereNull('email_queue.mailbox_state'));
else qb.where('email_queue.mailbox_state', st);
if (req.query.from) qb.where('email_queue.created_at', '>=', new Date(req.query.from));
if (req.query.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to));
if (req.query.q) {
@@ -677,7 +484,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
'email_queue.sent_at',
'email_queue.error_message',
'email_queue.retry_count',
'email_queue.origin',
'email_queue.event_id',
'events.event_name as event_name',
'events.slug as event_slug'
@@ -697,7 +503,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
sentAt: r.sent_at,
errorMessage: r.error_message,
retryCount: r.retry_count,
origin: r.origin || 'system',
eventId: r.event_id,
eventName: r.event_name || null,
eventSlug: r.event_slug || null,
@@ -713,111 +518,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
}
});
// Single queued/sent email WITH its rendered body — powers the Messages
// reading pane. `rendered_html` is the exact HTML that was sent (migration
// 119); rows sent before that migration have none. Attachment disk paths in
// `email_data` are never exposed — only the filenames, so the pane can list
// attachments without leaking storage paths (same PII posture as the list).
router.get('/queue/:id', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
const row = await db('email_queue')
.leftJoin('events', 'events.id', 'email_queue.event_id')
.select('email_queue.*', 'events.event_name as event_name', 'events.slug as event_slug')
.where('email_queue.id', id)
.first();
if (!row) return res.status(404).json({ error: 'Email not found' });
let cc = null;
let attachments = [];
try {
const data = row.email_data ? JSON.parse(row.email_data) : {};
if (data.cc) cc = Array.isArray(data.cc) ? data.cc.join(', ') : String(data.cc);
if (Array.isArray(data.attachments)) {
attachments = data.attachments
.filter((a) => a && a.filename)
.map((a) => ({ filename: a.filename, contentType: a.contentType || null }));
}
} catch (_) { /* malformed email_data → no cc/attachments, still return the body */ }
res.json({
id: row.id,
recipientEmail: row.recipient_email,
emailType: row.email_type,
status: row.status,
createdAt: row.created_at,
scheduledAt: row.scheduled_at,
sentAt: row.sent_at,
errorMessage: row.error_message,
retryCount: row.retry_count,
eventId: row.event_id,
eventName: row.event_name || null,
eventSlug: row.event_slug || null,
renderedHtml: row.rendered_html || null,
cc,
attachments,
});
} catch (error) {
logger.error('Get email queue item error:', error);
res.status(500).json({ error: 'Failed to load email', details: error.message });
}
});
// Send a human-composed email from the Messages composer. The admin already
// edited the body (reply or document message), so it is sent as-is — no
// template render — after a sanitize pass. Recorded in email_queue as a
// 'manual' send so it surfaces under Customers > Sent.
router.post('/send', adminAuth, messagingGate, requirePermission('email.send'), async (req, res) => {
try {
const b = req.body || {};
const to = String(b.to || '').trim();
const subject = String(b.subject || '').trim();
if (!to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) {
return res.status(400).json({ error: 'A valid recipient email is required.' });
}
if (!subject) return res.status(400).json({ error: 'A subject is required.' });
const sanitizeHtml = require('sanitize-html');
// Match the stricter inbound sanitizeBody allowlist: no <style> tag, no
// data: scheme — inline style/class attributes are enough for composed mail.
const html = sanitizeHtml(String(b.html || ''), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'width', 'height'],
'*': ['style', 'class'],
},
allowedSchemes: ['http', 'https', 'mailto', 'cid'],
});
const cc = b.cc ? String(b.cc).trim() : null;
const accountKey = b.accountKey ? String(b.accountKey) : undefined;
const emailProcessor = require('../services/emailProcessor');
const result = await emailProcessor.sendRawEmail({ to, cc, subject, html, accountKey });
await db('email_queue').insert({
recipient_email: to,
email_type: 'manual_message',
email_data: JSON.stringify({
subject,
cc: cc || undefined,
replyToReceivedId: b.replyToReceivedId || undefined,
messageId: result.messageId,
}),
status: 'sent',
origin: 'manual',
rendered_html: html,
created_at: new Date(),
sent_at: new Date(),
});
res.json({ ok: true });
} catch (error) {
logger.error('Manual send error:', error);
res.status(500).json({ error: 'Failed to send message', details: error.message });
}
});
// Helper: parse variables JSON safely
function parseVariables(template) {
try {
+2 -2
View File
@@ -178,7 +178,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
if (error.code === 'NOT_FOUND') {
return res.status(404).json({ error: error.message });
}
if (error.code === 'DUPLICATE_SLUG_PREFIX' || error.code === 'LAST_ACTIVE') {
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
return res.status(400).json({ error: error.message });
}
@@ -216,7 +216,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
if (error.code === 'NOT_FOUND') {
return res.status(404).json({ error: error.message });
}
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE' || error.code === 'LAST_TYPE') {
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') {
return res.status(400).json({ error: error.message });
}
+4 -53
View File
@@ -94,7 +94,7 @@ module.exports = (router) => {
body('allow_presigned_download').optional().isBoolean(),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
@@ -342,10 +342,8 @@ module.exports = (router) => {
// hero_logo_visible: store NULL ("inherit") unless the admin explicitly
// set it, so the global branding_logo_display_hero toggle keeps
// controlling this gallery afterwards (#756). Only an explicit per-event
// choice overrides the global. `!= null` treats an explicit null the same
// as omitted (both → inherit); otherwise formatBoolean(null) would coerce
// to 0/false on SQLite instead of NULL (the PUT handler already does this).
const effectiveHeroLogoVisible = req.body.hero_logo_visible != null
// choice overrides the global.
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined
? formatBoolean(hero_logo_visible)
: null;
// NULL = inherit the global branding_logo_size (#756), resolved at read
@@ -1226,7 +1224,7 @@ module.exports = (router) => {
}),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
@@ -1598,51 +1596,4 @@ module.exports = (router) => {
}
});
// Extend a gallery's expiration. Migrated from the legacy /api/events router
// (removed — GHSA-4j34-x562-5vfq), now on the canonical mount with the same
// permission + ownership guards as every other gallery mutation, so a
// non-owning editor/viewer can no longer touch a gallery they don't own.
router.post('/:id/extend', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('days').isInt({ min: 1, max: 365 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { days } = req.body;
let eventQuery = db('events').where('id', id);
// Editor role can only touch their own events (defence in depth alongside
// requireEventOwnership).
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const newExpiration = new Date(event.expires_at);
newExpiration.setDate(newExpiration.getDate() + days);
await db('events').where('id', id).update({
expires_at: newExpiration,
is_active: formatBoolean(true) // reactivate if it had expired
});
await logActivity('event_expiration_extended',
{ eventName: event.event_name, days },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ expires_at: newExpiration });
} catch (error) {
errorResponse(res, error, 500, 'Failed to extend expiration');
}
});
};
@@ -307,9 +307,6 @@ async function deleteEventCascade(eventId, adminContext) {
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
// Allowed per-slide color filters.
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
// Allowed slideshow play orders (#202). 'chronological' = upload order,
// 'random' = client-side shuffle.
const SLIDESHOW_ORDERS = ['chronological', 'random'];
module.exports = {
validateHeroImageAnchor,
getStoragePath,
@@ -324,7 +321,6 @@ module.exports = {
mapEventForApi,
hasCustomerContactColumns,
deleteEventCascade,
SLIDESHOW_ORDERS,
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
};
+3 -24
View File
@@ -13,7 +13,7 @@ const { parseBooleanInput } = require('../../utils/parsers');
const { requireEventOwnership } = require('../../middleware/ownership');
const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag');
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS, SLIDESHOW_ORDERS } = require('./helpers');
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
// The watermark LOOK (source/position/opacity/style/size) is global-only
// (app_settings, Settings → Slideshow); events only carry the show_watermark
@@ -105,9 +105,7 @@ module.exports = (router) => {
body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS),
body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }),
body('show_watermark').optional({ nullable: true }),
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS),
body('show_order').optional().isIn(SLIDESHOW_ORDERS),
body('show_category_id').optional({ nullable: true }).isInt({ min: 1 })
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS)
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -132,23 +130,6 @@ module.exports = (router) => {
: formatBoolean(parseBooleanInput(req.body.show_watermark, false));
}
if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter;
if (req.body.show_order !== undefined) updates.show_order = req.body.show_order;
// Category filter (#202). null clears it (all photos). A non-null id must
// belong to this event or be a global category — otherwise ignore it so a
// stale/foreign id can't leak another event's category selection.
if (req.body.show_category_id !== undefined) {
if (req.body.show_category_id === null) {
updates.show_category_id = null;
} else {
const catId = parseInt(req.body.show_category_id, 10);
const cat = await db('photo_categories')
.where({ id: catId })
.where(function () { this.where('event_id', event.id).orWhere('is_global', formatBoolean(true)); })
.first();
if (!cat) return res.status(400).json({ error: 'Category does not belong to this event' });
updates.show_category_id = catId;
}
}
// Knex throws on an empty update; only write if something changed.
if (Object.keys(updates).length > 0) {
@@ -160,9 +141,7 @@ module.exports = (router) => {
show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade',
show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800,
show_watermark: updates.show_watermark ?? event.show_watermark ?? null,
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none',
show_order: updates.show_order ?? event.show_order ?? 'chronological',
show_category_id: 'show_category_id' in updates ? updates.show_category_id : (event.show_category_id ?? null)
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none'
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to update slideshow settings');
+5 -194
View File
@@ -25,28 +25,12 @@ const { resetSecurityConfigCache } = require('../utils/authSecurity');
const { errorResponse } = require('../utils/routeHelpers');
const logger = require('../utils/logger');
const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD, clearMaxFileSizeCache, MAX_ALLOWED_FILE_SIZE_MB } = require('../services/uploadSettings');
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Reserved first-run bootstrap keys — never writable through the generic
// settings upserts in this file: setup_wizard_completed is a one-way marker
// (#800; writing false would reopen system-event-type deletion) and
// setup_token is the first-run bootstrap secret. Every handler that loops
// arbitrary request keys into app_settings must strip these first.
// oidc_client_secret is reserved too: it is AES-encrypted at rest and only
// writable through PUT /sso below — a generic upsert would store plaintext
// and break decryption (#798).
const RESERVED_SETTING_KEYS = ['setup_wizard_completed', 'setup_token', 'oidc_client_secret'];
const stripReservedSettingKeys = (settings) => {
for (const key of RESERVED_SETTING_KEYS) {
delete settings[key];
}
return settings;
};
// Configure multer for logo uploads
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
@@ -159,28 +143,10 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
}
});
// Reserved bootstrap/credential keys are NEVER readable through the
// generic settings reads — oidc_client_secret (#798) is stored encrypted
// with setting_type 'string' and would otherwise leak its ciphertext to
// any settings.view holder; setup_token is the first-run bootstrap secret.
for (const key of RESERVED_SETTING_KEYS) {
delete settingsObject[key];
}
// Mask sensitive secrets before sending to client
if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••';
}
// Backup credentials — the S3 secret key and the rsync SSH PRIVATE KEY
// were returned in plaintext to any settings.view holder. Same masking
// pattern as the recaptcha/umami/rybbit keys; the dedicated
// /admin/backup/config endpoints handle the edit round-trip.
if (settingsObject.backup_s3_secret_key) {
settingsObject.backup_s3_secret_key = '••••••••';
}
if (settingsObject.backup_rsync_ssh_key) {
settingsObject.backup_rsync_ssh_key = '••••••••';
}
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
// outbound calls to the operator's Umami instance for the device
// breakdown. Masked on GET, same pattern as the recaptcha secret.
@@ -397,124 +363,6 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
});
// Get settings by type
// ──────────────────────────────────────────────────────────────────────────
// OIDC SSO settings (#798). Dedicated endpoints — NOT the generic upsert —
// because the client secret must be encrypted at rest and never echoed back.
// ──────────────────────────────────────────────────────────────────────────
// Read the SSO config. The secret is redacted to a set/unset flag; the
// computed redirect URI is included for copy-paste into the IdP client.
router.get('/sso', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const oidcService = require('../services/oidcService');
const cfg = await oidcService.getOidcConfig();
// No public base URL configured → surface an empty redirect_uri rather
// than failing the whole settings read; the login route refuses to start
// the flow in that state anyway (OIDC_BAD_CONFIG).
const redirectUri = await oidcService.getRedirectUri().catch(() => '');
res.json({
oidc_enabled: cfg.enabled,
oidc_issuer_url: cfg.issuerUrl || '',
oidc_client_id: cfg.clientId || '',
oidc_client_secret_set: Boolean(cfg.clientSecret),
oidc_autoprovision: cfg.autoprovision,
oidc_default_role: cfg.defaultRole,
oidc_button_label: cfg.buttonLabel || '',
oidc_scopes: cfg.scopes,
redirect_uri: redirectUri,
});
} catch (error) {
logger.error('Failed to read SSO settings', { error: error.message });
res.status(500).json({ error: 'Failed to read SSO settings' });
}
});
router.put('/sso', adminAuth, requirePermission('settings.edit'), [
body('oidc_enabled').optional().isBoolean(),
body('oidc_issuer_url').optional({ checkFalsy: true }).isURL({ protocols: ['http', 'https'], require_tld: false }),
body('oidc_client_id').optional().isString().trim(),
body('oidc_client_secret').optional().isString(),
body('oidc_autoprovision').optional().isBoolean(),
body('oidc_default_role').optional().isString().trim(),
body('oidc_button_label').optional().isString().trim().isLength({ max: 60 }),
body('oidc_scopes').optional().isString().trim(),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const oidcService = require('../services/oidcService');
// Validate the MERGED resulting state, not just the request: enabling
// requires a complete config, and a partial PUT must not be able to
// blank the issuer/client while a stored enabled=true keeps a login
// button alive that can only fail.
const current = await oidcService.getOidcConfig();
const effectiveEnabled = req.body.oidc_enabled ?? current.enabled;
if (effectiveEnabled === true) {
const issuer = req.body.oidc_issuer_url ?? current.issuerUrl;
const clientId = req.body.oidc_client_id ?? current.clientId;
const secretPresent = (typeof req.body.oidc_client_secret === 'string' && req.body.oidc_client_secret.length > 0)
|| Boolean(current.clientSecret);
if (!issuer || !clientId || !secretPresent) {
return res.status(400).json({ error: 'Issuer URL, client ID and client secret must be configured while SSO is enabled — disable SSO first to clear them' });
}
// The redirect URI must be derivable too, or the login button leads
// straight to an error (needs API_URL / FRONTEND_URL / general_site_url).
try {
await oidcService.getRedirectUri();
} catch (err) {
return res.status(400).json({ error: err.message });
}
}
// Default role must exist — a typo here would brick JIT provisioning.
if (req.body.oidc_default_role !== undefined) {
const role = await db('roles').where('name', req.body.oidc_default_role).first();
if (!role) {
return res.status(400).json({ error: `Unknown role: ${req.body.oidc_default_role}` });
}
}
await oidcService.saveOidcSettings(req.body);
await logActivity('sso_settings_updated',
{ changes: Object.keys(req.body).filter((k) => k !== 'oidc_client_secret') },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'SSO settings saved' });
} catch (error) {
logger.error('Failed to save SSO settings', { error: error.message });
res.status(500).json({ error: 'Failed to save SSO settings' });
}
});
// Server-side discovery probe: confirms the issuer is reachable and speaks
// OIDC before the admin flips the enable toggle. Uses the SAVED config.
router.post('/sso/test', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const oidcService = require('../services/oidcService');
const cfg = await oidcService.getOidcConfig();
if (!oidcService.isConfigured(cfg)) {
return res.status(400).json({ ok: false, error: 'Issuer URL, client ID and client secret must be saved first' });
}
oidcService.invalidateDiscoveryCache();
const { issuerMetadata } = await oidcService.getClient(cfg);
res.json({
ok: true,
issuer: issuerMetadata.issuer,
authorization_endpoint: issuerMetadata.authorization_endpoint,
token_endpoint: issuerMetadata.token_endpoint,
});
} catch (error) {
logger.warn('SSO discovery test failed', { error: error.message });
res.status(400).json({ ok: false, error: `Discovery failed: ${error.message}` });
}
});
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { type } = req.params;
@@ -545,28 +393,10 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
}
});
// Reserved bootstrap/credential keys are NEVER readable through the
// generic settings reads — oidc_client_secret (#798) is stored encrypted
// with setting_type 'string' and would otherwise leak its ciphertext to
// any settings.view holder; setup_token is the first-run bootstrap secret.
for (const key of RESERVED_SETTING_KEYS) {
delete settingsObject[key];
}
// Mask sensitive secrets before sending to client
if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••';
}
// Backup credentials — the S3 secret key and the rsync SSH PRIVATE KEY
// were returned in plaintext to any settings.view holder. Same masking
// pattern as the recaptcha/umami/rybbit keys; the dedicated
// /admin/backup/config endpoints handle the edit round-trip.
if (settingsObject.backup_s3_secret_key) {
settingsObject.backup_s3_secret_key = '••••••••';
}
if (settingsObject.backup_rsync_ssh_key) {
settingsObject.backup_rsync_ssh_key = '••••••••';
}
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
// outbound calls to the operator's Umami instance for the device
// breakdown. Masked on GET, same pattern as the recaptcha secret.
@@ -1076,7 +906,7 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req,
// Update general settings
router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = stripReservedSettingKeys({ ...req.body });
const settings = { ...req.body };
let uploadLimitTouched = false;
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
@@ -1095,24 +925,6 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
settings.general_max_files_per_upload = normalizedValue;
}
// Per-file size limit (MB). Validate/clamp on save, mirroring the count
// above, so an out-of-range value can't be persisted — otherwise the public
// endpoint would advertise the raw value while getMaxFileSizeMb() normalizes
// it, and the guest UI would reject files the backend actually accepts.
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_file_size_mb')) {
uploadLimitTouched = true;
const rawValue = Number(settings.general_max_file_size_mb);
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILE_SIZE_MB) {
return res.status(400).json({
error: `general_max_file_size_mb must be an integer between 1 and ${MAX_ALLOWED_FILE_SIZE_MB}`
});
}
settings.general_max_file_size_mb = normalizedValue;
}
if (publicSiteKeysTouched) {
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
@@ -1169,7 +981,6 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
}
if (uploadLimitTouched) {
clearMaxFilesPerUploadCache();
clearMaxFileSizeCache();
}
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache();
@@ -1206,7 +1017,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
// Update security settings
router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = stripReservedSettingKeys({ ...req.body });
const settings = req.body;
// Update or insert each setting
for (const [key, value] of Object.entries(settings)) {
@@ -1244,7 +1055,7 @@ router.put('/security', adminAuth, requirePermission('settings.edit'), async (re
// Update analytics settings
router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = stripReservedSettingKeys({ ...req.body });
const settings = req.body;
// Validate the provider switch (#663 Phase 1). Reject unknown values
// so the dashboard route's factory doesn't have to defensively guard.
@@ -1299,7 +1110,7 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
// Update SEO settings
router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = stripReservedSettingKeys({ ...req.body });
const settings = req.body;
// Validate seo_blocked_ai_agents is an array of strings
if (settings.seo_blocked_ai_agents !== undefined) {
-13
View File
@@ -254,19 +254,6 @@ router.patch('/:id/enabled', requirePermission('workflows.manage'), async (req,
// enabled state on the next SEED_VERSION bump (review nit #1).
if (await hasColumnCached('workflows', 'admin_toggled_at')) patch.admin_toggled_at = db.fn.now();
await db('workflows').where({ id }).update(patch);
// Turning dunning ON enrolls existing open/unpaid invoices (anchored to
// their due date) so it starts chasing current debtors, not only invoices
// sent after enabling (#750). Scoped to this flow's id so the backfill only
// enrolls dunning, not any custom invoice.sent flow. Best-effort — never
// fail the toggle over it.
if (enabled && wf.builtin_key === 'invoice_dunning') {
try {
const n = await require('../services/workflows').backfillDunningRuns(id);
require('../utils/logger').info('[workflow] dunning enabled — enrolled existing invoices', { enrolled: n });
} catch (e) {
require('../utils/logger').warn('[workflow] dunning backfill failed', { error: e.message });
}
}
res.json({ id, enabled });
} catch (e) { next(e); }
});
+20 -194
View File
@@ -42,20 +42,9 @@ const router = express.Router();
* both produce an identical session. `lockoutKey` is the identifier the user
* typed (username or email) so success/failure tracking stays in one bucket.
*/
async function establishAdminSession(res, admin, ipAddress, userAgent, lockoutKey) {
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
// A normal login means the first-run wizard is over — the wizard never hits
// this route (setup sets its cookie directly). Close the system-event-type
// deletion window durably even when the wizard was abandoned mid-way (#800).
// Best-effort: a failure here must never block a login.
try {
const setupService = require('../services/setupService');
if (!(await setupService.isSetupWizardCompleted())) {
await setupService.markSetupWizardCompleted();
}
} catch (_) { /* best-effort */ }
await db('admin_users').where('id', admin.id).update({
last_login: new Date(),
last_login_ip: ipAddress
@@ -75,21 +64,18 @@ async function establishAdminSession(res, admin, ipAddress, userAgent, lockoutKe
setAdminAuthCookie(res, token);
return {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
};
}
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
const user = await establishAdminSession(res, admin, ipAddress, userAgent, lockoutKey);
return res.json({ user });
return res.json({
user: {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
}
});
}
// Admin login with enhanced security
@@ -143,11 +129,8 @@ router.post('/admin/login', [
)
.first();
// Use generic error to prevent user enumeration. OIDC-owned accounts
// (#798) never authenticate locally — their random hash is unusable by
// design, and the explicit check keeps that true even if a hash ever
// gets set through some other path.
if (!admin || admin.auth_provider === 'oidc' || !await bcrypt.compare(password, admin.password_hash)) {
// Use generic error to prevent user enumeration
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
@@ -560,18 +543,6 @@ router.post('/gallery/share-login', [
return res.status(401).json({ error: 'Invalid or expired share link' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
// The share link only proves the holder was given the link — it is NOT the
// gallery password. For a password-protected gallery, minting a full
// `type:'gallery'` token here would let anyone with the share URL bypass
// the password entirely (GHSA-9hmx-68vc-qpqw). Signal that a password is
// still required and return WITHOUT a token/cookie; the client then goes
// through POST /gallery/verify, which does check the password.
if (requiresPassword) {
return res.json({ requires_password: true });
}
const jwtToken = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
@@ -586,6 +557,8 @@ router.post('/gallery/share-login', [
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
token: jwtToken,
event: {
@@ -667,22 +640,12 @@ router.get('/session', async (req, res) => {
// or the gallery event was archived/deleted. Mirror those checks
// here so the session endpoint is always at least as strict as
// what the protected endpoints will enforce next.
// Full user payload for admin sessions — the SSO callback establishes
// the session via redirect (no JSON response the SPA could store), so
// session restoration must be able to hydrate the user object (#798).
let adminUser = null;
if (decoded.type === 'admin') {
let admin = null;
try {
admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
.select(
'admin_users.id', 'admin_users.username', 'admin_users.email',
'admin_users.password_changed_at', 'admin_users.must_change_password',
'roles.name as role_name', 'roles.display_name as role_display_name'
)
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'password_changed_at')
.first();
} catch (lookupErr) {
// admin_users table not present (test fixture, fresh DB) — fall
@@ -721,19 +684,6 @@ router.get('/session', async (req, res) => {
// Helper lookup failed (test stub may not export it) — fall through
// and trust the token. Real deployments always have the middleware.
}
if (admin) {
adminUser = {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
};
}
} else if (decoded.type === 'gallery') {
try {
const event = await db('events')
@@ -765,11 +715,7 @@ router.get('/session', async (req, res) => {
expiresIn: Math.floor(remainingTime),
user: decoded.username || decoded.eventSlug,
eventSlug: decoded.eventSlug,
adminUsername: decoded.username,
// Full admin payload (or null) — lets the SPA hydrate its user
// state after a redirect-established session (SSO, #798) where no
// login JSON response ever reached it.
adminUser
adminUsername: decoded.username
});
} catch (err) {
res.json({
@@ -891,124 +837,4 @@ router.post('/password-strength', [
}
});
// ──────────────────────────────────────────────────────────────────────────
// OIDC SSO for admins (#798, phase 1)
//
// Authorization-code + PKCE. The per-request secrets (state, nonce, PKCE
// verifier) cross the IdP redirect in a short-lived signed cookie —
// httpOnly, SameSite=Lax (the IdP returns via a top-level GET, which Lax
// permits), scoped to this route prefix. Token/claim validation happens in
// oidcService via openid-client; a successful callback reuses the exact
// session establishment of the local login, so an SSO session is
// indistinguishable from a password one downstream. MFA is the IdP's job on
// this path — local TOTP guards the password flow SSO users don't take.
// ──────────────────────────────────────────────────────────────────────────
const OIDC_STATE_COOKIE = 'oidc_state';
function oidcStateCookieOptions(req) {
return {
httpOnly: true,
secure: Boolean(req.secure),
sameSite: 'Lax',
path: '/api/auth/admin/sso',
maxAge: 10 * 60 * 1000,
};
}
// Kick off the IdP round-trip. 404 when SSO is off so the endpoint is
// invisible on non-SSO installs.
router.get('/admin/sso/login', async (req, res) => {
const oidcService = require('../services/oidcService');
try {
const { url, state, nonce, codeVerifier } = await oidcService.buildAuthorizationRequest();
const stash = jwt.sign(
{ type: 'oidc_state', s: state, n: nonce, cv: codeVerifier },
process.env.JWT_SECRET,
{ expiresIn: '10m', issuer: 'picpeak-auth' }
);
res.cookie(OIDC_STATE_COOKIE, stash, oidcStateCookieOptions(req));
return res.redirect(url);
} catch (error) {
if (error.code === 'OIDC_NOT_CONFIGURED') {
return res.status(404).json({ error: 'SSO is not enabled' });
}
logger.error('OIDC login initiation failed', { error: error.message });
// Absolute like the callback's redirects: in split-origin deployments a
// relative path would resolve on the API origin and 404.
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const frontendBase = (await getFrontendBaseUrl().catch(() => '')) || '';
return res.redirect(`${frontendBase}/admin/login?sso_error=config`);
}
});
// IdP redirect target. Every failure lands back on the login page with a
// translatable error key — never a raw error, never a broken JSON screen.
// Final redirects are ABSOLUTE to the frontend base: in split-origin
// deployments (absolute VITE_API_URL / API_URL) this callback runs on the
// API origin, where a relative /admin/login would 404.
router.get('/admin/sso/callback', async (req, res) => {
const oidcService = require('../services/oidcService');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const frontendBase = (await getFrontendBaseUrl().catch(() => '')) || '';
const fail = (key) => res.redirect(`${frontendBase}/admin/login?sso_error=${key}`);
const stashCookie = req.cookies?.[OIDC_STATE_COOKIE];
res.clearCookie(OIDC_STATE_COOKIE, { ...oidcStateCookieOptions(req), maxAge: undefined });
if (!stashCookie) return fail('state');
let stash;
try {
stash = jwt.verify(stashCookie, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
if (stash.type !== 'oidc_state') throw new Error('wrong token type');
} catch (_) {
return fail('state');
}
try {
// Reconstruct the exact redirect URI + the IdP's query for validation.
const callbackUrl = new URL(await oidcService.getRedirectUri());
callbackUrl.search = req.originalUrl.split('?')[1] || '';
const claims = await oidcService.handleCallback(callbackUrl.href, {
state: stash.s,
nonce: stash.n,
codeVerifier: stash.cv,
});
const resolved = await oidcService.resolveAdminFromClaims(claims);
// Reload with role info so the session payload matches a local login.
const admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', resolved.id)
.select('admin_users.*', 'roles.name as role_name', 'roles.display_name as role_display_name')
.first();
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
await establishAdminSession(res, admin, ipAddress, userAgent, admin.username);
await logActivity('admin_sso_login', { provider: 'oidc' }, null, {
type: 'admin', id: admin.id, name: admin.username,
});
return res.redirect(`${frontendBase}/admin/dashboard`);
} catch (error) {
const codeMap = {
OIDC_NOT_CONFIGURED: 'config',
OIDC_BAD_CONFIG: 'config',
OIDC_INACTIVE: 'inactive',
OIDC_NOT_PROVISIONED: 'not_provisioned',
OIDC_NO_EMAIL: 'no_email',
OIDC_BAD_CLAIMS: 'idp',
};
const key = codeMap[error.code] || 'idp';
// 'idp' covers token-exchange/validation failures from openid-client
// (bad state/nonce, signature, issuer mismatch, IdP-side errors).
logger.warn('OIDC callback failed', { error: error.message, key });
return fail(key);
}
});
module.exports = router;
+443
View File
@@ -0,0 +1,443 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { slugify } = require('../utils/slug');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { adminAuth } = require('../middleware/auth');
const fs = require('fs').promises;
const path = require('path');
const router = express.Router();
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
const logger = require('../utils/logger');
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
// Whether the global "phone field" toggle (#322) is enabled. Same shape as
// the helper in adminEvents.js — kept local so this route doesn't import
// from a sibling route file.
const isPhoneFieldEnabled = async () => {
try {
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
if (!row) return false;
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return value === true;
} catch {
return false;
}
};
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
...rest
} = event;
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null
};
};
let customerColumnCache = null;
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
return false;
}
};
// Create new event
router.post('/', adminAuth, [
body('event_type').notEmpty().trim().custom(async (value) => {
const isValid = await eventTypeService.isValidEventType(value);
if (!isValid) {
throw new Error('Invalid event type');
}
return true;
}),
body('event_name').notEmpty(),
body('event_date').isDate(),
body('customer_name').notEmpty().trim(),
body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
body('customer_phone').optional({ nullable: true, checkFalsy: true })
.isString().trim()
.isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'),
body('admin_email').isEmail(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
const requirePassword = parseBooleanInput(req.body.require_password, true);
if (!requirePassword) {
return true;
}
if (typeof value !== 'string' || value.trim().length < 6) {
throw new Error('Password must be at least 6 characters long');
}
return true;
}),
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
event_type,
event_name,
event_date,
admin_email,
password,
require_password: requirePasswordInput = true,
welcome_message,
color_theme,
expiration_days = 30
} = req.body;
const customerEmail = getCustomerEmailFromPayload(req.body);
const customerName = getCustomerNameFromPayload(req.body);
if (!customerName || !customerEmail) {
return res.status(400).json({ error: 'customer_name and customer_email are required' });
}
const customerColumnsAvailable = await hasCustomerContactColumns();
const phoneEnabled = await isPhoneFieldEnabled();
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
const requirePassword = parseBooleanInput(requirePasswordInput, true);
if (requirePassword) {
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
}
// Generate unique slug — slugify() handles accents (see #525).
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Generate share link variants (auto-detects short URL preference)
const shareToken = crypto.randomBytes(16).toString('hex');
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password (or placeholder when not required)
const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date)
const expires_at = new Date(event_date);
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Create folder structure
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const eventPath = path.join(storagePath, 'events/active', slug);
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Insert into database
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
event_date,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName,
host_email: customerEmail,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at,
require_password: formatBoolean(requirePassword)
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// Queue creation email
const { queueEmail } = require('../services/emailProcessor');
await queueEmail(eventId, customerEmail, 'gallery_created', {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName,
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
});
// WhatsApp gallery_ready notification (#647 follow-up). Mirrors the
// adminEvents.js path: fires when the customer supplied a phone, the
// feature is enabled, and a config exists. Non-fatal — a queue failure
// must never block gallery creation.
if (customerPhone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
customer_name: customerName || '',
event_name,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : '',
expiry_date: expires_at ? expires_at.toISOString() : null,
language: null,
});
}
} catch (waError) {
logger.warn('Failed to queue WhatsApp notification on create', waError.message);
}
}
// Webhook lifecycle (#327). Legacy public endpoint — events go live
// immediately so created + published fire together. Payload uses the
// canonical event subject (#341) — every event.* webhook now includes
// customer contact + share_token.
try {
const webhookService = require('../services/webhookService');
const eventSubject = webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
});
await webhookService.fire('event.created', { event: eventSubject });
await webhookService.fire('event.published', { event: eventSubject });
} catch (e) { /* non-fatal */ }
res.json({
id: eventId,
slug,
share_link: shareUrl,
expires_at,
require_password: requirePassword,
customer_name: customerName,
customer_email: customerEmail
});
} catch (error) {
logger.error(error);
res.status(500).json({ error: 'Failed to create event' });
}
});
// Get all events (admin)
router.get('/', adminAuth, async (req, res) => {
try {
const { status = 'all' } = req.query;
let query = db('events').select('*');
if (status === 'active') {
query = query.where('is_active', formatBoolean(true));
} else if (status === 'archived') {
query = query.where('is_archived', formatBoolean(true));
}
const events = await query.orderBy('created_at', 'desc');
// Add photo counts
for (const event of events) {
const photoCount = await db('photos').where('event_id', event.id).count('id as count').first();
event.photo_count = photoCount.count;
}
res.json(events.map(mapEventForApi));
} catch (error) {
res.status(500).json({ error: 'Failed to fetch events' });
}
});
// Update event
router.put('/:id', adminAuth, [
body('customer_name').optional().trim().notEmpty(),
body('customer_email').optional().isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
body('require_password').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const updates = { ...req.body };
const customerColumnsAvailable = await hasCustomerContactColumns();
// Don't allow updating certain fields
delete updates.id;
delete updates.slug;
delete updates.created_at;
delete updates.password_confirmation;
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
const nextName = getCustomerNameFromPayload(updates);
if (nextName) {
if (customerColumnsAvailable) {
updates.customer_name = nextName;
} else {
delete updates.customer_name;
}
updates.host_name = nextName;
} else {
delete updates.customer_name;
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
const nextEmail = getCustomerEmailFromPayload(updates);
if (nextEmail) {
if (customerColumnsAvailable) {
updates.customer_email = nextEmail;
} else {
delete updates.customer_email;
}
updates.host_email = nextEmail;
} else {
delete updates.customer_email;
}
}
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
let requirePasswordUpdate;
if (hasRequirePasswordUpdate) {
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
updates.require_password = formatBoolean(requirePasswordUpdate);
}
let newPasswordPlain;
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
if (updates.password === undefined || updates.password === null || updates.password === '') {
delete updates.password;
} else {
newPasswordPlain = updates.password;
delete updates.password;
}
}
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const currentRequirePassword = parseBooleanInput(event.require_password, true);
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
}
if (newPasswordPlain) {
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
await db('events').where('id', id).update(updates);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to update event' });
}
});
// Delete event (mark as inactive)
router.delete('/:id', adminAuth, async (req, res) => {
try {
const { id } = req.params;
await db('events').where('id', id).update({ is_active: formatBoolean(false) });
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to delete event' });
}
});
// Extend expiration
router.post('/:id/extend', adminAuth, [
body('days').isInt({ min: 1, max: 365 })
], async (req, res) => {
try {
const { id } = req.params;
const { days } = req.body;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const newExpiration = new Date(event.expires_at);
newExpiration.setDate(newExpiration.getDate() + days);
await db('events').where('id', id).update({
expires_at: newExpiration,
is_active: formatBoolean(true) // Reactivate if expired
});
res.json({ expires_at: newExpiration });
} catch (error) {
res.status(500).json({ error: 'Failed to extend expiration' });
}
});
module.exports = router;
+11 -64
View File
@@ -25,7 +25,6 @@ const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
@@ -38,24 +37,6 @@ const {
} = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const { getStorage } = require('../services/storage');
// Formats whose ORIGINAL bytes a browser can't render in an <img> (HEIC/HEIF,
// camera RAW/DNG). For these the lightbox must be served the generated JPEG
// preview instead of `url` (the original) — otherwise it shows a broken image.
// So we force `preview_url` for them regardless of the lightbox_preview_enabled
// toggle. Detection is by MIME first, extension as a fallback (browsers report
// these MIMEs inconsistently). EXPERIMENTAL: whether a preview actually renders
// still depends on the backend being able to decode the source (HEVC-in-HEIC on
// the prod image; exiftool for DNG) — see #821.
const NON_DISPLAYABLE_ORIGINAL_EXT = new Set(['heic', 'heif', 'dng']);
const NON_DISPLAYABLE_ORIGINAL_MIME = new Set(['image/heic', 'image/heif', 'image/x-adobe-dng']);
function originalNeedsPreview(photo) {
const mime = (photo.mime_type || '').toLowerCase();
if (NON_DISPLAYABLE_ORIGINAL_MIME.has(mime)) return true;
const name = photo.original_filename || photo.filename || '';
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
return NON_DISPLAYABLE_ORIGINAL_EXT.has(ext);
}
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
// Read globals from app_settings (the real table) — settingsService.getSetting
// queries a non-existent `settings` table and throws.
@@ -262,8 +243,8 @@ router.get('/:slug/info', async (req, res) => {
// Photos a slideshow may display: published, finished, non-hidden. Mirrors the
// guest filter in GET /:slug/photos so the live count matches the rendered set.
function slideshowPhotosQuery(eventId, categoryId = null) {
const q = db('photos')
function slideshowPhotosQuery(eventId) {
return db('photos')
.where('photos.event_id', eventId)
.where(function() {
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
@@ -271,10 +252,6 @@ function slideshowPhotosQuery(eventId, categoryId = null) {
.where(function() {
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
});
// Category filter (#202) — keep the /session + /state count in sync with the
// photos the kiosk actually renders.
if (categoryId) q.where('photos.category_id', categoryId);
return q;
}
// Resolve an active slideshow by slug + token. Returns the event row, or null
@@ -347,9 +324,6 @@ async function slideshowSettings(event) {
transition: event.show_transition || 'crossfade',
transition_ms: event.show_transition_ms || 800,
colorfilter: event.show_colorfilter || 'none',
// Play order (#202): 'chronological' | 'random'. The client shuffles when
// 'random' so live-appended uploads keep working.
order: event.show_order || 'chronological',
fit: g.fit,
watermark,
};
@@ -382,7 +356,7 @@ router.get('/:slug/show/:token/session', handleAsync(async (req, res) => {
// here so the kiosk's image requests are authorized with zero extra wiring.
setGalleryAuthCookies(res, sessionToken, event.slug);
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count');
res.json({
token: sessionToken,
@@ -408,7 +382,7 @@ router.get('/:slug/show/:token/state', handleAsync(async (req, res) => {
throw new NotFoundError('Slideshow');
}
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count');
res.json({
...(await slideshowSettings(event)),
@@ -452,13 +426,6 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
});
}
// Live Slideshow category filter (#202). Enforced server-side so the kiosk
// viewer can't widen the set: when the event pins show_category_id, the
// slideshow only sees that category. NULL = all photos (unchanged).
if (req.accessLevel === 'slideshow' && req.event.show_category_id) {
photosQuery = photosQuery.where('photos.category_id', req.event.show_category_id);
}
// Apply sort option
if (sort === 'capture_date') {
// Sort by capture date, falling back to uploaded_at if capture date is null
@@ -606,12 +573,10 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// Fetch category details from photo_categories table
let categories = [];
if (usedCategoryIds.length > 0) {
// Resolved category order (#782): per-event override, else global
// default, else name — restricted to categories that have photos.
const categoryDetails = await getEventCategoriesOrdered(req.event.id, {
onlyIds: usedCategoryIds,
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads'],
});
const categoryDetails = await db('photo_categories')
.whereIn('id', usedCategoryIds)
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id', 'allow_downloads')
.orderBy('name', 'asc');
categories = categoryDetails.map(cat => ({
id: cat.id,
@@ -744,7 +709,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// installs that haven't opted in keep loading the original
// (current behaviour). Skipped for videos since they don't
// get a preview tier; lightbox will use the original .url.
preview_url: (lightboxPreviewEnabled || originalNeedsPreview(photo))
preview_url: lightboxPreviewEnabled
&& photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
@@ -1851,7 +1816,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
// Import multer and photo processing
const multer = require('multer');
const { getAllowedMimeTypes, getMaxFilesPerUpload, getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../services/uploadSettings');
const { getAllowedMimeTypes, getMaxFilesPerUpload } = require('../services/uploadSettings');
const { validateFileType } = require('../utils/fileSecurityUtils');
// Resolve allowed MIME types from settings
@@ -1877,22 +1842,10 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
maxFilesPerUpload = 500;
}
// Per-file size cap was hardcoded to 50MB here, so the admin's Settings →
// General → "Max File Size (MB)" value (general_max_file_size_mb) never
// applied to guest uploads — a guest could not upload a large video even
// when the admin allowed it (reported on #613 by mat1990dj). Resolve it from
// settings like the count above; fall back to the 50MB default on read error.
let maxFileSizeBytes;
try {
maxFileSizeBytes = await getMaxFileSizeBytes();
} catch {
maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024;
}
const upload = multer({
dest: tempUploadDir,
limits: {
fileSize: maxFileSizeBytes,
fileSize: 50 * 1024 * 1024, // 50MB per file (separate concern from #613)
files: maxFilesPerUpload
},
fileFilter: (req, file, cb) => {
@@ -1908,12 +1861,6 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
upload(req, res, async (err) => {
if (err) {
logger.error('Upload error:', err);
// Turn multer's generic "File too large" into an actionable message
// that names the configured limit.
if (err.code === 'LIMIT_FILE_SIZE') {
const limitMb = Math.floor(maxFileSizeBytes / (1024 * 1024));
return res.status(400).json({ error: `File too large. Maximum size is ${limitMb} MB per file.` });
}
return res.status(400).json({ error: err.message });
}
+1 -21
View File
@@ -27,17 +27,7 @@ router.get('/', async (req, res) => {
// backend route also enforces it via getMaxFilesPerUpload,
// but a client-side guard saves a 4MB+ round-trip when the
// limit is small.
'general_max_files_per_upload',
// Same rationale for the per-file size limit — the gallery upload
// component renders it in the requirements hint and guards
// client-side before posting an oversized file. Backend enforces
// via getMaxFileSizeBytes regardless.
'general_max_file_size_mb',
// #798 — the admin login page needs to know whether to show
// the "Sign in with SSO" button (and its label). Only these
// two oidc_* keys are public; issuer/client stay admin-only.
'oidc_enabled',
'oidc_button_label'
'general_max_files_per_upload'
]);
})
.select('setting_key', 'setting_value');
@@ -138,10 +128,6 @@ router.get('/', async (req, res) => {
crm_overview_show_outstanding: settingsObject.crm_overview_show_outstanding !== false,
crm_overview_show_quotes: settingsObject.crm_overview_show_quotes !== false,
crm_overview_show_invoices: settingsObject.crm_overview_show_invoices !== false,
// OIDC SSO (#798): the admin login page renders the "Sign in with
// SSO" button from these. Issuer/client/secret are never public.
oidc_enabled: settingsObject.oidc_enabled === true,
oidc_button_label: settingsObject.oidc_button_label || '',
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
@@ -212,12 +198,6 @@ router.get('/', async (req, res) => {
general_max_files_per_upload: Number.isFinite(Number(settingsObject.general_max_files_per_upload))
? Number(settingsObject.general_max_files_per_upload)
: 500,
// Per-file size limit (MB). Default mirrors uploadSettings.js
// DEFAULT_MAX_FILE_SIZE_MB so the gallery UI shows a sensible number on
// installs that never set it explicitly.
general_max_file_size_mb: Number.isFinite(Number(settingsObject.general_max_file_size_mb))
? Number(settingsObject.general_max_file_size_mb)
: 50,
// SEO meta tag flags (safe to expose - these are intended for crawlers)
seo_meta_noindex: settingsObject.seo_meta_noindex === true,
seo_meta_nofollow: settingsObject.seo_meta_nofollow === true,
-16
View File
@@ -10,7 +10,6 @@ const { body, validationResult } = require('express-validator');
const setupService = require('../services/setupService');
const { getClientIp } = require('../utils/requestIp');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
const { adminAuth } = require('../middleware/auth');
const logger = require('../utils/logger');
const router = express.Router();
@@ -80,19 +79,4 @@ router.post('/admin', [
}
});
// Wizard finish marker — unlike the endpoints above this one runs AFTER the
// admin exists (the wizard is authenticated from the account step onward), so
// it takes the normal admin auth. One-way: while the flag is unset the seeded
// SYSTEM event types may be deleted from the wizard's event-types step; once
// set they are permanently protected (#800).
router.post('/complete', adminAuth, async (req, res) => {
try {
await setupService.markSetupWizardCompleted();
res.json({ completed: true });
} catch (err) {
logger.error('[setup] markSetupWizardCompleted failed', { error: err.message });
res.status(500).json({ error: 'Failed to mark setup complete' });
}
});
module.exports = router;
@@ -80,16 +80,7 @@ jest.mock('../../../services/webhookService', () => ({
buildEventSubject: jest.fn().mockReturnValue({}),
}));
// event_type is validated against the live event_types catalog (#800) —
// that lookup would consume the first queued db() chain and shift the
// call sequence these tests pin. Stub it valid; the invalid path has its
// own test below.
jest.mock('../../../services/eventTypeService', () => ({
isValidEventType: jest.fn().mockResolvedValue(true),
}));
const { db } = require('../../../database/db');
const { isValidEventType } = require('../../../services/eventTypeService');
const eventsRouter = require('../events');
const buildApp = () => {
@@ -240,16 +231,4 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
.expect(400);
});
it('rejects an event_type unknown to the catalog with 400 (#800)', async () => {
isValidEventType.mockResolvedValueOnce(false);
const res = await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, event_type: 'nope' })
.expect(400);
expect(isValidEventType).toHaveBeenCalledWith('nope');
expect(JSON.stringify(res.body.errors)).toContain('event_type');
expect(db).not.toHaveBeenCalled();
});
});
+2 -52
View File
@@ -26,7 +26,6 @@ const logger = require('../../utils/logger');
const { slugify } = require('../../utils/slug');
const { formatBoolean } = require('../../utils/dbCompat');
const { parseBooleanInput } = require('../../utils/parsers');
const { isValidEventType } = require('../../services/eventTypeService');
const router = express.Router();
@@ -81,7 +80,7 @@ const photoUpload = multer({
* event_name: { type: string }
* event_type:
* type: string
* description: "Slug of an active event type from the catalog (Settings → Event Types). Defaults on a fresh install: wedding, birthday, corporate, other. GET /api/v1/event-types lists the live values."
* enum: [wedding, birthday, corporate, other, family]
* event_date: { type: string, format: date, nullable: true }
* customer_name: { type: string, nullable: true }
* customer_email: { type: string, format: email, nullable: true }
@@ -118,14 +117,7 @@ router.post(
requireApiScope('admin'),
[
body('event_name').isString().trim().notEmpty(),
// Validate against the live event_types catalog (admins can rename/delete
// the defaults and add custom types), not a hardcoded whitelist (#800).
body('event_type').isString().trim().notEmpty().bail().custom(async (value) => {
if (!(await isValidEventType(value))) {
throw new Error('Unknown event type — must match an active event type slug');
}
return true;
}),
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other', 'family']),
body('event_date').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('customer_name').optional({ nullable: true }).isString(),
body('customer_email').optional({ nullable: true, checkFalsy: true }).isEmail(),
@@ -455,48 +447,6 @@ router.get(
}
);
// ──────────────────────────────────────────────────────────────────────────
// GET /event-types — read (catalog discovery for event creation, #800)
// ──────────────────────────────────────────────────────────────────────────
/**
* @openapi
* /event-types:
* get:
* tags: [Events]
* summary: List active event types
* description: The slugs accepted as `event_type` when creating events. The catalog is admin-customizable (Settings → Event Types), so integrations should discover values here instead of hardcoding them.
* security: [{ bearerAuth: [] }]
* responses:
* 200:
* description: Active event types
* content:
* application/json:
* schema:
* type: object
* properties:
* eventTypes:
* type: array
* items:
* type: object
* properties:
* slug_prefix: { type: string }
* name: { type: string }
* emoji: { type: string }
*/
router.get('/event-types', apiTokenAuth, requireApiScope('read'), async (req, res) => {
try {
const types = await db('event_types')
.where('is_active', formatBoolean(true))
.orderBy('display_order', 'asc')
.select('slug_prefix', 'name', 'emoji');
res.json({ eventTypes: types });
} catch (error) {
logger.error('v1 GET /event-types failed', { error: error.message });
res.status(500).json({ error: 'Failed to list event types' });
}
});
// ──────────────────────────────────────────────────────────────────────────
// GET /events/:id — read
// ──────────────────────────────────────────────────────────────────────────
+2 -5
View File
@@ -31,10 +31,7 @@ function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'waitDue', type: 'wait', config: { untilVar: 'dueDate' }, pos_x: 240, pos_y: 110 },
// Anchor the grace period to the invoice's due date (dueDate + firstDays),
// not "now + firstDays" — so an already-overdue invoice enrolled via backfill
// duns on its real timeline instead of restarting a fresh grace clock (#750).
{ node_key: 'waitGrace', type: 'wait', config: { untilVar: 'dueDate', delayDays: firstDays }, pos_x: 240, pos_y: 220 },
{ node_key: 'waitGrace', type: 'wait', config: { delayDays: firstDays }, pos_x: 240, pos_y: 220 },
{ node_key: 'loop', type: 'loop', config: { maxIterations: maxReminders }, pos_x: 240, pos_y: 330 },
{ node_key: 'checkPaid', type: 'condition', config: { condition: 'invoice_paid' }, pos_x: 240, pos_y: 440 },
{ node_key: 'paymentCheck', type: 'action', config: { action: 'queue_payment_check' }, pos_x: 240, pos_y: 550 },
@@ -221,7 +218,7 @@ function buildGalleryExpiredGraph() {
const BUILTINS = [
{
key: DUNNING_KEY,
version: 7,
version: 6,
enabled: false,
name: 'Invoice dunning (built-in)',
trigger_type: 'invoice.sent',
+2 -12
View File
@@ -30,16 +30,6 @@ async function initializeUpload(options) {
totalChunks
} = options;
// Strip any directory components from the client-supplied filename. It is
// later joined onto the temp merge dir (path.join(tempDir, filename)), and
// path.join does NOT neutralise `../` — a filename like `../../uploads/
// logos/evil.svg` would escape the temp dir and overwrite arbitrary files
// (GHSA-pc72-jf53-w28j). basename() collapses it to the leaf name only.
const safeFilename = path.basename(String(filename || ''));
if (!safeFilename || safeFilename === '.' || safeFilename === '..') {
throw new Error('Invalid filename');
}
// Generate unique upload ID
const uploadId = crypto.randomUUID();
@@ -53,7 +43,7 @@ async function initializeUpload(options) {
// Store upload metadata
const uploadMeta = {
uploadId,
filename: safeFilename,
filename,
fileSize,
mimeType,
eventId,
@@ -69,7 +59,7 @@ async function initializeUpload(options) {
logger.info('Initialized chunked upload', {
uploadId,
filename: safeFilename,
filename,
fileSize,
expectedChunks,
eventId
+1 -8
View File
@@ -11,7 +11,6 @@ const businessProfileService = require('../businessProfileService');
const { ensureSystemBlocksSeeded } = require('../contractBlocksService');
const { ensureInt } = require('../../utils/numericHelpers');
const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers');
const { resolveDefaultEventType } = require('../eventTypeService');
/**
@@ -222,12 +221,6 @@ async function convertToEvent(contractId, adminId) {
const placeholderHash = crypto.randomBytes(32).toString('hex');
const shareToken = crypto.randomBytes(32).toString('hex');
// Event type: the configurable org default, else the resolved catch-all —
// same chain as quoteService.convertToEvent. Never a hardcoded slug: the
// admin may have renamed or deleted 'wedding' (#800).
const eventType = (await getAppSetting('crm_default_event_type'))
|| (await resolveDefaultEventType());
const eventCols = await db('events').columnInfo();
const candidate = {
slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`,
@@ -243,7 +236,7 @@ async function convertToEvent(contractId, adminId) {
customer_email: customerEmail,
customer_phone: customer.phone,
admin_email: adminEmail,
event_type: eventType,
event_type: 'wedding',
password_hash: placeholderHash,
share_link: shareToken,
share_token: shareToken,
+23 -101
View File
@@ -16,7 +16,6 @@ const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getStoragePath } = require('../config/storage');
const expenseService = require('./expenseService');
const sanitizeHtml = require('sanitize-html');
const { isUniqueViolation } = require('../utils/dbErrors');
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
@@ -231,36 +230,14 @@ async function roundTripTest({ timeoutMs = 30000, intervalMs = 3000 } = {}) {
}
}
// Sanitize an inbound HTML body before storing it. Inbound mail is untrusted,
// so this strips scripts/handlers/unknown schemes (the viewer ALSO renders it
// in a script-less sandboxed iframe — defense in depth). Remote images are kept
// (many legit emails embed them) but that is the only tracking-vector allowed.
function sanitizeBody(html) {
if (!html) return null;
try {
return sanitizeHtml(html, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'width', 'height'],
'*': ['style'],
},
allowedSchemes: ['http', 'https', 'mailto', 'cid'],
});
} catch (_) {
return null;
}
}
/** Poll the mailbox once. Safe to call repeatedly; self-skips when busy/off. */
async function pollOnce() {
if (polling) return { skipped: 'busy' };
if (!(await isEnabled())) return { skipped: 'disabled' };
const cfg = await getImapConfig();
if (!cfg) return { skipped: 'unconfigured' };
/**
* Poll ONE mailbox once and return the count of newly-processed messages.
* `opts.accountKey` tags each received_emails row; `opts.routeToExpenses`
* controls whether PDF/image attachments are dropped into the accounting inbox
* (true for the primary rechnungen@ mailbox) or only logged with the body
* (customer mail, e.g. hello@). The claim/dedup/stale-recovery logic is
* identical for every mailbox.
*/
async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses = true } = {}) {
polling = true;
const client = makeImapClient(cfg);
let processed = 0;
try {
@@ -327,7 +304,6 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
try {
await db('received_emails').insert({
message_id: claimKey,
account_key: accountKey,
status: 'processing',
attachment_count: 0,
received_at: new Date(),
@@ -339,47 +315,37 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
throw ce;
}
// Attachment handling. The accounting mailbox drops PDF/image
// attachments into the incoming-invoices inbox (isolated so one bad
// file can't prevent the audit row). Customer mailboxes only COUNT
// attachments — they aren't supplier invoices.
// Ingest attachments. Isolate each so one bad file can't prevent the
// audit row (the symptom: doc lands in Incoming invoices but the
// email never shows under Received).
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
let inboundId = null;
let count = 0;
const attErrors = [];
if (routeToExpenses) {
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
for (const att of atts) {
try {
const filePath = await saveAttachment(att);
const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null);
inboundId = doc.id; count += 1;
} catch (ae) {
attErrors.push(ae.message);
logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`);
}
for (const att of atts) {
try {
const filePath = await saveAttachment(att);
const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null);
inboundId = doc.id; count += 1;
} catch (ae) {
attErrors.push(ae.message);
logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`);
}
} else {
count = (parsed.attachments || []).length;
}
// A malformed Date: header yields an Invalid Date, which throws on a
// Postgres timestamp insert — coerce to now.
const receivedAt = (parsed.date instanceof Date && !Number.isNaN(parsed.date.getTime())) ? parsed.date : new Date();
const status = routeToExpenses
? (count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment'))
: 'received';
const status = count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment');
// Finalise the claimed row — every processed message ends up in the
// Received log with its (sanitized) body, even attachment-less ones.
// Received tab, even attachment-less ones.
await db('received_emails').where({ message_id: claimKey }).update({
from_address: ((parsed.from && parsed.from.text) || '').slice(0, 512) || null,
to_address: ((parsed.to && parsed.to.text) || '').slice(0, 512) || null,
subject: parsed.subject || null,
received_at: receivedAt,
attachment_count: count,
status,
inbound_document_id: inboundId,
body_html: sanitizeBody(parsed.html || null),
body_text: parsed.text || null,
error: attErrors.length ? attErrors.join('; ').slice(0, 2000) : null,
});
await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true });
@@ -394,7 +360,7 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
await db('received_emails').where({ message_id: claimKey })
.update({ status: 'error', error: String(e.message).slice(0, 2000) });
} else {
await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, account_key: accountKey, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() });
await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() });
}
} catch (ie) {
logger.error?.(`emailIntake: could not even write the error row (received_emails insert failing): ${ie.message}`);
@@ -407,55 +373,11 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
/* eslint-enable no-await-in-loop */
await client.logout();
} catch (e) {
logger.error?.(`emailIntake: poll failed (${accountKey}): ${e.message}`);
logger.error?.(`emailIntake: poll failed: ${e.message}`);
try { await client.close(); } catch (_e) { /* ignore */ }
}
return processed;
}
/**
* Poll ALL configured inbound mailboxes once: the primary accounting IMAP
* (email_configs) plus every enabled row in mail_accounts (e.g. hello@).
* Safe to call repeatedly; self-skips when busy/off.
*/
async function pollOnce() {
if (polling) return { skipped: 'busy' };
if (!(await isEnabled())) return { skipped: 'disabled' };
polling = true;
let processed = 0;
let anyConfigured = false;
try {
// 1) Primary accounting mailbox — routes attachments to the invoices inbox.
const acctCfg = await getImapConfig();
if (acctCfg) {
anyConfigured = true;
processed += await pollAccountOnce(acctCfg, { accountKey: 'accounting', routeToExpenses: true });
}
// 2) Additional mailboxes (customers/hello@) — body captured, no expense
// routing. Guarded so a pre-migration DB simply polls the accounting box.
let extras = [];
try {
if (await db.schema.hasTable('mail_accounts')) {
extras = await db('mail_accounts').where({ enabled: true });
}
} catch (_) { extras = []; }
for (const a of extras) {
if (!a.imap_host || !a.imap_user) continue;
anyConfigured = true;
const cfg = {
host: a.imap_host,
port: a.imap_port || 993,
secure: a.imap_secure !== false && a.imap_secure !== 0,
auth: { user: a.imap_user, pass: a.imap_pass || '' },
folder: a.imap_folder || 'INBOX',
};
// eslint-disable-next-line no-await-in-loop
processed += await pollAccountOnce(cfg, { accountKey: a.account_key, routeToExpenses: false });
}
} finally {
polling = false;
}
if (!anyConfigured) return { skipped: 'unconfigured' };
return { processed };
}
-57
View File
@@ -775,62 +775,6 @@ async function sendTemplateEmail(to, templateKey, variables) {
}
}
/**
* Send a fully-composed email (subject + HTML the admin already edited in the
* Messages composer) WITHOUT a template. Used for replies + human-sent document
* messages. Uses the configured SMTP identity + from address. Returns
* { messageId, html } so the caller can persist rendered_html for the record.
*/
async function sendRawEmail({ to, cc, subject, html, text, attachments, accountKey } = {}) {
let tx = null;
let fromEmail = null;
let fromName = null;
// Prefer a per-account outgoing identity (e.g. hello@) when the mail account
// has its own SMTP config, so customer replies send from that address instead
// of the global no-reply@. Falls back to the global SMTP transport.
if (accountKey) {
const acct = await db('mail_accounts').where({ account_key: accountKey }).first();
if (acct && acct.smtp_host && (acct.smtp_user || acct.from_email)) {
const nodemailer = require('nodemailer');
tx = nodemailer.createTransport({
host: acct.smtp_host,
port: parseInt(acct.smtp_port, 10) || 587,
secure: acct.smtp_secure === true || acct.smtp_secure === 1,
auth: acct.smtp_user && acct.smtp_pass ? { user: acct.smtp_user, pass: acct.smtp_pass } : undefined,
tls: { rejectUnauthorized: true },
});
fromEmail = acct.from_email || acct.smtp_user;
fromName = acct.from_name || '';
}
}
if (!tx) {
tx = await initializeTransporter();
if (!tx) throw new Error('Email service not configured');
const config = await db('email_configs').first();
if (!config || !config.from_email) throw new Error('Email service not configured');
fromEmail = config.from_email;
fromName = config.from_name;
}
const ccList = Array.isArray(cc) ? cc.filter(Boolean) : (cc ? [cc] : undefined);
const atts = Array.isArray(attachments)
? attachments.filter((a) => a && (a.contentPath || a.path || a.content))
.map((a) => ({ filename: a.filename, path: a.contentPath || a.path, content: a.content, contentType: a.contentType }))
: undefined;
const info = await tx.sendMail({
from: `${fromName || 'picpeak'} <${fromEmail}>`,
to,
cc: ccList,
subject,
html,
text: text || htmlToText(html),
attachments: atts,
});
logger.info(`Manual email sent: ${info.messageId}`);
return { messageId: info.messageId, html };
}
/**
* Render a queued email's HTML WITHOUT sending it. Used by the Project
* Overview cockpit to preview emails that predate the rendered_html column
@@ -1164,7 +1108,6 @@ module.exports = {
initializeTransporter,
startEmailQueueProcessor,
sendTemplateEmail,
sendRawEmail,
renderQueuedEmail,
processEmailQueue,
queueEmail,
+4 -27
View File
@@ -47,23 +47,11 @@ async function detectEnvironment() {
type = 'standalone';
}
// Detect a production compose install. The backend runs INSIDE a container and
// cannot see the host's compose files (the image only carries backend/), so we
// can't stat docker-compose.production.yml. Instead we key off an env var the
// production compose sets in the backend environment (PICPEAK_RELEASE_CHANNEL)
// and the default docker-compose.yml does not. When present, the update
// instructions must target that file explicitly — bare `docker compose`
// operates on docker-compose.yml, a different (build-based) stack that also
// starts the dev-only mailhog and leaves the real production containers on the
// old version.
const isProductionCompose = Boolean(process.env.PICPEAK_RELEASE_CHANNEL);
return {
type,
isDocker,
isGit,
hasDockerCompose,
isProductionCompose,
platform: process.platform,
nodeVersion: process.version,
appVersion
@@ -106,36 +94,25 @@ function generateUpdateInstructions(env, targetVersion) {
if (env.isDocker) {
instructions.environmentName = 'Docker';
// Production installs use docker-compose.production.yml (the file the README
// documents and the only one with pinned GHCR images + no dev-only mailhog).
// Bare `docker compose` targets docker-compose.yml instead, so a production
// user who runs it stays on the old version and gets a stray mailhog. When we
// detect a production compose (PICPEAK_RELEASE_CHANNEL set), point every
// command at that file with `-f`.
const composeFile = env.isProductionCompose ? '-f docker-compose.production.yml ' : '';
instructions.steps = [
{
description: 'Pull latest images',
command: `docker compose ${composeFile}pull`,
command: 'docker compose pull',
note: 'Downloads the new version images'
},
{
description: 'Recreate containers with new images',
command: `docker compose ${composeFile}up -d`,
command: 'docker compose up -d',
note: 'Restarts containers with new version'
},
{
description: 'Watch logs for startup (optional)',
command: `docker compose ${composeFile}logs -f backend`,
command: 'docker compose logs -f backend',
note: 'Press Ctrl+C to exit logs',
optional: true
}
];
if (env.isProductionCompose) {
instructions.warnings.push('Run these from the directory containing your docker-compose.production.yml file.');
} else {
instructions.warnings.push('Make sure you are in the directory containing your compose file. If you installed with docker-compose.production.yml, add `-f docker-compose.production.yml` to each command.');
}
instructions.warnings.push('Make sure you are in the directory containing your docker-compose.yml file');
} else if (env.isGit) {
instructions.environmentName = 'Git (Development)';
instructions.steps = [
@@ -266,22 +266,7 @@ async function ensureEventReminderTemplatesSeeded(db, logger) {
}
};
// Per-type templates are only seeded for slugs that still exist in the
// event_types catalog — the setup wizard (and admins) can delete the
// seeded defaults, and re-inserting event_reminder_<slug> for a removed
// type would resurrect an orphan on every boot (#800). The catch-all
// event_reminder_default is always seeded.
let existingSlugs = null;
if (await db.schema.hasTable('event_types')) {
const rows = await db('event_types').select('slug_prefix');
existingSlugs = new Set(rows.map((r) => r.slug_prefix));
}
for (const [templateKey, def] of Object.entries(EVENT_REMINDER_TEMPLATES)) {
const typeSlug = templateKey.replace(/^event_reminder_/, '');
if (typeSlug !== 'default' && existingSlugs && !existingSlugs.has(typeSlug)) {
continue;
}
try {
let existing = await db('email_templates').where({ template_key: templateKey }).first();
+10 -120
View File
@@ -67,20 +67,13 @@ const getEventTypeBySlugPrefix = async (slugPrefix) => {
const isValidEventType = async (slugPrefix) => {
const normalized = slugPrefix.toLowerCase();
// The live catalog is authoritative: a row decides by its active flag, and
// a slug the admin deleted (setup wizard, #800) or deactivated must NOT
// sneak back in through the legacy list below.
// Check in database
const eventType = await getEventTypeBySlugPrefix(normalized);
if (eventType) {
return Boolean(eventType.is_active);
}
const anyType = await db('event_types').first('id');
if (anyType) {
return false;
if (eventType && eventType.is_active) {
return true;
}
// Legacy fallback: only for a degenerate install with an EMPTY catalog
// (pre-catalog schema drift) — accept the old hardcoded values.
// Legacy fallback: Accept old hardcoded values for backward compatibility
const legacyTypes = ['wedding', 'birthday', 'corporate', 'other'];
return legacyTypes.includes(normalized);
};
@@ -205,19 +198,6 @@ const updateEventType = async (id, updates) => {
}
if (updates.is_active !== undefined) {
// Deactivating the last active type would empty the ACTIVE catalog and
// brick event creation (unknown slugs are rejected since #800).
if (updates.is_active === false && eventType.is_active) {
const otherActive = await db('event_types')
.whereNot('id', id)
.where('is_active', formatBoolean(true))
.first('id');
if (!otherActive) {
const error = new Error('Cannot deactivate the last active event type — activate another one first.');
error.code = 'LAST_ACTIVE';
throw error;
}
}
updateData.is_active = formatBoolean(updates.is_active);
}
@@ -270,12 +250,6 @@ const updateEventType = async (id, updates) => {
/**
* Delete an event type
*
* System types are protected EXCEPT during the first-run setup wizard
* (setup_wizard_completed flag unset, see setupService), where the admin may
* replace the seeded defaults before anything references them (#800). The
* in-use checks below still apply in that window as defense in depth.
*
* @param {number} id - Event type ID
* @returns {Promise<Object>}
*/
@@ -287,17 +261,11 @@ const deleteEventType = async (id) => {
throw error;
}
// Prevent deletion of system types once the setup wizard has completed.
// Prevent deletion of system types
if (eventType.is_system) {
// Lazy require: keeps the module graph flat (setupService has no
// dependency back on this service, but the require is only needed on
// this rare path).
const { isSetupWizardCompleted } = require('./setupService');
if (await isSetupWizardCompleted()) {
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
error.code = 'SYSTEM_TYPE';
throw error;
}
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
error.code = 'SYSTEM_TYPE';
throw error;
}
// Check if any events use this type
@@ -312,62 +280,7 @@ const deleteEventType = async (id) => {
throw error;
}
// Never delete the last remaining type — and never delete the last ACTIVE
// one either: event creation and the quote/contract default-type resolution
// both need at least one active catalog entry.
const remaining = await db('event_types').whereNot('id', id).count('id as count').first();
if (!remaining || parseInt(remaining.count) === 0) {
const error = new Error('Cannot delete the last event type — at least one must remain.');
error.code = 'LAST_TYPE';
throw error;
}
if (eventType.is_active) {
const remainingActive = await db('event_types')
.whereNot('id', id)
.where('is_active', formatBoolean(true))
.count('id as count')
.first();
if (!remainingActive || parseInt(remainingActive.count) === 0) {
const error = new Error('Cannot delete the last active event type — activate another one first.');
error.code = 'LAST_TYPE';
throw error;
}
}
// Quotes carry event_type too (migration 146) — a dangling slug there would
// corrupt the quote→event conversion default chain.
if (await hasColumnCached('quotes', 'event_type')) {
const quotesUsingType = await db('quotes')
.where('event_type', eventType.slug_prefix)
.count('id as count')
.first();
if (quotesUsingType && parseInt(quotesUsingType.count) > 0) {
const error = new Error(`Cannot delete: ${quotesUsingType.count} quotes are using this type. Deactivate it instead.`);
error.code = 'IN_USE';
throw error;
}
}
// Resolve schema lookups BEFORE opening the transaction — a global-db read
// inside a SQLite transaction (single connection) deadlocks. Same pattern
// as the rename cascade in updateEventType above.
const hasTranslations = await db.schema.hasTable('email_template_translations');
await db.transaction(async (trx) => {
await trx('event_types').where('id', id).del();
// Drop the per-type reminder template with the type, or it lingers as an
// orphan (invisible in the Reminder Emails tab, which derives its rows
// from the live catalog).
const tpl = await trx('email_templates')
.where({ template_key: `event_reminder_${eventType.slug_prefix}` })
.first('id');
if (tpl) {
if (hasTranslations) {
await trx('email_template_translations').where({ template_id: tpl.id }).del();
}
await trx('email_templates').where({ id: tpl.id }).del();
}
});
await db('event_types').where('id', id).del();
return { success: true, deleted: eventType };
};
@@ -428,28 +341,6 @@ const getEventTypeForSlug = async (eventTypeIdentifier) => {
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
};
/**
* Resolve the fallback event type for documentevent conversions (quotes,
* contracts) when the source carries none. Never hardcodes a specific slug
* (any of them, incl. 'other', can be disabled by the admin): prefer the
* generic 'other' catch-all when it's active, else the first active type by
* display order, and only fall back to the literal 'other' if the catalog is
* somehow empty/unreadable.
* @param {Object} [conn] - Optional knex connection/transaction
* @returns {Promise<string>} - slug_prefix to use
*/
const resolveDefaultEventType = async (conn) => {
const q = conn || db;
try {
const other = await q('event_types').where({ slug_prefix: 'other', is_active: formatBoolean(true) }).first('slug_prefix');
if (other) return 'other';
const firstActive = await q('event_types').where({ is_active: formatBoolean(true) }).orderBy('display_order', 'asc').first('slug_prefix');
return firstActive?.slug_prefix || 'other';
} catch (_) {
return 'other';
}
};
module.exports = {
getAllEventTypes,
getActiveEventTypes,
@@ -461,6 +352,5 @@ module.exports = {
updateEventType,
deleteEventType,
reorderEventTypes,
getEventTypeForSlug,
resolveDefaultEventType
getEventTypeForSlug
};
+10 -21
View File
@@ -2,7 +2,6 @@ const chokidar = require('chokidar');
const path = require('path');
const fs = require('fs').promises;
const sharp = require('sharp');
const pLimit = require('p-limit');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
@@ -14,20 +13,6 @@ const downloadZipService = require('./downloadZipService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const WATCH_PATH = () => path.join(getStoragePath(), 'events/active');
// Bound concurrent watcher work. chokidar fires 'add' once per file — with no
// ignoreInitial option the boot scan fires it for EVERY existing file, and a
// bulk drop into the watch folder fires it for every new one at once. Each
// handler runs DB lookups and (for new files) a full sharp pipeline;
// sharp.concurrency(2) only caps libvips threads WITHIN one operation, not the
// number of parallel pipelines, so unbounded handlers can OOM small hosts.
// 'unlink' shares the limiter: mass deletes otherwise burst DB work and
// ZIP-cache invalidation the same way.
const configuredConcurrency = Number.parseInt(process.env.FILE_WATCHER_CONCURRENCY || '2', 10);
const watcherConcurrency = Number.isFinite(configuredConcurrency)
? Math.max(1, configuredConcurrency)
: 2;
const processLimit = pLimit(watcherConcurrency);
function startFileWatcher() {
// Auto-import via filesystem watching only works with the local storage
// backend. In S3 mode there is no local directory to watch — every photo
@@ -49,15 +34,19 @@ function startFileWatcher() {
});
watcher
.on('add', (filePath) => {
processLimit(() => processNewPhoto(filePath)).catch((error) => {
.on('add', async (filePath) => {
try {
await processNewPhoto(filePath);
} catch (error) {
logger.error('Error processing new photo:', error);
});
}
})
.on('unlink', (filePath) => {
processLimit(() => removePhoto(filePath)).catch((error) => {
.on('unlink', async (filePath) => {
try {
await removePhoto(filePath);
} catch (error) {
logger.error('Error removing photo:', error);
});
}
});
logger.info('File watcher started');
+11 -99
View File
@@ -7,80 +7,11 @@ const crypto = require('crypto');
const logger = require('../utils/logger');
const { db } = require('../database/db');
const { getStorage } = require('./storage');
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
// Configure sharp for better memory management with large batches
sharp.cache(false); // Disable cache to prevent memory buildup
sharp.concurrency(2); // Limit concurrent operations
// Camera RAW / DNG formats. Sharp's bundled libvips has no raw loader, so these
// can't be fed to sharp() directly — instead we extract the full-resolution JPEG
// preview that every RAW file embeds (via exiftool) and process THAT. Gated
// strictly by extension, so nothing here runs for ordinary jpg/png/webp photos.
const RAW_EXTENSIONS = new Set([
'dng', 'cr2', 'cr3', 'nef', 'nrw', 'arw', 'sr2', 'srf',
'raf', 'rw2', 'orf', 'pef', 'srw', 'raw', '3fr', 'dcr', 'kdc'
]);
function isRawFilename(name) {
if (!name || typeof name !== 'string') return false;
const ext = path.extname(name).toLowerCase().replace(/^\./, '');
return RAW_EXTENSIONS.has(ext);
}
/**
* Extract the embedded full-resolution JPEG preview from a RAW/DNG file to a
* temp .jpg and return its path. Tries the largest previews first
* (JpgFromRaw PreviewImage ThumbnailImage). Throws if none can be extracted
* or the result isn't a valid image the caller treats that as a processing
* failure (photo 'failed'), same as any unreadable upload.
*/
async function extractRawPreview(rawPath) {
const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-raw-'));
const outPath = path.join(outDir, `${crypto.randomBytes(4).toString('hex')}.jpg`);
const tags = ['-JpgFromRaw', '-PreviewImage', '-ThumbnailImage'];
let lastErr;
for (const tag of tags) {
try {
// `-b` writes the raw tag bytes to stdout; -w isn't reliable across tags,
// so capture stdout as a buffer and write it ourselves.
const { stdout } = await execFileAsync('exiftool', ['-b', tag, rawPath], {
encoding: 'buffer',
maxBuffer: 256 * 1024 * 1024,
});
if (stdout && stdout.length > 0) {
await fsp.writeFile(outPath, stdout);
// Validate it's a real, decodable image before handing it to the pipeline.
const meta = await sharp(outPath).metadata();
if (meta.width && meta.height) {
return { path: outPath, cleanup: () => fsp.rm(outDir, { recursive: true, force: true }).catch(() => {}) };
}
}
} catch (err) {
lastErr = err;
}
}
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {});
throw new Error(`No usable embedded preview in RAW file ${path.basename(rawPath)}: ${lastErr ? lastErr.message : 'no preview tag returned data'}`);
}
/**
* Give a Sharp-processable local image path for `localPath`. For ordinary
* images it's a pass-through (no cost). For RAW/DNG (by `sourceName` extension)
* it extracts the embedded JPEG preview and returns that, plus the basename to
* use for generated outputs so thumbnails/previews stay named after the source
* rather than the random temp file. Always call `cleanup()` when done.
*/
async function withProcessableImage(localPath, sourceName) {
if (!isRawFilename(sourceName)) {
return { path: localPath, outputBasename: undefined, cleanup: () => {} };
}
const { path: previewPath, cleanup } = await extractRawPreview(localPath);
return { path: previewPath, outputBasename: path.basename(sourceName), cleanup };
}
// Default thumbnail settings
const DEFAULT_THUMBNAIL_WIDTH = 300;
const DEFAULT_THUMBNAIL_HEIGHT = 300;
@@ -366,14 +297,9 @@ async function ensureThumbnail(photo) {
return null;
}
logger.info(`Ensuring thumbnail for photo ${photo.id} from key: ${sourceKey}`);
newThumbnailPath = await withLocalCopy(sourceKey, async (localPath) => {
const proc = await withProcessableImage(localPath, sourceKey);
try {
return await generateThumbnail(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
} finally {
await proc.cleanup();
}
});
newThumbnailPath = await withLocalCopy(sourceKey, (localPath) =>
generateThumbnail(localPath, { regenerate: true })
);
}
if (newThumbnailPath) {
@@ -440,7 +366,7 @@ async function generateVideoPlaceholder(originalFilename, options = {}) {
* Outputs a 1920x1080 image suitable for full-width hero sections
*/
async function generateHeroImage(imagePath, options = {}) {
const filename = options.outputBasename || path.basename(imagePath);
const filename = path.basename(imagePath);
const heroFilename = `hero_${filename}`;
const heroRelKey = path.posix.join('heroes', heroFilename);
const storage = getStorage();
@@ -543,14 +469,9 @@ async function ensureHeroImage(photo) {
logger.warn(`Invalid hero image detected for photo ${photo.id}, regenerating...`);
}
const newHeroPath = await withLocalCopy(sourceKey, async (localPath) => {
const proc = await withProcessableImage(localPath, sourceKey);
try {
return await generateHeroImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
} finally {
await proc.cleanup();
}
});
const newHeroPath = await withLocalCopy(sourceKey, (localPath) =>
generateHeroImage(localPath, { regenerate: true })
);
if (newHeroPath) {
await db('photos')
@@ -577,7 +498,7 @@ async function ensureHeroImage(photo) {
* thumbnails or heroes.
*/
async function generatePreviewImage(imagePath, options = {}) {
const filename = options.outputBasename || path.basename(imagePath);
const filename = path.basename(imagePath);
const previewFilename = `preview_${filename}`;
const previewRelKey = path.posix.join('previews', previewFilename);
const storage = getStorage();
@@ -678,14 +599,9 @@ async function ensurePreviewImage(photo) {
logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`);
}
const newPreviewPath = await withLocalCopy(sourceKey, async (localPath) => {
const proc = await withProcessableImage(localPath, sourceKey);
try {
return await generatePreviewImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
} finally {
await proc.cleanup();
}
});
const newPreviewPath = await withLocalCopy(sourceKey, (localPath) =>
generatePreviewImage(localPath, { regenerate: true })
);
if (newPreviewPath) {
await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath });
@@ -749,8 +665,4 @@ module.exports = {
ensurePreviewImage,
extractCaptureDate,
withLocalCopy,
isRawFilename,
extractRawPreview,
withProcessableImage,
RAW_EXTENSIONS,
};
-10
View File
@@ -174,14 +174,6 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
? 0
: ensureInt(invoice.net_amount_minor) - displayedNetMinor;
// Optional free-text VAT / legal note printed directly under the MwSt. line
// on the invoice PDF (#794). Configured globally in Settings → CRM → Invoices.
// Data-driven: the admin types the exact wording (e.g. the Austrian
// Kleinunternehmer statement, § 6 Abs. 1 Z 27 UStG 1994), so no jurisdiction
// is hardcoded. Empty/whitespace → null (row omitted).
const vatNoteRaw = await getAppSetting('crm_invoices_vat_note_text');
const vatNote = typeof vatNoteRaw === 'string' && vatNoteRaw.trim() ? vatNoteRaw.trim() : null;
return {
locale: invoice.language || profile?.default_locale || 'de',
currency: invoice.currency,
@@ -197,8 +189,6 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
iban: bank.iban, bic: bank.bic, currency: bank.currency,
} : null,
paymentTerm,
// Free-text VAT/legal note (#794) — rendered under the MwSt. line by drawTotals.
vatNote,
lineItems: lineItems.map((li) => ({
quantity: li.quantity,
description: li.description,
-440
View File
@@ -1,440 +0,0 @@
/**
* OIDC SSO for admin users (#798, phase 1).
*
* Authorization-code + PKCE against a single configurable IdP (Keycloak,
* Authentik, Pocket ID, or any spec-compliant provider). Scope is deliberately
* narrow in phase 1: admin logins only, JIT provisioning with one default
* role. Role-claim mapping and logout-to-IdP are follow-ups.
*
* Identity binding: SSO logins match on `admin_users.external_subject` (the
* IdP's stable `sub` claim) NEVER on email alone, which is an
* account-takeover vector with IdPs that don't verify addresses. A one-time
* link of an EXISTING local admin by email is allowed only when the ID token
* carries `email_verified: true`; the sub is stamped so all future logins
* match by sub even if the email changes. Linked local admins keep
* `auth_provider='local'` (their password still works); JIT-provisioned rows
* get `auth_provider='oidc'` and an unusable random password hash.
*
* Config lives in app_settings (oidc_* keys, managed via the dedicated
* /admin/settings/sso endpoints). The client secret is AES-256-GCM encrypted
* at rest same construction as mfaService, own salt, key from
* OIDC_ENCRYPTION_KEY (fallback JWT_SECRET).
*
* MFA is delegated to the IdP for SSO logins: local TOTP protects the local
* password path, which SSO users don't take.
*/
const crypto = require('crypto');
const bcrypt = require('bcrypt');
// openid-client v5 (CommonJS). v6+ is ESM-only, which Node 22 can require()
// but Jest's CJS runtime cannot — v5 is the battle-tested major and its
// protocol coverage (discovery, PKCE, full ID-token validation) is identical
// for our flow.
const { Issuer, generators } = require('openid-client');
const { db } = require('../database/db');
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
const { formatBoolean } = require('../utils/dbCompat');
const { getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const ENC_ALGO = 'aes-256-gcm';
const ENC_SALT = 'picpeak-oidc-secret-v1'; // fixed: derivation must be stable
function getEncryptionKey() {
const material = process.env.OIDC_ENCRYPTION_KEY || process.env.JWT_SECRET;
if (!material) {
throw new Error('oidcService: OIDC_ENCRYPTION_KEY or JWT_SECRET must be set');
}
return crypto.scryptSync(material, ENC_SALT, 32);
}
/** AES-256-GCM encrypt → "iv.tag.ciphertext" (all base64url). */
function encryptSecret(plainSecret) {
const key = getEncryptionKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ENC_ALGO, key, iv);
const ct = Buffer.concat([cipher.update(plainSecret, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv, tag, ct].map((b) => b.toString('base64url')).join('.');
}
/** Reverse of encryptSecret. Throws on tamper/wrong key. */
function decryptSecret(stored) {
const key = getEncryptionKey();
const [ivB64, tagB64, ctB64] = String(stored).split('.');
if (!ivB64 || !tagB64 || !ctB64) {
throw new Error('oidcService: malformed encrypted secret');
}
const decipher = crypto.createDecipheriv(ENC_ALGO, key, Buffer.from(ivB64, 'base64url'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
const pt = Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64url')), decipher.final()]);
return pt.toString('utf8');
}
/**
* Read the full OIDC config from app_settings. Secret is returned DECRYPTED
* for internal use only; the settings GET endpoint must never call this.
*/
async function getOidcConfig() {
const [enabled, issuerUrl, clientId, encSecret, autoprovision, defaultRole, buttonLabel, scopes] =
await Promise.all([
getAppSetting('oidc_enabled'),
getAppSetting('oidc_issuer_url'),
getAppSetting('oidc_client_id'),
getAppSetting('oidc_client_secret'),
getAppSetting('oidc_autoprovision'),
getAppSetting('oidc_default_role'),
getAppSetting('oidc_button_label'),
getAppSetting('oidc_scopes'),
]);
let clientSecret = null;
if (encSecret) {
try {
clientSecret = decryptSecret(encSecret);
} catch (err) {
// Wrong key / tampered / plaintext-clobbered value → treat as
// unconfigured rather than sending garbage to the IdP.
logger.error('OIDC client secret could not be decrypted — treating SSO as unconfigured', {
error: err.message,
});
}
}
return {
enabled: enabled === true,
issuerUrl: issuerUrl || null,
clientId: clientId || null,
clientSecret,
autoprovision: autoprovision === true,
defaultRole: defaultRole || 'viewer',
buttonLabel: buttonLabel || null,
scopes: scopes || 'openid profile email',
};
}
function isConfigured(cfg) {
return Boolean(cfg.issuerUrl && cfg.clientId && cfg.clientSecret);
}
// Discovery result cache. Keyed by issuer+client so a settings change gets a
// fresh client; invalidated explicitly on settings save too.
let _clientCache = null; // { key, client, issuerMetadata }
function invalidateDiscoveryCache() {
_clientCache = null;
}
/**
* Resolve the openid-client Client for the current settings, performing
* OIDC discovery on first use. Throws on unreachable/invalid issuer
* callers surface that as a config error.
*/
async function getClient(cfg) {
// The secret is part of the key (as a fingerprint, never plaintext): in
// multi-worker deployments a secret rotation only invalidates the cache in
// the worker that handled the settings request — the others must detect
// the change through the key, or they keep signing with the old secret.
const secretFp = crypto.createHash('sha256').update(cfg.clientSecret || '').digest('hex').slice(0, 16);
const key = `${cfg.issuerUrl}|${cfg.clientId}|${secretFp}`;
if (_clientCache && _clientCache.key === key) {
return _clientCache;
}
const issuer = await Issuer.discover(cfg.issuerUrl);
const client = new issuer.Client({
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
redirect_uris: [await getRedirectUri()],
response_types: ['code'],
});
_clientCache = { key, client, issuerMetadata: issuer.metadata };
return _clientCache;
}
/**
* The redirect URI registered with the IdP. Derived from the public frontend
* base URL nginx proxies /api to the backend, so this resolves publicly.
*/
async function getRedirectUri() {
// The callback must land on the API's public origin — that is where the
// oidc_state cookie was set when the browser hit /sso/login. In the
// standard deployment the frontend proxies /api on the same origin, so
// FRONTEND_URL works; split-origin deployments set API_URL (canonically
// ending in /api, see .env.example) and MUST be honored first or the
// callback goes to a host that has neither the route nor the cookie.
const apiBase = (process.env.API_URL || '').trim().replace(/\/$/, '');
if (apiBase) {
return `${apiBase}/auth/admin/sso/callback`;
}
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const base = (await getFrontendBaseUrl()).replace(/\/$/, '');
if (!base) {
// Without a public base URL the redirect_uri would be relative — the IdP
// would reject it with an opaque error on ITS side. Fail here with a
// clear config message instead.
const err = new Error('API_URL or FRONTEND_URL (or the general_site_url setting) must be set for SSO');
err.code = 'OIDC_BAD_CONFIG';
throw err;
}
return `${base}/api/auth/admin/sso/callback`;
}
/**
* Build the IdP authorization URL plus the per-request secrets the callback
* needs (state, nonce, PKCE verifier). The route stores those in a
* short-lived signed cookie this service is stateless across the redirect.
*/
async function buildAuthorizationRequest() {
const cfg = await getOidcConfig();
if (!cfg.enabled || !isConfigured(cfg)) {
const err = new Error('SSO is not enabled or not fully configured');
err.code = 'OIDC_NOT_CONFIGURED';
throw err;
}
const { client } = await getClient(cfg);
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const state = generators.state();
const nonce = generators.nonce();
const url = client.authorizationUrl({
redirect_uri: await getRedirectUri(),
scope: cfg.scopes,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return { url, state, nonce, codeVerifier };
}
/**
* Exchange the authorization code and validate the ID token (issuer,
* audience, signature, nonce, state all enforced by openid-client).
* Returns the ID token claims.
*/
async function handleCallback(currentUrl, { state, nonce, codeVerifier }) {
const cfg = await getOidcConfig();
if (!cfg.enabled || !isConfigured(cfg)) {
const err = new Error('SSO is not enabled or not fully configured');
err.code = 'OIDC_NOT_CONFIGURED';
throw err;
}
const { client, issuerMetadata } = await getClient(cfg);
// Extract code/state from the callback URL, then exchange + validate the
// ID token (issuer, audience, signature, exp, nonce, state — all enforced
// by openid-client).
const callbackUrl = new URL(currentUrl);
const params = Object.fromEntries(callbackUrl.searchParams.entries());
const tokenSet = await client.callback(await getRedirectUri(), params, {
state,
nonce,
code_verifier: codeVerifier,
});
let claims = tokenSet.claims();
// Spec-compliant providers may deliver `profile`/`email` scope claims only
// from the UserInfo endpoint, not inside the ID token. When the email is
// missing there, fetch UserInfo and merge — ID-token claims win on
// conflict (they are signature-bound to this very authorization). The sub
// must match, or the response is discarded (spec requirement).
if (!claims.email && issuerMetadata.userinfo_endpoint && tokenSet.access_token) {
try {
const userinfo = await client.userinfo(tokenSet);
if (userinfo && userinfo.sub === claims.sub) {
claims = { ...userinfo, ...claims };
}
} catch (err) {
// Non-fatal: providers that put everything in the ID token don't need
// this; resolveAdminFromClaims handles a still-missing email.
logger.warn('OIDC userinfo fetch failed — proceeding with ID token claims only', {
error: err.message,
});
}
}
return claims;
}
/**
* Map validated ID token claims to an admin_users row.
*
* Resolution order:
* 1. (external_issuer, external_subject) === (iss, sub) that admin
* (must be active). Matching includes the issuer because OIDC only
* guarantees sub uniqueness WITHIN an issuer a lookup on sub alone
* would let a user of a newly-configured IdP inherit an old IdP's
* admin account on a subject collision.
* 2. email match against an UNLINKED admin, only if email_verified === true
* one-time link (stamps issuer+subject; auth_provider unchanged so
* a local password keeps working).
* 3. JIT provisioning when oidc_autoprovision is on (requires an email
* claim; role = oidc_default_role; unusable random password).
*
* Errors carry a `code` the route maps to a redirect error key.
*/
async function resolveAdminFromClaims(claims) {
const sub = claims.sub;
const iss = claims.iss;
const email = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : null;
const emailVerified = claims.email_verified === true;
if (!sub || !iss) {
const err = new Error('ID token has no sub/iss claim');
err.code = 'OIDC_BAD_CLAIMS';
throw err;
}
// 1. Established binding — issuer AND subject.
const bySub = await db('admin_users')
.where('external_issuer', iss)
.where('external_subject', sub)
.first();
if (bySub) {
if (!bySub.is_active) {
const err = new Error('Admin account is deactivated');
err.code = 'OIDC_INACTIVE';
throw err;
}
return bySub;
}
// 2. One-time email link — verified emails only, and only onto rows that
// have no binding yet (a different identity on the row means a
// different IdP identity already owns it).
if (email && emailVerified) {
const byEmail = await db('admin_users')
.where('email', email)
.whereNull('external_subject')
.first();
if (byEmail) {
if (!byEmail.is_active) {
const err = new Error('Admin account is deactivated');
err.code = 'OIDC_INACTIVE';
throw err;
}
// Claim atomically: two concurrent first-time callbacks with the same
// email but DIFFERENT subjects must not both authenticate as this
// admin — the conditional update lets exactly one win.
const claimed = await db('admin_users')
.where('id', byEmail.id)
.whereNull('external_subject')
.update({
external_issuer: iss,
external_subject: sub,
updated_at: new Date(),
});
if (claimed !== 1) {
// Lost the race. If the winner was this very identity (double-click,
// parallel tabs), the binding lookup now succeeds; anything else is
// an unbound identity again and must not proceed as this admin.
const rebound = await db('admin_users')
.where('external_issuer', iss)
.where('external_subject', sub)
.first();
if (rebound && rebound.is_active) return rebound;
const err = new Error('Account link raced with another sign-in — try again');
err.code = 'OIDC_BAD_CLAIMS';
throw err;
}
logger.info('OIDC: linked existing admin to IdP subject', {
adminId: byEmail.id,
sub,
});
return { ...byEmail, external_issuer: iss, external_subject: sub };
}
}
// 3. JIT provisioning.
const cfg = await getOidcConfig();
if (!cfg.autoprovision) {
const err = new Error('No matching admin account and auto-provisioning is disabled');
err.code = 'OIDC_NOT_PROVISIONED';
throw err;
}
if (!email) {
const err = new Error('IdP supplied no email claim — cannot provision an account');
err.code = 'OIDC_NO_EMAIL';
throw err;
}
const role = await db('roles').where('name', cfg.defaultRole).first();
if (!role) {
const err = new Error(`Configured default role '${cfg.defaultRole}' does not exist`);
err.code = 'OIDC_BAD_CONFIG';
throw err;
}
// Unusable-but-valid bcrypt hash: local login always fails for this row,
// and nothing downstream chokes on a malformed hash.
const passwordHash = await bcrypt.hash(crypto.randomBytes(32).toString('base64url'), getBcryptRounds());
const inserted = await db('admin_users')
.insert({
username: email,
email,
password_hash: passwordHash,
role_id: role.id,
is_active: formatBoolean(true),
must_change_password: formatBoolean(false),
auth_provider: 'oidc',
external_issuer: iss,
external_subject: sub,
created_at: new Date(),
updated_at: new Date(),
})
.returning('id');
const adminId = inserted[0]?.id || inserted[0];
logger.info('OIDC: JIT-provisioned admin from IdP', { adminId, sub, role: cfg.defaultRole });
return db('admin_users').where('id', adminId).first();
}
/**
* Persist SSO settings (dedicated endpoint the generic settings upserts
* strip oidc_client_secret so it can't be clobbered with plaintext).
* An absent/empty secret keeps the stored one.
*/
async function saveOidcSettings(input) {
const writes = [];
const put = (key, value, type) => writes.push(upsertAppSetting(key, JSON.stringify(value), type));
if (input.oidc_enabled !== undefined) put('oidc_enabled', input.oidc_enabled === true, 'boolean');
if (input.oidc_issuer_url !== undefined) put('oidc_issuer_url', String(input.oidc_issuer_url).trim(), 'string');
if (input.oidc_client_id !== undefined) put('oidc_client_id', String(input.oidc_client_id).trim(), 'string');
if (input.oidc_autoprovision !== undefined) put('oidc_autoprovision', input.oidc_autoprovision === true, 'boolean');
if (input.oidc_default_role !== undefined) put('oidc_default_role', String(input.oidc_default_role).trim(), 'string');
if (input.oidc_button_label !== undefined) put('oidc_button_label', String(input.oidc_button_label).trim(), 'string');
if (input.oidc_scopes !== undefined) {
// The `openid` scope is what makes this OIDC rather than plain OAuth —
// without it there is no ID token and the callback cannot authenticate
// anyone. Force it in rather than trusting the admin's edit.
const scopes = String(input.oidc_scopes).trim().split(/\s+/).filter(Boolean);
if (!scopes.includes('openid')) scopes.unshift('openid');
put('oidc_scopes', scopes.join(' ') || 'openid profile email', 'string');
}
if (typeof input.oidc_client_secret === 'string' && input.oidc_client_secret.length > 0) {
put('oidc_client_secret', encryptSecret(input.oidc_client_secret), 'string');
}
await Promise.all(writes);
invalidateDiscoveryCache();
}
module.exports = {
getOidcConfig,
isConfigured,
getRedirectUri,
buildAuthorizationRequest,
handleCallback,
resolveAdminFromClaims,
saveOidcSettings,
invalidateDiscoveryCache,
getClient,
encryptSecret,
decryptSecret,
};
+4 -36
View File
@@ -851,18 +851,6 @@ function drawTotals(doc, ctx, x, y, width) {
doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
y = doc.y + 4;
// Free-text VAT / legal note (#794) — printed directly under the MwSt. line
// (Benedikt's requested spot). The admin sets the exact wording in
// Settings → CRM → Invoices (e.g. the Austrian Kleinunternehmer statement).
// Optional; wraps across the totals column. Font size is restored to the row
// scale so the Mahngebühr / Rundung / grand-total rows below are unaffected.
if (ctx.vatNote) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#555');
doc.text(ctx.vatNote, labelX, y, { width: right - labelX });
doc.fillColor('#000').fontSize(10);
y = doc.y + 4;
}
// Mahngebühr row — only rendered when a late fee has been added
// (second reminder onwards). Sits between VAT and the grand-total
// divider so the customer sees a clear "VAT + late fee → Total"
@@ -1682,16 +1670,7 @@ function renderDocument(type, context) {
// VAT + middle divider + Total)
const FOOTER_RESERVE = 30;
const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50;
let TOTALS_BLOCK_HEIGHT = 90;
// A free-text VAT note (#794) adds a wrapped row under the MwSt. line —
// grow the reserved totals height by its measured height so a long note
// can't push the grand total / payment block into the footer.
if (ctx.vatNote) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8);
const noteWidth = PAGE.contentWidth - ((PAGE.contentWidth - 20) / 2 + 20);
TOTALS_BLOCK_HEIGHT += doc.heightOfString(ctx.vatNote, { width: noteWidth }) + 4;
doc.fontSize(10);
}
const TOTALS_BLOCK_HEIGHT = 90;
const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT;
const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT;
@@ -1767,23 +1746,14 @@ function renderDocument(type, context) {
// grey line in the bottom corner) is negligible.
for (let i = 0; i < total; i++) {
doc.switchToPage(range.start + i);
// Drop this page's bottom margin to 0 so writing the label INTO the
// margin band (below the content area the line-item table fills) can't
// trigger PDFKit's auto-page-break. Previously the label sat at
// marginBottom-12 — INSIDE the content area — so on a full multi-page
// invoice the table's last row overlapped the "Seite X von Y" stamp
// (#794). The page is already fully laid out (buffered), so zeroing the
// margin here is safe.
doc.page.margins.bottom = 0;
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888');
const label = t(ctx.locale, 'page_of', {
current: i + 1,
total,
});
// Bottom-right corner, INSIDE the bottom margin (below the content
// edge the table fills), so a full continuation page's last row can't
// overlap it.
const labelY = doc.page.height - PAGE.marginBottom + 8;
// Bottom-right corner, just above the bottom margin so
// it doesn't trigger PDFKit's auto-paging.
const labelY = doc.page.height - PAGE.marginBottom - 12;
const labelW = 120;
const labelX = doc.page.width - PAGE.marginRight - labelW;
doc.text(label, labelX, labelY, {
@@ -1823,8 +1793,6 @@ function normaliseContext(type, ctx) {
totals: ctx.totals || {},
doc: ctx.doc || {},
qrFormat: ctx.qrFormat || 'none',
// Free-text VAT/legal note printed under the MwSt. line on invoices (#794).
vatNote: (typeof ctx.vatNote === 'string' && ctx.vatNote.trim()) ? ctx.vatNote.trim() : null,
// Date-format config from the `general_date_format` app setting.
// Shape: `{ format: 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' |
// 'YYYY-MM-DD', locale?: string }`. The service layer hydrates
+36 -96
View File
@@ -1,9 +1,9 @@
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { generateThumbnail, generateVideoPlaceholder, extractCaptureDate, withLocalCopy, withProcessableImage } = require('./imageProcessor');
const { generateThumbnail, extractCaptureDate, withLocalCopy } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { processUploadedVideo, extractVideoMetadata, isVideoMimeType } = require('./videoProcessor');
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
const { getStorage } = require('./storage');
const { resolvePhotoStorageKey } = require('./photoResolver');
const logger = require('../utils/logger');
@@ -141,50 +141,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
'thumbnails',
`thumb_${newFilename.replace(/\.[^.]+$/, '.jpg')}`
);
// A thumbnail/probe failure must not lose the video: without this
// guard the whole upload errors here, while the image branch below
// already survives its thumbnail failures. Fall back to metadata-only
// plus the static play-button placeholder — a completed video with a
// NULL thumbnail would make the grid fetch the ORIGINAL video file
// as an <img> blob (thumbnail_url || url), i.e. a multi-GB download
// for a broken tile (codex review of #845).
try {
const result = await processUploadedVideo(tempPath, videoThumbnailKey);
videoMetadata = result.metadata;
thumbnailPath = result.thumbnailKey;
} catch (videoErr) {
logger.warn(`Video processing failed for ${file.originalname}, using placeholder thumbnail:`, videoErr.message);
try {
videoMetadata = await extractVideoMetadata(tempPath);
} catch (metaErr) {
logger.warn(`Video metadata extraction also failed for ${file.originalname}:`, metaErr.message);
}
// ffmpeg-free (sharp-rendered SVG); returns null on failure.
thumbnailPath = await generateVideoPlaceholder(newFilename);
}
const result = await processUploadedVideo(tempPath, videoThumbnailKey);
videoMetadata = result.metadata;
thumbnailPath = result.thumbnailKey;
} else {
// RAW/DNG can't be fed to sharp directly (no raw loader), so extract the
// embedded JPEG preview first and thumbnail/measure THAT. Pass-through
// for ordinary images. The stored original stays the RAW (download).
// Use the unique stored filename (not the client-supplied original) so
// the RAW-derived thumbnail's global key can't collide across galleries.
const proc = await withProcessableImage(tempPath, newFilename);
thumbnailPath = await generateThumbnail(tempPath);
try {
thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename });
try {
const sharp = require('sharp');
const metadata = await sharp(proc.path).metadata();
if (metadata.width && metadata.height) {
imageMetadata = {
width: metadata.width,
height: metadata.height
};
}
} catch (metadataError) {
logger.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
const sharp = require('sharp');
const metadata = await sharp(tempPath).metadata();
if (metadata.width && metadata.height) {
imageMetadata = {
width: metadata.width,
height: metadata.height
};
}
} finally {
await proc.cleanup();
} catch (metadataError) {
logger.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
}
}
@@ -475,63 +447,31 @@ async function processPhoto(photoId) {
'thumbnails',
`thumb_${photo.filename.replace(/\.[^.]+$/, '.jpg')}`
);
// A thumbnail/probe failure must not fail the row: processPhoto's caller
// marks failed rows 'failed' and the guest gallery only lists 'complete',
// so the video would become permanently invisible. The image branch below
// already survives its thumbnail failures — mirror that: fall back to
// metadata-only plus the static play-button placeholder. A completed
// video with a NULL thumbnail would make the grid fetch the ORIGINAL
// video file as an <img> blob (thumbnail_url || url) — a multi-GB
// download for a broken tile (codex review of #845).
let videoResult = null;
try {
videoResult = await processUploadedVideo(localPath, videoThumbnailKey);
} catch (videoErr) {
logger.warn(`processPhoto: video processing failed for ${photoId}, using placeholder thumbnail`, { error: videoErr.message });
try {
videoResult = { metadata: await extractVideoMetadata(localPath) };
} catch (metaErr) {
logger.warn(`processPhoto: video metadata extraction also failed for ${photoId}`, { error: metaErr.message });
}
// ffmpeg-free (sharp-rendered SVG); returns null on failure.
const placeholderKey = await generateVideoPlaceholder(photo.filename);
if (placeholderKey) videoResult = { ...(videoResult || {}), thumbnailKey: placeholderKey };
}
if (videoResult?.thumbnailKey) updateData.thumbnail_path = videoResult.thumbnailKey;
if (videoResult?.metadata) {
const m = videoResult.metadata;
if (m.duration != null) updateData.duration = m.duration;
if (m.videoCodec) updateData.video_codec = m.videoCodec;
if (m.audioCodec) updateData.audio_codec = m.audioCodec;
if (m.width) updateData.width = m.width;
if (m.height) updateData.height = m.height;
const result = await processUploadedVideo(localPath, videoThumbnailKey);
updateData.thumbnail_path = result.thumbnailKey;
if (result.metadata) {
if (result.metadata.duration != null) updateData.duration = result.metadata.duration;
if (result.metadata.videoCodec) updateData.video_codec = result.metadata.videoCodec;
if (result.metadata.audioCodec) updateData.audio_codec = result.metadata.audioCodec;
if (result.metadata.width) updateData.width = result.metadata.width;
if (result.metadata.height) updateData.height = result.metadata.height;
}
} else {
// RAW/DNG can't be sharp-decoded directly — extract the embedded JPEG
// preview and thumbnail/measure that. Pass-through for ordinary images.
// This is the ASYNC worker path (backgroundProcessor → processPhoto), the
// one real uploads actually take; the synchronous processUploadedPhotos()
// has the same handling.
const proc = await withProcessableImage(localPath, photo.filename);
try {
try {
const thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename });
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
} catch (e) {
logger.warn(`processPhoto: thumbnail generation failed for ${photoId}`, { error: e.message });
const thumbnailPath = await generateThumbnail(localPath);
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
} catch (e) {
logger.warn(`processPhoto: thumbnail generation failed for ${photoId}`, { error: e.message });
}
try {
const sharp = require('sharp');
const metadata = await sharp(localPath).metadata();
if (metadata.width && metadata.height) {
updateData.width = metadata.width;
updateData.height = metadata.height;
}
try {
const sharp = require('sharp');
const metadata = await sharp(proc.path).metadata();
if (metadata.width && metadata.height) {
updateData.width = metadata.width;
updateData.height = metadata.height;
}
} catch (e) {
logger.warn(`processPhoto: dimensions extraction failed for ${photoId}`, { error: e.message });
}
} finally {
await proc.cleanup();
} catch (e) {
logger.warn(`processPhoto: dimensions extraction failed for ${photoId}`, { error: e.message });
}
}
});
+16 -24
View File
@@ -10,7 +10,7 @@ const path = require('path');
const fsp = require('fs/promises');
const sharp = require('sharp');
const { db } = require('../database/db');
const { generateThumbnail, extractCaptureDate, withProcessableImage } = require('./imageProcessor');
const { generateThumbnail, extractCaptureDate } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const watermarkGeneratorService = require('./watermarkGeneratorService');
const { getStorage } = require('./storage');
@@ -61,32 +61,24 @@ async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename,
// No EXIF — keep null
}
const stats = await fsp.stat(newFileTempPath);
// RAW/DNG isn't sharp-decodable — extract the embedded JPEG preview first
// (pass-through for ordinary images), then measure + thumbnail that. Mirrors
// the ingest paths (processPhoto / processUploadedPhotos).
let width = null;
let height = null;
let thumbnailPath = null;
// Detect/name by the unique stored filename (newFilename), not the
// client-supplied original, so RAW derivative keys can't collide.
const proc = await withProcessableImage(newFileTempPath, newFilename);
try {
try {
const metadata = await sharp(proc.path).metadata();
width = metadata.width || null;
height = metadata.height || null;
} catch {
// Non-image or corrupt
}
try {
thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename });
} catch {
logger.warn('Failed to generate thumbnail for replaced photo', { photoId: existingPhoto.id });
}
} finally {
await proc.cleanup();
const metadata = await sharp(newFileTempPath).metadata();
width = metadata.width || null;
height = metadata.height || null;
} catch {
// Non-image or corrupt
}
const stats = await fsp.stat(newFileTempPath);
// Generate new thumbnail FROM the local temp before uploading the original.
let thumbnailPath = null;
try {
thumbnailPath = await generateThumbnail(newFileTempPath);
} catch {
logger.warn('Failed to generate thumbnail for replaced photo', { photoId: existingPhoto.id });
}
// Delete old assets BEFORE uploading the new key — if they share the path
+17 -181
View File
@@ -18,12 +18,10 @@ const fsp = require('fs').promises;
const path = require('path');
const os = require('os');
const StreamZip = require('node-stream-zip');
const { assertZipEntriesWithin } = require('../utils/safePath');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const { getStoragePath } = require('../config/storage');
const { hasColumnCached } = require('../utils/schemaCache');
const { setSessionsValidAfter } = require('../utils/sessionCutoff');
const logger = require('../utils/logger');
const { PICPEAK_FORMAT_VERSION, EXCLUDED_TABLES, listDataTables } = require('./picpeakExportService');
@@ -82,164 +80,25 @@ function parseNdjson(filePath) {
}
// Re-insert the operator's account inside the restore transaction so they keep
// working credentials after the wipe.
//
// The operator's login + credentials + MFA must be restored, not just the
// password. A crafted backup can carry a row with the operator's email whose
// two_factor_* fields are attacker-chosen — leaving those in place would let
// the backup strip or hijack the operator's MFA, or (cross-instance) pin a TOTP
// secret encrypted with the source instance's key the operator can never
// satisfy. These columns are scalar/text (recovery codes are a JSON string in a
// TEXT column), so writing them needs no special json handling. Relationship/
// audit FKs (role_id, created_by) are deliberately NOT forced from the snapshot
// — see the update branch below.
//
// admin_users has UNIQUE constraints on BOTH email and username, and a restored
// backup can collide with the operator on either — possibly on two DIFFERENT
// rows (one shares the email, another shares the default `admin` username). We
// reconcile WITHOUT deleting any restored row: deleting would fire ON DELETE
// actions (SQLite) or dangle references such as events.created_by (Postgres,
// where replica mode suppresses cascades). Instead:
// - if a row already has the operator's email, overwrite it in place (its id
// is preserved, so every FK pointing at the operator stays valid);
// - if a DIFFERENT row holds the operator's username, rename that row (id
// preserved, its own FKs stay valid) to free the username;
// - only when no row has the operator's email do we insert a fresh row.
// working credentials. If the backup already loaded an admin with the same
// email, overwrite that row's credentials with the current account's (current
// creds win); otherwise insert the snapshot with a fresh id.
async function reinjectCurrentAdmin(trx, currentAdmin) {
if (!currentAdmin) return null;
const emailMatch = await trx('admin_users')
.whereRaw('lower(email) = lower(?)', [currentAdmin.email])
.first();
// Free the operator's username if a different row holds it (rename, not delete).
const usernameHolder = await trx('admin_users')
.whereRaw('lower(username) = lower(?)', [currentAdmin.username])
.first();
if (usernameHolder && (!emailMatch || usernameHolder.id !== emailMatch.id)) {
await trx('admin_users')
.where({ id: usernameHolder.id })
.update({ username: `${usernameHolder.username}__restored_${usernameHolder.id}` });
}
if (emailMatch) {
// Update in place — keeps emailMatch.id so restored FKs to the operator
// hold. Write only the AUTH-critical columns (login identity + credentials
// + MFA), never the relationship/audit FKs (role_id → roles, created_by →
// admin_users). Forcing the operator's pre-restore role_id/created_by here
// could reference rows absent from a cross-instance backup and dangle the
// FK (SQLite rolls back at commit); the row already carries the backup's
// own valid values for those. This still closes the MFA-hijack gap — a
// crafted backup can't strip or replace the operator's second factor.
const authUpdate = {};
for (const field of PRESERVED_AUTH_FIELDS) {
if (field in currentAdmin) authUpdate[field] = currentAdmin[field];
}
await trx('admin_users').where({ id: emailMatch.id }).update(authUpdate);
return emailMatch.id;
if (!currentAdmin) return;
const existing = await trx('admin_users').whereRaw('lower(email) = lower(?)', [currentAdmin.email]).first();
if (existing) {
await trx('admin_users').where({ id: existing.id }).update({
password_hash: currentAdmin.password_hash,
is_active: currentAdmin.is_active,
must_change_password: currentAdmin.must_change_password,
});
} else {
// The operator's email isn't in the backup, so nothing restored references
// their id — a fresh row can't dangle a reference TO the operator. Null the
// self-referential created_by (its target admin may be absent from this
// backup; ON DELETE SET NULL makes null the correct "unknown inviter"
// value) so the insert itself can't dangle. Use an explicit max(id)+1
// rather than the identity sequence, which batchInsert left unadvanced on
// Postgres (a sequence-based insert could collide with a restored id).
const snapshot = { ...currentAdmin };
delete snapshot.id;
if ('created_by' in snapshot) snapshot.created_by = null;
const maxRow = await trx('admin_users').max({ m: 'id' }).first();
snapshot.id = (Number(maxRow && maxRow.m) || 0) + 1;
await trx('admin_users').insert(snapshot);
return snapshot.id;
const row = { ...currentAdmin };
delete row.id; // let the engine assign a fresh id to avoid collision
await trx('admin_users').insert(row);
}
}
// Capture the operator's role and its granted permission NAMES before the wipe,
// so preserveOperatorRole() can re-establish the operator's authorization after
// the RBAC tables are replaced. Permission NAMES (not ids) are captured because
// the restored permissions table reassigns ids. Returns null if the operator
// has no role.
async function captureOperatorRole(roleId) {
if (!roleId) return null;
const role = await db('roles').where({ id: roleId }).first();
if (!role) return null;
const permissions = await db('role_permissions')
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
.where('role_permissions.role_id', roleId)
.pluck('permissions.name');
return { role, permissions };
}
// Restore the operator's authorization after roles/role_permissions are
// replaced. A restore rewrites the RBAC tables, so the operator's pre-restore
// role_id may now name a different (or missing) role — a crafted backup could
// silently downgrade them, and reinjectCurrentAdmin deliberately does NOT copy
// role_id (it could dangle). Here we resolve the role by NAME against the
// restored data: if a role with the operator's role name exists we trust it
// (it's the backup the operator chose to restore); otherwise we re-create the
// role from the captured snapshot and re-grant the captured permissions that
// still exist, so the operator can never be locked out of their own instance.
async function preserveOperatorRole(trx, operatorId, snapshot) {
if (!operatorId || !snapshot || !snapshot.role) return;
const { role, permissions } = snapshot;
let target = await trx('roles').whereRaw('lower(name) = lower(?)', [role.name]).first();
if (!target) {
const roleRow = { ...role };
delete roleRow.id;
const maxRole = await trx('roles').max({ m: 'id' }).first();
const newRoleId = (Number(maxRole && maxRole.m) || 0) + 1; // sequence resynced post-commit
roleRow.id = newRoleId;
await trx('roles').insert(roleRow);
if (permissions && permissions.length) {
const perms = await trx('permissions').whereIn('name', permissions).select('id');
if (perms.length) {
await trx('role_permissions').insert(
perms.map((p) => ({ role_id: newRoleId, permission_id: p.id }))
);
}
}
target = { id: newRoleId };
}
await trx('admin_users').where({ id: operatorId }).update({ role_id: target.id });
}
// Fast-forward each restored table's Postgres identity sequence to its current
// max(id). batchInsert writes explicit ids without advancing the sequence, so
// the next natural insert into any restored table (a new event, an accepted
// invitation, etc.) would otherwise collide on the primary key. Runs AFTER the
// restore transaction commits (setval is non-transactional and would survive a
// rollback) and guards every table with a column-existence check —
// pg_get_serial_sequence RAISES on a table lacking an `id` column (e.g. the
// composite-key role_permissions), so an unguarded call would abort here.
// No-op on SQLite, whose AUTOINCREMENT tracks the high-water mark itself.
async function resyncSequences(tables) {
if (!isPostgres()) return;
for (const table of tables) {
try {
if (!(await db.schema.hasColumn(table, 'id'))) continue;
const res = await db.raw('SELECT pg_get_serial_sequence(?, ?) AS seq', [table, 'id']);
const seq = res && res.rows && res.rows[0] && res.rows[0].seq;
if (!seq) continue; // `id` isn't a serial/identity column
await db.raw(
'SELECT setval(?, (SELECT COALESCE(MAX(id), 1) FROM ??), (SELECT MAX(id) IS NOT NULL FROM ??))',
[seq, table, table]
);
} catch (err) {
logger.warn(`[picpeak-import] could not resync sequence for ${table}: ${err.message}`);
}
}
}
// AUTH-critical admin_users columns preserved when overwriting a restored row
// that shares the operator's email. Deliberately excludes relationship/audit
// FKs (role_id, created_by) — see reinjectCurrentAdmin for why.
const PRESERVED_AUTH_FIELDS = [
'username', 'email', 'password_hash', 'is_active', 'must_change_password',
'two_factor_enabled', 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_enrolled_at',
];
// The json/jsonb columns of a table (Postgres only). The pg driver returns
// jsonb as parsed JS values, so on re-insert they must be serialised back to
// valid JSON text — otherwise a scalar like the string "PicPeak" is sent
@@ -268,7 +127,7 @@ function serialiseJsonColumns(rows, jsonCols) {
// session_replication_role=replica on the trx connection, reset before commit;
// sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never
// in the data set, so the target's schema/migration state is left intact.
async function replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot) {
async function replaceAllTables(tables, dataDir, currentAdmin) {
await db.transaction(async (trx) => {
if (isPostgres()) {
try {
@@ -298,10 +157,7 @@ async function replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot) {
await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100);
}
const operatorId = await reinjectCurrentAdmin(trx, currentAdmin);
if (operatorId && roleSnapshot) {
await preserveOperatorRole(trx, operatorId, roleSnapshot);
}
await reinjectCurrentAdmin(trx, currentAdmin);
// Reset the pg session flag BEFORE the connection returns to the pool.
if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'");
@@ -371,18 +227,11 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
const currentAdmin = currentAdminId
? await db('admin_users').where({ id: currentAdminId }).first()
: null;
// Capture the operator's role + granted permission names BEFORE the wipe so
// their authorization can be re-established after the RBAC tables are replaced.
const roleSnapshot = currentAdmin ? await captureOperatorRole(currentAdmin.role_id) : null;
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-import-'));
try {
const zip = new StreamZip.async({ file: picpeakPath });
try {
// Reject ZIP-slip entries before extracting — a crafted .picpeak could
// otherwise write outside the staging dir via `../` entry names
// (same class as GHSA-jfhw-fj23-fx6x).
assertZipEntriesWithin(Object.values(await zip.entries()), staging);
await zip.extract(null, staging);
} finally {
await zip.close();
@@ -402,16 +251,7 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`);
}
await replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot);
// Post-commit fixups (must NOT run inside the restore transaction):
// - resync Postgres identity sequences left behind by the explicit-id
// batchInsert, so the next natural insert doesn't collide;
// - stamp a global session cutoff so every JWT issued before this restore
// (admin, customer, gallery) stops authenticating — ids may have shifted.
await resyncSequences(tables);
await setSessionsValidAfter(Math.floor(Date.now() / 1000));
await replaceAllTables(tables, dataDir, currentAdmin);
const filesRestored = await restoreFiles(staging);
const usesExternalMedia = await detectExternalMedia();
@@ -428,8 +268,4 @@ module.exports = {
importFromPicpeak,
readManifestFromZip,
validateManifest,
reinjectCurrentAdmin,
captureOperatorRole,
preserveOperatorRole,
resyncSequences,
};
+19 -1
View File
@@ -34,7 +34,6 @@ const { cleanNetMinor } = require('../utils/invoiceRounding');
const { AppError } = require('../utils/errors');
const { formatBoolean } = require('../utils/dbCompat');
const { nextDocumentNumber } = require('../utils/documentSequences');
const { resolveDefaultEventType } = require('./eventTypeService');
const { formatShortDate } = require('../utils/dateFormatter');
const businessProfileService = require('./businessProfileService');
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
@@ -309,6 +308,25 @@ async function nextQuoteNumber(trx) {
return nextDocumentNumber('quote', 'crm_quotes_number_format', 'Q-{YEAR}-{SEQ:04d}', trx);
}
/**
* Resolve the fallback event type for a quoteevent conversion when the quote
* itself carries none. Never hardcodes a specific slug (any of them, incl.
* 'other', can be disabled by the admin): prefer the generic 'other' catch-all
* when it's active, else the first active type by display order, and only fall
* back to the literal 'other' if the catalog is somehow empty/unreadable.
*/
async function resolveDefaultEventType(conn) {
const q = conn || db;
try {
const other = await q('event_types').where({ slug_prefix: 'other', is_active: true }).first('slug_prefix');
if (other) return 'other';
const firstActive = await q('event_types').where({ is_active: true }).orderBy('display_order', 'asc').first('slug_prefix');
return firstActive?.slug_prefix || 'other';
} catch (_) {
return 'other';
}
}
function ensureCustomerFeatureEnabled(customer, feature) {
// Global toggle (`customer_feature_quotes_enabled` / `..._bills_enabled`)
// is checked at the route layer (feature flag); here we only enforce
+1 -28
View File
@@ -20,26 +20,6 @@ const { formatBoolean } = require('../utils/dbCompat');
// is permanently closed once setup is done — safe even on a public IP.
const SETUP_TOKEN_KEY = 'setup_token';
// One-way flag flipped when the setup wizard finishes (migration 161 marks it
// completed on installs that predate the wizard's event-types step). While it
// is unset — i.e. only during the first-run wizard — the seeded SYSTEM event
// types may be deleted (eventTypeService.deleteEventType), because nothing
// can reference them yet. Once true, system types are permanently protected.
const SETUP_WIZARD_COMPLETED_KEY = 'setup_wizard_completed';
async function isSetupWizardCompleted() {
// Fail closed: only an explicit stored `false` (seeded by migration 161 on
// a fresh, admin-less install) opens the deletion window. A missing row —
// e.g. app_settings replaced by a portable-backup restore that predates the
// migration, which will not rerun — means a configured instance, not a
// first run.
return (await getAppSetting(SETUP_WIZARD_COMPLETED_KEY)) !== false;
}
async function markSetupWizardCompleted() {
await upsertAppSetting(SETUP_WIZARD_COMPLETED_KEY, JSON.stringify(true), 'boolean');
}
async function noAdminExists() {
const row = await db('admin_users').count({ c: '*' }).first();
return Number(row?.c || 0) === 0;
@@ -193,11 +173,4 @@ async function createInitialAdmin({ token, email, password, ip }) {
};
}
module.exports = {
getSetupStatus,
ensureSetupToken,
verifySetupToken,
createInitialAdmin,
isSetupWizardCompleted,
markSetupWizardCompleted,
};
module.exports = { getSetupStatus, ensureSetupToken, verifySetupToken, createInitialAdmin };
-70
View File
@@ -5,18 +5,9 @@ const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
const MAX_ALLOWED_FILES_PER_UPLOAD = 2000;
const CACHE_TTL_MS = 60_000;
// Per-file upload size limit (general_max_file_size_mb). The admin sets this in
// Settings → General; the default mirrors the frontend's default (50 MB). A
// hard ceiling keeps a fat-fingered value from disabling multer's guard.
const DEFAULT_MAX_FILE_SIZE_MB = 50;
const MAX_ALLOWED_FILE_SIZE_MB = 10 * 1024; // 10 GB — matches the admin path's cap
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
let cacheExpiresAt = 0;
let cachedFileSizeMb = DEFAULT_MAX_FILE_SIZE_MB;
let fileSizeCacheExpiresAt = 0;
// Map of file extension to MIME type(s)
const EXTENSION_TO_MIME = {
'jpg': 'image/jpeg',
@@ -29,16 +20,6 @@ const EXTENSION_TO_MIME = {
'webm': 'video/webm',
'mov': 'video/quicktime',
'avi': 'video/x-msvideo',
// HEIC/HEIF (iPhone). Sharp's bundled libvips decodes `heif` input, so
// thumbnails generate fine. (iOS Safari usually transcodes to JPEG at file
// selection, but a genuine .heic upload is handled when it does arrive.)
'heic': 'image/heic',
'heif': 'image/heif',
// Camera RAW / Apple ProRAW. Not sharp-decodable directly — the processing
// pipeline extracts the embedded JPEG preview (exiftool) for thumbnails/
// display, keeping the original for download. Browsers send DNG as
// image/x-adobe-dng, image/tiff, or an empty type, so accept the common set.
'dng': 'image/x-adobe-dng',
};
const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp';
@@ -118,52 +99,6 @@ const clearMaxFilesPerUploadCache = () => {
cacheExpiresAt = 0;
};
const normalizeFileSizeMb = (value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return DEFAULT_MAX_FILE_SIZE_MB;
}
const intValue = Math.floor(value);
if (intValue < 1) return DEFAULT_MAX_FILE_SIZE_MB;
if (intValue > MAX_ALLOWED_FILE_SIZE_MB) return MAX_ALLOWED_FILE_SIZE_MB;
return intValue;
};
/**
* Per-file upload size limit in MB (general_max_file_size_mb). Cached 60s, same
* as the other upload settings. Falls back to the default on a read error.
*/
const getMaxFileSizeMb = async () => {
if (Date.now() < fileSizeCacheExpiresAt) {
return cachedFileSizeMb;
}
try {
const setting = await db('app_settings')
.where({ setting_key: 'general_max_file_size_mb' })
.first();
const parsedValue = normalizeFileSizeMb(parseSettingValue(setting));
cachedFileSizeMb = parsedValue;
fileSizeCacheExpiresAt = Date.now() + CACHE_TTL_MS;
return parsedValue;
} catch (error) {
logger.error('Failed to read max file size setting:', error.message);
cachedFileSizeMb = DEFAULT_MAX_FILE_SIZE_MB;
fileSizeCacheExpiresAt = Date.now() + CACHE_TTL_MS;
return DEFAULT_MAX_FILE_SIZE_MB;
}
};
/** Per-file upload size limit in bytes — convenience for multer `limits.fileSize`. */
const getMaxFileSizeBytes = async () => {
const mb = await getMaxFileSizeMb();
return mb * 1024 * 1024;
};
const clearMaxFileSizeCache = () => {
fileSizeCacheExpiresAt = 0;
};
/**
* Convert a comma-separated list of file extensions into an array of MIME types.
* Unknown extensions are silently ignored.
@@ -229,16 +164,11 @@ const clearAllowedTypesCache = () => {
module.exports = {
getMaxFilesPerUpload,
clearMaxFilesPerUploadCache,
getMaxFileSizeMb,
getMaxFileSizeBytes,
clearMaxFileSizeCache,
getAllowedMimeTypes,
clearAllowedTypesCache,
extensionsToMimeTypes,
EXTENSION_TO_MIME,
DEFAULT_MAX_FILES_PER_UPLOAD,
MAX_ALLOWED_FILES_PER_UPLOAD,
DEFAULT_MAX_FILE_SIZE_MB,
MAX_ALLOWED_FILE_SIZE_MB,
DEFAULT_ALLOWED_FILE_TYPES
};
@@ -459,13 +459,6 @@ async function resetAdminPassword(id, resetById) {
throw new NotFoundError('Admin user', id);
}
// OIDC-owned accounts (#798) have no usable local password by design —
// minting one here would hand out a login that bypasses the IdP's MFA
// and access policies.
if (user.auth_provider === 'oidc') {
throw new ValidationError('This account is managed by your identity provider (SSO) — reset the password there.');
}
const newPassword = generateReadablePassword();
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
@@ -11,7 +11,7 @@
const { db } = require('../database/db');
const watermarkService = require('./watermarkService');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
const { withLocalCopy, isRawFilename } = require('./imageProcessor');
const { withLocalCopy } = require('./imageProcessor');
const logger = require('../utils/logger');
class WatermarkGeneratorService {
@@ -52,14 +52,6 @@ class WatermarkGeneratorService {
return { success: false, error: 'Videos do not support watermarks' };
}
// Skip RAW/DNG (experimental, #821). The watermark path opens the original
// with sharp, which can't decode RAW — proceeding would fall back to the
// original bytes and falsely record the copy as watermarked. Skipping keeps
// the watermark state honest until RAW watermarking is properly supported.
if (isRawFilename(photo.filename)) {
return { success: false, error: 'RAW/DNG files are not watermarked yet' };
}
// Get watermark settings
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
+2 -55
View File
@@ -46,18 +46,11 @@ function outEdge(edges, fromNode, handle) {
function computeWakeAt(config = {}, vars = {}) {
const cfg = config || {};
if (cfg.untilVar && vars[cfg.untilVar]) return new Date(vars[cfg.untilVar]).toISOString();
const ms = (Number(cfg.delayDays || 0) * 86400000)
+ (Number(cfg.delayHours || 0) * 3600000)
+ (Number(cfg.delayMinutes || 0) * 60000);
// Anchor to a context var when given (e.g. dueDate), plus any delay offset —
// so `{ untilVar: 'dueDate', delayDays: 7 }` means "due date + 7 days"
// (absolute), and an already-past anchor resumes immediately. Backward
// compatible: untilVar-only → the var; delay-only → now + delay. Behaviour
// change for the both-fields case (untilVar + delay): previously the delay was
// ignored and only the var returned; now they add (this is the intended
// waitGrace semantics — no seeded node relied on the old both-fields path).
const base = (cfg.untilVar && vars[cfg.untilVar]) ? new Date(vars[cfg.untilVar]) : new Date();
return new Date(base.getTime() + ms).toISOString();
return new Date(Date.now() + ms).toISOString();
}
function gateTimeout(config = {}) {
@@ -428,51 +421,6 @@ async function isBuiltinFlowActive(builtinKey) {
}
}
/**
* Enroll every open, unpaid invoice into the dunning flow by emitting
* `invoice.sent` for it called when the dunning built-in is turned ON so it
* starts chasing invoices that were already sent, not only new ones (#750).
* Idempotent: emitWorkflowEvent's per-(flow, entity) dedup means at most one
* run per invoice, so re-enabling is safe. Paired with the due-date-anchored
* grace wait, already-overdue invoices dun on their real timeline immediately.
*
* Scoped to `targetWorkflowId` (the dunning flow being enabled) so the backfill
* only enrolls invoices into dunning never into unrelated custom `invoice.sent`
* flows an admin may have built, which would fire their actions for every
* historical invoice.
*/
async function backfillDunningRuns(targetWorkflowId) {
let enrolled = 0;
try {
if (!(await db.schema.hasTable('invoices'))) return 0;
const invoices = await db('invoices')
.whereIn('status', ['sent', 'overdue'])
.whereNotNull('due_date')
.whereRaw('COALESCE(paid_amount_minor, 0) < total_amount_minor');
for (const inv of invoices) {
const ids = await emitWorkflowEvent('invoice.sent', {
entityType: 'invoice',
entityId: inv.id,
targetWorkflowId,
payload: {
invoiceId: inv.id,
invoiceNumber: inv.invoice_number,
eventId: inv.event_id || null,
customerAccountId: inv.customer_account_id,
dueDate: inv.due_date,
issueDate: inv.issue_date,
totalMinor: inv.total_amount_minor,
currency: inv.currency,
},
});
if (ids && ids.length) enrolled += 1;
}
} catch (e) {
logger.error('[workflow] dunning backfill failed', { error: e.message });
}
return enrolled;
}
/**
* Emit `event.date_approaching` for events entering an enabled flow's lead
* window. This is the trigger source for the pre-event reminder built-in, so it
@@ -597,7 +545,6 @@ async function testRun(workflowId, { entityType = null, entityId = null, payload
module.exports = {
emitWorkflowEvent,
isBuiltinFlowActive,
backfillDunningRuns,
runDueWaits,
emitDueEventReminders,
recoverStaleRuns,
-63
View File
@@ -1,63 +0,0 @@
/**
* Category order resolution (#782).
*
* Resolves an event's categories into their effective display order, layering:
* 1. per-event override event_category_order.position, when the event has
* been customised;
* 2. the global default photo_categories.display_order (migration 159);
* 3. name.
*
* Globals and event-specific categories are ordered together so a custom order
* can interleave them into the flow of the day. Shared by the admin event view
* and the public gallery so the two never diverge.
*/
const { db } = require('../database/db');
const { formatBoolean } = require('./dbCompat');
const { hasColumnCached } = require('./schemaCache');
/**
* @param {number|string} eventId
* @param {object} [opts]
* @param {number[]|null} [opts.onlyIds] restrict to these category ids (the
* public gallery only shows categories that actually have photos).
* @param {string[]|null} [opts.select] qualified columns to select (default
* `c.*`). Always aliased to the `photo_categories as c` table.
* @returns rows with an added `override_position` (null when not customised).
*/
async function getEventCategoriesOrdered(eventId, { onlyIds = null, select = null } = {}) {
const eid = parseInt(eventId, 10);
const base = db('photo_categories as c').where(function () {
this.where('c.is_global', formatBoolean(true)).orWhere('c.event_id', eid);
});
if (onlyIds) base.whereIn('c.id', onlyIds);
// Fail safe: if the override table isn't present yet (half-applied migration),
// fall back to the global-default order so the public gallery never 500s.
const overrideReady = await hasColumnCached('event_category_order', 'position');
if (!overrideReady) {
return base
.select(select || 'c.*')
.orderBy('c.is_global', 'desc')
.orderBy('c.display_order', 'asc')
.orderBy('c.name', 'asc');
}
const cols = select ? [...select] : ['c.*'];
cols.push('o.position as override_position');
return base
.leftJoin('event_category_order as o', function () {
this.on('o.category_id', 'c.id').andOnVal('o.event_id', '=', eid);
})
.select(cols)
// Overridden categories first (in their pinned order), then the rest by the
// global default. CASE keeps NULL-ordering portable across SQLite + Postgres.
.orderByRaw('CASE WHEN o.position IS NULL THEN 1 ELSE 0 END ASC')
.orderBy('o.position', 'asc')
.orderBy('c.is_global', 'desc')
.orderBy('c.display_order', 'asc')
.orderBy('c.name', 'asc');
}
module.exports = { getEventCategoriesOrdered };
+20 -33
View File
@@ -79,39 +79,6 @@ const ALLOWED_IMAGE_TYPES = {
extensions: ['.svg'],
// SVG files are XML-based text files, so we skip magic number validation
magicNumbers: null
},
// HEIC/HEIF (iPhone). ISO-BMFF container: bytes 4-7 are the "ftyp" box marker,
// present in every HEIF/HEIC file (single entry — the magic check is `.every`,
// so alternatives can't be listed as separate entries). Sharp's libvips
// decodes these; extension + MIME are already gated by validateFileType.
'image/heic': {
extensions: ['.heic'],
magicNumbers: [
{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // "ftyp"
]
},
'image/heif': {
extensions: ['.heif'],
magicNumbers: [
{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // "ftyp"
]
},
// Camera RAW / Apple ProRAW (#821). DNG is a TIFF container, so it carries the
// TIFF magic (little-endian "II*\0" or big-endian "MM\0*"). The pipeline can't
// sharp-decode it directly — it extracts the embedded JPEG preview (exiftool)
// for thumbnails/display while storing the original for download. Only reached
// when an admin adds `dng` to the allowed types AND the browser reports the
// DNG MIME (Chrome does; browsers that send an empty type won't get this far).
'image/x-adobe-dng': {
extensions: ['.dng'],
// Single entry: the magic check is `.every`, so listing both endianness
// variants would require BOTH to match (impossible). DNG is TIFF; Apple
// ProRAW and virtually all camera DNGs are little-endian ("II*\0"). A rare
// big-endian DNG would fail this check and be rejected — acceptable, since
// the embedded-preview extraction validates the real content downstream.
magicNumbers: [
{ offset: 0, bytes: [0x49, 0x49, 0x2A, 0x00] } // little-endian TIFF (II*\0)
]
}
};
@@ -213,6 +180,25 @@ async function validateFileContent(filePath, expectedMimeType) {
}
}
/**
* Get safe filename for storage
* @param {string} originalFilename - Original filename
* @returns {string} - Safe filename
*/
function getSafeFilename(originalFilename) {
const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 15);
const ext = path.extname(originalFilename).toLowerCase();
// Validate extension - including both image and video extensions
const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico', '.mp4', '.m4v', '.webm', '.mov', '.avi'];
if (!validExtensions.includes(ext)) {
throw new Error('Invalid file extension');
}
return `upload_${timestamp}_${randomString}${ext}`;
}
/**
* Create a file upload validator middleware
* @param {Object} options - Validation options
@@ -276,6 +262,7 @@ module.exports = {
isPathSafe,
validateFileType,
validateFileContent,
getSafeFilename,
createFileUploadValidator,
ALLOWED_IMAGE_TYPES,
ALLOWED_VIDEO_TYPES,
+7 -19
View File
@@ -123,32 +123,20 @@ async function getPasswordComplexitySettings() {
// Use retry wrapper to handle connection failures
const settings = await withRetry(async () => {
// Key must match what the settings UI writes: `security_` prefix +
// `password_complexity` (useSettingsState.ts saveSecurityMutation).
// The old `security_password_complexity_level` key is written by
// nothing, so the admin's choice was silently ignored.
return await db('app_settings')
.where('setting_key', 'security_password_complexity')
.where('setting_key', 'security_password_complexity_level')
.first();
});
if (!settings || !settings.setting_value) {
return 'moderate'; // Default
}
// Parse with fallback, mirroring getAppSetting: on SQLite the TEXT
// column returns the JSON-stringified value ('"very_strong"'), but on
// Postgres the json column comes back already decoded ('very_strong')
// — a bare JSON.parse would throw there and the outer catch would
// silently fall back to 'moderate' again.
let value = settings.setting_value;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (_) { /* already-decoded plain string — keep as-is */ }
}
return value || 'moderate';
const value = typeof settings.setting_value === 'string'
? JSON.parse(settings.setting_value)
: settings.setting_value;
return value;
} catch (error) {
logger.error('Failed to get password complexity settings:', error);
return 'moderate'; // Default on error - ensures app continues working
-34
View File
@@ -118,41 +118,7 @@ function assertContractPdfPath(filePath) {
]);
}
/**
* ZIP-slip guard. `node-stream-zip`'s `extract(null, root)` writes each entry
* to `path.join(root, entry.name)` without neutralising `../` a crafted
* archive with an entry named `../../uploads/logos/evil.svg` escapes `root`
* and overwrites arbitrary files (GHSA-jfhw-fj23-fx6x). Call this with the
* entry list BEFORE extract() to reject any entry that resolves outside the
* target directory.
*
* Purely lexical (path.resolve, no realpath) because the extraction target
* does not exist on disk yet. Absolute entry names (`/etc/passwd`) resolve
* away from `root` and are caught too. Throws AppError 400 on the first
* offending entry so the whole archive is refused.
*
* @param {Array<{name?: string}>} entries node-stream-zip entry objects
* @param {string} extractRoot directory extract() will write into
*/
function assertZipEntriesWithin(entries, extractRoot) {
const rootResolved = path.resolve(extractRoot);
const prefix = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep;
for (const entry of entries || []) {
const name = entry && entry.name;
if (!name) continue;
const target = path.resolve(rootResolved, name);
if (target !== rootResolved && !target.startsWith(prefix)) {
throw new AppError(
`Archive contains an entry that escapes the extraction directory: ${name}`,
400,
'ZIP_SLIP'
);
}
}
}
module.exports = {
assertPathInside,
assertContractPdfPath,
assertZipEntriesWithin,
};
-100
View File
@@ -1,100 +0,0 @@
/**
* Global session cutoff.
*
* A .picpeak restore rewrites admin_users / customer_accounts / events and can
* reassign their primary keys, so any JWT issued BEFORE the restore may now
* resolve to a different restored principal (auth middleware binds a token to
* `decoded.id`; IP is only logged and the backup controls each row's
* `password_changed_at`). Revoking the single importing token is not enough
* every pre-restore admin, customer, and gallery session must stop being
* honoured.
*
* We record a single unix-second cutoff in app_settings and reject any token
* whose `iat` predates it, across all three JWT auth paths. The operator's
* forced re-login mints a token with `iat >= cutoff`, so it passes; everything
* issued earlier is refused. The value is cached briefly so the common auth
* path stays a single in-memory comparison.
*/
const { db } = require('../database/db');
const logger = require('./logger');
const CUTOFF_KEY = 'security_sessions_valid_after';
const CACHE_MS = 30 * 1000; // restores are rare; a short TTL keeps auth cheap
let cache = null; // { value: number, expiry: number }
async function readCutoffFromDb() {
const row = await db('app_settings')
.where('setting_key', CUTOFF_KEY)
.first()
.timeout(5000);
if (!row || row.setting_value == null) return 0;
let value = row.setting_value;
// pg `json` returns a parsed number; sqlite returns the stored string.
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch (_) { /* fall through to parseInt */ }
}
const seconds = parseInt(value, 10);
return Number.isFinite(seconds) ? seconds : 0;
}
/**
* Cutoff as unix seconds (0 = no cutoff set). Cached for CACHE_MS. On a
* transient DB error, returns the last known value (or 0) rather than blocking
* auth the cutoff is defence-in-depth layered on top of per-token revocation.
*/
async function getSessionsValidAfter() {
const now = Date.now();
if (cache && now < cache.expiry) return cache.value;
try {
const value = await readCutoffFromDb();
cache = { value, expiry: now + CACHE_MS };
return value;
} catch (err) {
logger.warn('[sessionCutoff] failed to read cutoff:', err.message);
return cache ? cache.value : 0;
}
}
/** Persist a new cutoff (unix seconds) and refresh the in-process cache. */
async function setSessionsValidAfter(unixSeconds) {
await db('app_settings')
.insert({
setting_key: CUTOFF_KEY,
setting_value: JSON.stringify(unixSeconds),
setting_type: 'number',
updated_at: new Date(),
})
.onConflict('setting_key')
.merge({ setting_value: JSON.stringify(unixSeconds), setting_type: 'number', updated_at: new Date() });
cache = { value: unixSeconds, expiry: Date.now() + CACHE_MS };
}
/**
* True when this token was issued before the global cutoff. Fail-open on any
* error: the cutoff is defence-in-depth on top of per-token revocation and the
* post-restore cookie clear, and must never turn a transient read failure into
* an auth outage.
*/
async function isTokenBeforeCutoff(decoded) {
try {
if (!decoded || !decoded.iat) return false;
const cutoff = await getSessionsValidAfter();
if (!cutoff) return false;
return decoded.iat < cutoff;
} catch (err) {
logger.warn('[sessionCutoff] check failed, allowing token:', err.message);
return false;
}
}
/** Test-only: drop the in-process cache. */
function _resetCache() { cache = null; }
module.exports = {
CUTOFF_KEY,
getSessionsValidAfter,
setSessionsValidAfter,
isTokenBeforeCutoff,
_resetCache,
};
-2
View File
@@ -100,8 +100,6 @@ services:
- STORAGE_PATH=/app/storage
- PHOTOS_DIR=/app/storage/events
- PICPEAK_RELEASE_CHANNEL=${PICPEAK_CHANNEL:-stable}
# Watch-folder auto-import: max photos processed in parallel (default 2).
- FILE_WATCHER_CONCURRENCY=${FILE_WATCHER_CONCURRENCY:-2}
volumes:
- ${APP_STORAGE}:/app/storage
- ${LOGS}:/app/logs

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