Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1ae562a37 | |||
| c153b5b891 |
@@ -1,4 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
buy_me_a_coffee: theluap
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.89.0-beta.0"
|
||||
".": "3.79.1-beta.0"
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
{".":"3.44.0"}
|
||||
{
|
||||
".": "2.6.1"
|
||||
}
|
||||
|
||||
-213
@@ -5,219 +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.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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([0c2d319](https://github.com/PicPeak/picpeak/commit/0c2d319fc1ed67843cc60afdcaea5807ea49226f))
|
||||
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([fcc3e91](https://github.com/PicPeak/picpeak/commit/fcc3e9195d6f63b2dffddfa72a867a3e32325e81))
|
||||
* **email:** sibling billing emails follow customer language too ([c0008be](https://github.com/PicPeak/picpeak/commit/c0008be39bc8a9d354e48ce8d6bd89662bc53ebb))
|
||||
|
||||
## [3.82.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.2-beta.0...v3.82.3-beta.0) (2026-07-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([a88da99](https://github.com/PicPeak/picpeak/commit/a88da99c8d35c0c7cb7f96a235e984edad74ac7c))
|
||||
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([96fe478](https://github.com/PicPeak/picpeak/commit/96fe478bf87a3350185206b3d6f15133138b995d))
|
||||
* **branding:** unify hero logo SIZE the same way as visibility ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([60b03b1](https://github.com/PicPeak/picpeak/commit/60b03b17287539b3ad5e5d32f4eda8622f0575e4))
|
||||
|
||||
## [3.82.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.1-beta.0...v3.82.2-beta.0) (2026-07-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **og:** broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.) ([a0a28a4](https://github.com/PicPeak/picpeak/commit/a0a28a47777db9ca9e60a5134c8d86503c060e79))
|
||||
* **og:** route branded short URLs + slideshow links to OG, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([0dffe0c](https://github.com/PicPeak/picpeak/commit/0dffe0ce92339e0608b3ef660e84c31a62f4a98c))
|
||||
* **og:** route branded short URLs + slideshow to OG handler, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([a87ad77](https://github.com/PicPeak/picpeak/commit/a87ad77d8d5215c88f5d95cc7aebaa1769938ec0))
|
||||
|
||||
## [3.82.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.0-beta.0...v3.82.1-beta.0) (2026-07-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **invoices:** correct payment-check email template key so dunning email sends ([9a76333](https://github.com/PicPeak/picpeak/commit/9a763337b658299aae0d7c985071c4a775000f99))
|
||||
* **invoices:** correct payment-check email template key so dunning email sends ([3682de1](https://github.com/PicPeak/picpeak/commit/3682de195b46eae692db3ff4a1476b00d3a6e216))
|
||||
|
||||
## [3.82.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.81.0-beta.0...v3.82.0-beta.0) (2026-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **setup:** final community step ([#732](https://github.com/PicPeak/picpeak/issues/732)) + fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([a5f49e3](https://github.com/PicPeak/picpeak/commit/a5f49e32350564ee4d3894f33e9611e9244cc994))
|
||||
* **setup:** final community/thank-you step ([#732](https://github.com/PicPeak/picpeak/issues/732)); fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([dadaaee](https://github.com/PicPeak/picpeak/commit/dadaaeea7781cb62811256b512003e5c4d6ad95e))
|
||||
|
||||
## [3.81.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.80.0-beta.0...v3.81.0-beta.0) (2026-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* admin two-factor authentication (TOTP) with recovery codes + CLI reset ([cf07361](https://github.com/PicPeak/picpeak/commit/cf073615effa8a91e19374ad3e9924e6e7322950))
|
||||
* **admin-ui:** TOTP MFA enrollment + two-step login; remove stub 2FA toggle ([96e3c68](https://github.com/PicPeak/picpeak/commit/96e3c68b9d6b35a82abcad664a6da7b19150b4fd))
|
||||
* **auth:** admin TOTP MFA — enrollment, login challenge, recovery, CLI reset ([72e2ef6](https://github.com/PicPeak/picpeak/commit/72e2ef6721b0572ed34455de901aa357eacd8c76))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* event creation 500s on PostgreSQL (NaN slideshow seed) + stray "0" boolean renders ([b187f58](https://github.com/PicPeak/picpeak/commit/b187f588b4d12af7a7849f8558c0085573d4af76))
|
||||
* **security:** close cross-event thumbnail leak, bulk-op ownership bypass, + hardening ([081f3ed](https://github.com/PicPeak/picpeak/commit/081f3edcdffc65a77000cc638e364ea9dc03767f))
|
||||
* **security:** cross-event thumbnail leak, bulk-op ownership bypass + auth hardening ([b732974](https://github.com/PicPeak/picpeak/commit/b732974779803b67097c81ae6bce2de0f2910794))
|
||||
|
||||
## [3.80.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.1-beta.0...v3.80.0-beta.0) (2026-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backup:** fold .picpeak restore into the Restore wizard's Upload source ([86324e7](https://github.com/PicPeak/picpeak/commit/86324e7da75069e61686b1b77495f02c33b12e1a))
|
||||
* first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip ([e513e83](https://github.com/PicPeak/picpeak/commit/e513e8345b73e37ebedc9c9ec09665ffc5773e23))
|
||||
* **setup:** add restore-from-backup branch to the first-run wizard ([a95ee47](https://github.com/PicPeak/picpeak/commit/a95ee473ae7fb2bf3c94c610724476701145d113))
|
||||
* **setup:** per-feature config step after feature selection ([07b450a](https://github.com/PicPeak/picpeak/commit/07b450a954a53781d23a71749552e4101c637777))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backup:** address .picpeak review — table filter, superuser guard, tests ([fa7665c](https://github.com/PicPeak/picpeak/commit/fa7665c5b1ad18a4db4f0b59eb4c197a3c9a36e2))
|
||||
* **setup:** keep the first-run wizard light regardless of dark mode ([d4b143f](https://github.com/PicPeak/picpeak/commit/d4b143f313d00f2a30abcbc1a880c140d7455e7b))
|
||||
|
||||
## [3.79.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.0-beta.0...v3.79.1-beta.0) (2026-07-02)
|
||||
|
||||
|
||||
|
||||
+4
-18
@@ -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.
|
||||
|
||||
@@ -9,16 +9,6 @@ PORT=3001
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
|
||||
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
|
||||
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
|
||||
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
|
||||
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
|
||||
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
|
||||
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
|
||||
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
# Generate with: openssl rand -base64 32
|
||||
#MFA_ENCRYPTION_KEY=
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: 'auto' in production, false in dev (#427)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
|
||||
|
||||
+1
-8
@@ -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
|
||||
|
||||
@@ -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 event’s 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,94 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// Validates the engine-neutral .picpeak export: it must produce a real zip with
|
||||
// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo
|
||||
// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let createPicpeak;
|
||||
|
||||
// bootCrmDb MUST run before requiring the service (which transitively requires
|
||||
// db.js) so the export reads this test's DB, not the default path.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
async function readZip(filePath) {
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const entries = Object.keys(await zip.entries());
|
||||
const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
|
||||
await zip.close();
|
||||
return { entries, manifest };
|
||||
}
|
||||
|
||||
describe('picpeak export (.picpeak logical export)', () => {
|
||||
it('produces a .picpeak with a manifest and per-table NDJSON', async () => {
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
expect(filePath.endsWith('.picpeak')).toBe(true);
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
|
||||
expect(manifest.format).toBe(1);
|
||||
expect(manifest.kind).toBe('picpeak-backup');
|
||||
expect(manifest.database.engine).toBe('sqlite');
|
||||
expect(manifest.options.includePhotos).toBe(false);
|
||||
expect(manifest.contains_secrets).toBe(true);
|
||||
// Migrations seed real tables (e.g. app_settings) — expect several.
|
||||
expect(Object.keys(manifest.tables).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(manifest.tables)).toContain('app_settings');
|
||||
|
||||
const { entries, manifest: zipped } = await readZip(filePath);
|
||||
expect(entries).toContain('manifest.json');
|
||||
expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true);
|
||||
expect(entries).toContain('data/app_settings.ndjson');
|
||||
// Manifest inside the zip matches the returned one.
|
||||
expect(zipped.tables).toEqual(manifest.tables);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never exports knex bookkeeping tables', async () => {
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
const names = Object.keys(manifest.tables);
|
||||
expect(names).not.toContain('knex_migrations');
|
||||
expect(names).not.toContain('knex_migrations_lock');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('row counts in the manifest match the NDJSON line counts', async () => {
|
||||
// Insert a couple of settings so at least one table is non-empty.
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' })
|
||||
.onConflict('setting_key').merge();
|
||||
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const buf = await zip.entryData('data/app_settings.ndjson');
|
||||
await zip.close();
|
||||
const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0);
|
||||
expect(lines.length).toBe(manifest.tables.app_settings.rowCount);
|
||||
expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// Full .picpeak roundtrip on a temp SQLite DB:
|
||||
// 1. seed a "backup" instance (admin A + a marker setting)
|
||||
// 2. export → .picpeak
|
||||
// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data
|
||||
// 4. import the backup with currentAdminId = B
|
||||
// 5. assert the backup data is restored AND the current account (B) survives,
|
||||
// while the backup's admin (A) is also present (different email → added).
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let createPicpeak;
|
||||
let importFromPicpeak;
|
||||
let validateManifest;
|
||||
let superAdminRoleId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir;
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
superAdminRoleId = role.id;
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
const adminRow = (email, hash) => ({
|
||||
username: email,
|
||||
email,
|
||||
password_hash: hash,
|
||||
role_id: superAdminRoleId,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
async function setMarker(value) {
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' })
|
||||
.onConflict('setting_key').merge();
|
||||
}
|
||||
async function getMarker() {
|
||||
const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first();
|
||||
return row ? JSON.parse(row.setting_value) : null;
|
||||
}
|
||||
|
||||
describe('.picpeak roundtrip (export → import)', () => {
|
||||
it('restores backup data and preserves the current account', async () => {
|
||||
// 1. Seed the "source" instance.
|
||||
await db('admin_users').del();
|
||||
await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A'));
|
||||
await setMarker('from_backup');
|
||||
|
||||
// 2. Export.
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
|
||||
try {
|
||||
// 3. Simulate a reinstall: fresh current admin B, mutated data.
|
||||
await db('admin_users').del();
|
||||
const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id');
|
||||
const currentAdminId = typeof bId === 'object' ? bId.id : bId;
|
||||
await setMarker('mutated_after_backup');
|
||||
|
||||
// 4. Import, preserving the current admin.
|
||||
const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId });
|
||||
expect(result.restored).toBe(true);
|
||||
expect(result.tables).toBeGreaterThan(0);
|
||||
|
||||
// 5a. Backup data restored (marker reverted to the backup value).
|
||||
expect(await getMarker()).toBe('from_backup');
|
||||
|
||||
// 5b. The backup's admin is present (different email → added).
|
||||
const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first();
|
||||
expect(a).toBeTruthy();
|
||||
expect(a.password_hash).toBe('HASH_A');
|
||||
|
||||
// 5c. The current account SURVIVES the override, with its own credentials.
|
||||
const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first();
|
||||
expect(b).toBeTruthy();
|
||||
expect(b.password_hash).toBe('HASH_B');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('overwrites a backup admin that collides with the current account email', async () => {
|
||||
// Source has an admin at the SAME email the current operator will use.
|
||||
await db('admin_users').del();
|
||||
await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH'));
|
||||
await setMarker('collision_case');
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
|
||||
try {
|
||||
// Reinstall: current admin uses the same email but a NEW password.
|
||||
await db('admin_users').del();
|
||||
const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id');
|
||||
const currentAdminId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
|
||||
|
||||
// Exactly one admin at that email, and it keeps the CURRENT password.
|
||||
const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].password_hash).toBe('NEW_HASH');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('restores files/ and reports filesRestored', async () => {
|
||||
// A business-doc that lives in storage → travels in the backup.
|
||||
const docDir = path.join(tmpDir, 'business-docs');
|
||||
const marker = path.join(docDir, 'roundtrip-doc.txt');
|
||||
fs.mkdirSync(docDir, { recursive: true });
|
||||
fs.writeFileSync(marker, 'hello');
|
||||
await db('admin_users').del();
|
||||
const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id');
|
||||
const currentAdminId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
fs.rmSync(marker); // delete on disk so the restore must bring it back
|
||||
const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
|
||||
expect(result.filesRestored).toBeGreaterThanOrEqual(1);
|
||||
expect(fs.existsSync(marker)).toBe(true);
|
||||
expect(fs.readFileSync(marker, 'utf8')).toBe('hello');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
fs.rmSync(docDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('.picpeak manifest validation', () => {
|
||||
it('rejects a database-engine mismatch', async () => {
|
||||
// Harness runs on SQLite, so a pg manifest must be refused.
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
|
||||
});
|
||||
expect(blockers.some((b) => /engine/i.test(b))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a backup from a newer schema (forward-only)', async () => {
|
||||
// validateManifest reads knex_migrations for the target's latest migration;
|
||||
// the harness has none, so create it with an older migration than the backup.
|
||||
await db.schema.createTable('knex_migrations', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name');
|
||||
t.integer('batch');
|
||||
t.timestamp('migration_time');
|
||||
});
|
||||
try {
|
||||
await db('knex_migrations').insert({ name: '100_baseline', batch: 1 });
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1,
|
||||
database: { engine: 'sqlite', latest_migration: '999_from_the_future' },
|
||||
tables: {},
|
||||
});
|
||||
expect(blockers.some((b) => /newer/i.test(b))).toBe(true);
|
||||
} finally {
|
||||
await db.schema.dropTableIfExists('knex_migrations');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a file that is not a PicPeak backup', async () => {
|
||||
const blockers = await validateManifest({ some: 'random-json' });
|
||||
expect(blockers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738).
|
||||
*
|
||||
* Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs
|
||||
* the script in a child process (--email <addr> --yes) pointed at the same
|
||||
* DB file, and asserts the four MFA columns are zeroed. The script runs in
|
||||
* its own process with its own knex connection; the parent connection is
|
||||
* idle during the spawn so the SQLite write lock isn't contended.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js');
|
||||
|
||||
async function seedEnrolledAdmin(email) {
|
||||
const inserted = await db('admin_users').insert({
|
||||
username: email.split('@')[0],
|
||||
email,
|
||||
password_hash: 'x',
|
||||
is_active: true,
|
||||
two_factor_enabled: true,
|
||||
two_factor_secret: 'iv.tag.ct',
|
||||
two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']),
|
||||
two_factor_enrolled_at: new Date(),
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
}
|
||||
|
||||
it('zeroes the four MFA columns for the targeted admin', async () => {
|
||||
const email = 'reset-me@example.com';
|
||||
const id = await seedEnrolledAdmin(email);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', email, '--yes'], {
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const row = await db('admin_users').where({ id }).first();
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
expect(row.two_factor_enrolled_at).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a different admin untouched', async () => {
|
||||
const targetEmail = 'target@example.com';
|
||||
const bystanderEmail = 'bystander@example.com';
|
||||
const targetId = await seedEnrolledAdmin(targetEmail);
|
||||
const bystanderId = await seedEnrolledAdmin(bystanderEmail);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], {
|
||||
env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const target = await db('admin_users').where({ id: targetId }).first();
|
||||
const bystander = await db('admin_users').where({ id: bystanderId }).first();
|
||||
expect(Number(target.two_factor_enabled)).toBe(0);
|
||||
expect(Number(bystander.two_factor_enabled)).toBe(1);
|
||||
expect(bystander.two_factor_secret).toBe('iv.tag.ct');
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Regression test for the bulk archive/delete ownership bypass.
|
||||
*
|
||||
* bulk-archive and bulk-delete acted on body-supplied event ids with no
|
||||
* ownership filter, so an admin/editor scoped to their own events (the
|
||||
* single-event routes enforce requireEventOwnership) could archive or
|
||||
* cascade-delete ANY event by id. filterOwnedEventIds is the helper those
|
||||
* routes now use to drop foreign/non-existent ids.
|
||||
*/
|
||||
|
||||
// events owned by admin 7; event 3 owned by someone else; event 4 is
|
||||
// ownerless (legacy). The mock models:
|
||||
// whereIn('id', ids).andWhere(created_by IS NULL OR created_by = admin.id)
|
||||
const EVENTS = [
|
||||
{ id: 1, created_by: 7 },
|
||||
{ id: 2, created_by: 7 },
|
||||
{ id: 3, created_by: 99 }, // foreign
|
||||
{ id: 4, created_by: null }, // ownerless/legacy
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => {
|
||||
const q = {
|
||||
_ids: null,
|
||||
_adminId: null,
|
||||
whereIn(_col, ids) { this._ids = ids; return this; },
|
||||
andWhere(cb) {
|
||||
// Emulate the (created_by IS NULL OR created_by = admin.id) builder
|
||||
// by capturing the admin id the callback closes over via a probe.
|
||||
const probe = {
|
||||
_adminId: null,
|
||||
whereNull() { return this; },
|
||||
orWhere(_col, id) { this._adminId = id; return this; },
|
||||
};
|
||||
cb(probe);
|
||||
this._adminId = probe._adminId;
|
||||
return this;
|
||||
},
|
||||
select() {
|
||||
return Promise.resolve(
|
||||
EVENTS
|
||||
.filter((e) => this._ids.includes(e.id))
|
||||
.filter((e) => e.created_by === null || e.created_by === this._adminId)
|
||||
.map((e) => ({ id: e.id }))
|
||||
);
|
||||
},
|
||||
};
|
||||
return q;
|
||||
},
|
||||
}));
|
||||
|
||||
const { filterOwnedEventIds } = require('../../src/middleware/ownership');
|
||||
|
||||
describe('filterOwnedEventIds', () => {
|
||||
it('super_admin gets every id, nothing denied', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'super_admin' }, [1, 3, 4, 999]
|
||||
);
|
||||
expect(allowed).toEqual([1, 3, 4, 999]);
|
||||
expect(denied).toEqual([]);
|
||||
});
|
||||
|
||||
it('non-super_admin keeps owned + ownerless, denies foreign and non-existent', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'admin' }, [1, 2, 3, 4, 999]
|
||||
);
|
||||
expect(allowed.sort()).toEqual([1, 2, 4]); // owns 1,2; 4 is ownerless
|
||||
expect(denied.sort()).toEqual([3, 999]); // 3 foreign, 999 missing
|
||||
});
|
||||
|
||||
it('foreign-only request yields empty allowed', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'editor' }, [3]
|
||||
);
|
||||
expect(allowed).toEqual([]);
|
||||
expect(denied).toEqual([3]);
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Regression test for the cross-event thumbnail enumeration leak.
|
||||
*
|
||||
* Thumbnails are served flat from /thumbnails/thumb_<name> with
|
||||
* deterministic, enumerable filenames. photoAuth previously granted any
|
||||
* holder of a gallery token for ANY active event access to ANY thumbnail
|
||||
* (it set eventSlug=null and returned next() as long as the token's event
|
||||
* existed), so a visitor to one gallery could pull another (password-
|
||||
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
|
||||
* access to the token's event by matching the requested file against
|
||||
* photos.thumbnail_path for that event_id.
|
||||
*/
|
||||
|
||||
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Two events, each owning one thumbnail. The photos mock resolves a row
|
||||
// only when BOTH event_id and thumbnail_path match — i.e. it models the
|
||||
// real ownership query.
|
||||
const EVENTS = [
|
||||
{ id: 10, slug: 'event-a', is_active: 1 },
|
||||
{ id: 20, slug: 'event-b', is_active: 1 },
|
||||
];
|
||||
const PHOTOS = [
|
||||
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
|
||||
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: (table) => ({
|
||||
_cond: null,
|
||||
where(cond) { this._cond = cond; return this; },
|
||||
first() {
|
||||
if (table === 'events') {
|
||||
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
|
||||
}
|
||||
if (table === 'photos') {
|
||||
return Promise.resolve(
|
||||
PHOTOS.find((p) => p.event_id === this._cond.event_id
|
||||
&& p.thumbnail_path === this._cond.thumbnail_path) || null
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const photoAuth = require('../../src/middleware/photoAuth');
|
||||
|
||||
function galleryToken(eventId) {
|
||||
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
}
|
||||
|
||||
function makeReqRes(token, thumbPath) {
|
||||
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
describe('photoAuth — thumbnail ownership scoping', () => {
|
||||
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
// Access denied: middleware must not pass the request through.
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.event).toMatchObject({ id: 20 });
|
||||
});
|
||||
|
||||
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,345 +0,0 @@
|
||||
/**
|
||||
* HTTP-level tests for the admin TOTP MFA feature (#738).
|
||||
*
|
||||
* Two surfaces:
|
||||
* 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable,
|
||||
* GET /mfa/status, POST /mfa/disable — mounted like server.js at
|
||||
* /api/admin/auth (src/routes/adminAuth.js).
|
||||
* 2. Login challenge — POST /admin/login + POST /admin/login/mfa
|
||||
* (src/routes/auth.js, mounted /api/auth).
|
||||
*
|
||||
* Uses the same real-SQLite harness as the CRM route tests
|
||||
* (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are
|
||||
* generated in-test via otplib's authenticator against the secret the
|
||||
* /setup endpoint returns in plaintext.
|
||||
*
|
||||
* NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the
|
||||
* first require of db.js — mirror adminCrmAuth.test.js exactly.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-route-test-secret';
|
||||
// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login
|
||||
// tests don't need a token. Be explicit so a leaked env can't flip it on.
|
||||
delete process.env.RECAPTCHA_SECRET_KEY;
|
||||
|
||||
const request = require('supertest');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
const {
|
||||
bootCrmDb, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminApp; // /api/admin/auth (enrollment)
|
||||
let authApp; // /api/auth (login challenge)
|
||||
|
||||
/**
|
||||
* Seed a bare admin (password known) and return its id + login creds.
|
||||
* seedMinimal always creates username 'tester'; we need distinct rows per
|
||||
* scenario, so insert directly with a unique username/email.
|
||||
*/
|
||||
async function seedAdmin({ username, superAdmin = false } = {}) {
|
||||
const password = 'correct-horse';
|
||||
const passwordHash = await bcrypt.hash(password, 4);
|
||||
const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const row = {
|
||||
username: uname,
|
||||
email: `${uname}@example.com`,
|
||||
password_hash: passwordHash,
|
||||
must_change_password: false,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
};
|
||||
if (superAdmin) {
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
if (!role) throw new Error('super_admin role not seeded');
|
||||
row.role_id = role.id;
|
||||
}
|
||||
const inserted = await db('admin_users').insert(row).returning('id');
|
||||
const id = inserted[0]?.id ?? inserted[0];
|
||||
return { id, username: uname, password };
|
||||
}
|
||||
|
||||
/** Run the full setup→enable enrollment against the live app. Returns
|
||||
* the plaintext TOTP secret (for later login codes) and recovery codes. */
|
||||
async function enroll(adminId) {
|
||||
const token = mintAdminToken(adminId);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(setup.status).toBe(200);
|
||||
const secret = setup.body.secret;
|
||||
|
||||
const enable = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(enable.status).toBe(200);
|
||||
return { secret, recoveryCodes: enable.body.recoveryCodes, token };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
|
||||
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('MFA enrollment — /api/admin/auth/mfa/*', () => {
|
||||
it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.secret).toEqual(expect.any(String));
|
||||
expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(res.body.qr).toMatch(/^data:image\/png;base64,/);
|
||||
|
||||
// Not yet enabled: status must still report disabled.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
|
||||
// And the row stores an encrypted secret (not the plaintext one).
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeTruthy();
|
||||
expect(row.two_factor_secret).not.toBe(res.body.secret);
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
});
|
||||
|
||||
it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
|
||||
expect(Array.isArray(recoveryCodes)).toBe(true);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(10);
|
||||
expect(status.body.enrolledAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('enable with a WRONG code is rejected (400) and MFA stays off', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
const valid = authenticator.generate(setup.body.secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: wrong });
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enable before setup is rejected', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '123456' });
|
||||
// No provisional secret → ValidationError (400).
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('all enrollment endpoints require a valid admin token (401 without one)', async () => {
|
||||
const noToken = await request(adminApp).get('/api/admin/auth/mfa/status');
|
||||
expect(noToken.status).toBe(401);
|
||||
const setup = await request(adminApp).post('/api/admin/auth/mfa/setup');
|
||||
expect(setup.status).toBe(401);
|
||||
});
|
||||
|
||||
// Regression guard for #735: super_admin used to be blocked from enrolling.
|
||||
// Enrollment operates on req.admin.id and is role-agnostic — assert a
|
||||
// super_admin can complete the full setup→enable flow.
|
||||
it('#735 regression — a super_admin can enroll in MFA', async () => {
|
||||
const admin = await seedAdmin({ superAdmin: true });
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA disable — /api/admin/auth/mfa/disable', () => {
|
||||
it('requires a valid code; a wrong code is rejected and state persists', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { token } = await enroll(admin.id);
|
||||
|
||||
const bad = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '000000' });
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
const stillOn = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(stillOn.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('a valid TOTP disables MFA and clears the stored secret', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(0);
|
||||
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBe(true);
|
||||
expect(res.body.mfaToken).toEqual(expect.any(String));
|
||||
expect(res.body.user).toBeUndefined(); // no completed session
|
||||
// No admin auth cookie should have been set on the challenge response.
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.join(';')).not.toMatch(/adminToken/i);
|
||||
});
|
||||
|
||||
it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBeUndefined();
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.username).toBe(admin.username);
|
||||
});
|
||||
|
||||
it('login/mfa with a valid TOTP completes the session', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const { mfaToken } = challenge.body;
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken, code: authenticator.generate(secret) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
const valid = authenticator.generate(secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: challenge.body.mfaToken, code: wrong });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('MFA_INVALID');
|
||||
expect(res.body.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a recovery code logs in and is then single-use (second use fails)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes } = await enroll(admin.id);
|
||||
const recovery = recoveryCodes[0];
|
||||
|
||||
// First challenge + recovery-code exchange succeeds.
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const first = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c1.body.mfaToken, code: recovery });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.user).toBeDefined();
|
||||
|
||||
// recoveryCodesRemaining dropped by one.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${mintAdminToken(admin.id)}`);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(9);
|
||||
|
||||
// Second use of the SAME recovery code must fail.
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const second = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c2.body.mfaToken, code: recovery });
|
||||
expect(second.status).toBe(401);
|
||||
expect(second.body.code).toBe('MFA_INVALID');
|
||||
});
|
||||
|
||||
it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: mintAdminToken(admin.id), code: '123456' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -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,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,193 +0,0 @@
|
||||
/**
|
||||
* Unit tests for mfaService — admin TOTP MFA (#738).
|
||||
*
|
||||
* Pure unit: no DB, no Express. Exercises the crypto/verification surface
|
||||
* directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt
|
||||
* derivation has key material (the service derives the AES key from
|
||||
* MFA_ENCRYPTION_KEY, falling back to JWT_SECRET).
|
||||
*/
|
||||
|
||||
// Must be set BEFORE the service is required — the key is derived lazily per
|
||||
// call, but keep it explicit and stable so encrypt/decrypt round-trips.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret';
|
||||
delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET
|
||||
|
||||
const { authenticator } = require('otplib');
|
||||
const mfaService = require('../../src/services/mfaService');
|
||||
|
||||
describe('mfaService — secret encryption (AES-256-GCM)', () => {
|
||||
it('round-trips encrypt → decrypt to the original secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const blob = mfaService.encryptSecret(secret);
|
||||
expect(blob).toEqual(expect.any(String));
|
||||
expect(blob).not.toContain(secret); // stored form is not plaintext
|
||||
expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext
|
||||
expect(mfaService.decryptSecret(blob)).toBe(secret);
|
||||
});
|
||||
|
||||
it('produces a different ciphertext each time (random IV) but decrypts identically', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const a = mfaService.encryptSecret(secret);
|
||||
const b = mfaService.encryptSecret(secret);
|
||||
expect(a).not.toBe(b);
|
||||
expect(mfaService.decryptSecret(a)).toBe(secret);
|
||||
expect(mfaService.decryptSecret(b)).toBe(secret);
|
||||
});
|
||||
|
||||
it('throws when decrypting a malformed blob (wrong segment count)', () => {
|
||||
expect(() => mfaService.decryptSecret('garbage')).toThrow();
|
||||
expect(() => mfaService.decryptSecret('only.two')).toThrow();
|
||||
});
|
||||
|
||||
it('throws when the auth tag / ciphertext is tampered with', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.');
|
||||
// Flip a character in the ciphertext → GCM auth check must fail.
|
||||
const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA');
|
||||
expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — TOTP verification', () => {
|
||||
it('accepts a freshly generated code for the plaintext secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(code, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates whitespace in the submitted code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotp(wrong, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty inputs rather than throwing', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
expect(mfaService.verifyTotp('', secret)).toBe(false);
|
||||
expect(mfaService.verifyTotp('123456', '')).toBe(false);
|
||||
expect(mfaService.verifyTotp(null, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifies through the encrypted blob (verifyTotpEncrypted)', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const stored = mfaService.encryptSecret(secret);
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true);
|
||||
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — otpauth URI / QR', () => {
|
||||
it('builds an otpauth:// URI containing issuer, account and secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
||||
expect(uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(uri).toContain(encodeURIComponent(mfaService.ISSUER));
|
||||
expect(uri).toContain(`secret=${secret}`);
|
||||
});
|
||||
|
||||
it('builds a PNG data-URL QR for the URI', async () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
||||
const qr = await mfaService.buildQrDataUrl(uri);
|
||||
expect(qr).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — recovery codes', () => {
|
||||
it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(new Set(plain).size).toBe(10);
|
||||
expect(new Set(hashed).size).toBe(10);
|
||||
// Hashes are bcrypt, not the plaintext.
|
||||
hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/));
|
||||
plain.forEach((p) => expect(hashed).not.toContain(p));
|
||||
});
|
||||
|
||||
it('formats a raw code into 4-char groups', () => {
|
||||
expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij');
|
||||
});
|
||||
|
||||
it('consumes a valid recovery code once and removes it (single-use)', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const target = plain[3];
|
||||
|
||||
const first = await mfaService.consumeRecoveryCode(target, hashed);
|
||||
expect(first.matched).toBe(true);
|
||||
expect(first.remainingHashes).toHaveLength(9);
|
||||
|
||||
// Reusing the same code against the reduced set must now fail.
|
||||
const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes);
|
||||
expect(reuse.matched).toBe(false);
|
||||
expect(reuse.remainingHashes).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('matches case-insensitively and trims whitespace', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed);
|
||||
expect(res.matched).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code and leaves the hash set unchanged', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('handles empty / missing input safely', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toBe(hashed);
|
||||
const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null);
|
||||
expect(noHashes.matched).toBe(false);
|
||||
expect(noHashes.remainingHashes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — parseRecoveryCodes', () => {
|
||||
it('parses a JSON string array', () => {
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']);
|
||||
});
|
||||
it('passes an already-array through', () => {
|
||||
expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
it('returns [] for null / garbage / non-array JSON', () => {
|
||||
expect(mfaService.parseRecoveryCodes(null)).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — isEnrolled coercion', () => {
|
||||
it('treats true / 1 / "1" as enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true);
|
||||
});
|
||||
it('treats false / 0 / null / missing as not enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false);
|
||||
expect(mfaService.isEnrolled({})).toBe(false);
|
||||
expect(mfaService.isEnrolled(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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 2–3 pages; a stray blank page (the old margin
|
||||
// bug) or a runaway loop would blow past this.
|
||||
expect(pages).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,48 +0,0 @@
|
||||
/**
|
||||
* Regression tests for clampIntOrUndefined — the slideshow-seed NaN bug.
|
||||
*
|
||||
* The event-create route seeds show_interval_ms/show_transition_ms from
|
||||
* app_settings via an int-parse-and-clamp. The old inline guard
|
||||
* (`Number.isFinite(+v) ? parseInt(v) : undefined`) disagreed with itself
|
||||
* for null/''/true: `+null` is 0 (finite) but `parseInt(null)` is NaN, so
|
||||
* NaN flowed through Math.min/Math.max into the INSERT. PostgreSQL
|
||||
* rejects NaN for integer columns ("invalid input syntax for type
|
||||
* integer: NaN") while SQLite silently stores NULL — so POST
|
||||
* /api/admin/events 500'd on PG whenever the slideshow settings rows
|
||||
* were absent (getAppSetting returns its null default).
|
||||
*/
|
||||
|
||||
const { clampIntOrUndefined } = require('../../src/utils/numericHelpers');
|
||||
|
||||
describe('clampIntOrUndefined', () => {
|
||||
it('returns undefined for null (the getAppSetting missing-row default)', () => {
|
||||
expect(clampIntOrUndefined(null, 1000, 120000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for undefined, empty string, and booleans', () => {
|
||||
expect(clampIntOrUndefined(undefined, 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined('', 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined(true, 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined(false, 1000, 120000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for non-numeric garbage', () => {
|
||||
expect(clampIntOrUndefined('fast', 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined({}, 1000, 120000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never returns NaN for any of the failure-mode inputs', () => {
|
||||
for (const v of [null, undefined, '', true, false, 'x', {}, []]) {
|
||||
const out = clampIntOrUndefined(v, 100, 5000);
|
||||
expect(Number.isNaN(out)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('parses and clamps valid values', () => {
|
||||
expect(clampIntOrUndefined('2500', 1000, 120000)).toBe(2500);
|
||||
expect(clampIntOrUndefined(2500, 1000, 120000)).toBe(2500);
|
||||
expect(clampIntOrUndefined('500', 1000, 120000)).toBe(1000);
|
||||
expect(clampIntOrUndefined(999999, 1000, 120000)).toBe(120000);
|
||||
expect(clampIntOrUndefined('2500.9', 1000, 120000)).toBe(2500);
|
||||
});
|
||||
});
|
||||
@@ -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,58 +0,0 @@
|
||||
/**
|
||||
* Migration 151: admin MFA (TOTP) enrollment support — issue #738.
|
||||
*
|
||||
* The `admin_users.two_factor_enabled` / `two_factor_secret` columns already
|
||||
* exist from the legacy migration 016 but were never wired to any code. This
|
||||
* migration adds the two columns the real TOTP flow needs on top of them:
|
||||
*
|
||||
* - two_factor_recovery_codes: JSON array of one-time backup codes, stored
|
||||
* HASHED (never plaintext), so a locked-out admin can log in without the
|
||||
* authenticator. Consumed on use.
|
||||
* - two_factor_enrolled_at: when the admin completed enrollment (audit /
|
||||
* display only).
|
||||
*
|
||||
* The TOTP secret itself continues to live in the existing `two_factor_secret`
|
||||
* column, but is now stored ENCRYPTED at rest (AES-256-GCM) by mfaService —
|
||||
* the column type is unchanged (the encrypted blob is short).
|
||||
*
|
||||
* Additive and idempotent: only adds columns, guarded by hasColumn, so it is
|
||||
* safe to re-run and touches no existing data.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes');
|
||||
const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at');
|
||||
const hasEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled');
|
||||
const hasSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret');
|
||||
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
// Backfill the legacy columns too, in case an install somehow lacks them
|
||||
// (016 is a legacy migration; guard defensively).
|
||||
if (!hasEnabled) {
|
||||
t.boolean('two_factor_enabled').defaultTo(false);
|
||||
}
|
||||
if (!hasSecret) {
|
||||
t.string('two_factor_secret').nullable();
|
||||
}
|
||||
if (!hasRecovery) {
|
||||
t.text('two_factor_recovery_codes').nullable();
|
||||
}
|
||||
if (!hasEnrolledAt) {
|
||||
t.timestamp('two_factor_enrolled_at').nullable();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes');
|
||||
const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at');
|
||||
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
// Only drop what THIS migration added; leave the legacy 016 columns.
|
||||
if (hasRecovery) {
|
||||
t.dropColumn('two_factor_recovery_codes');
|
||||
}
|
||||
if (hasEnrolledAt) {
|
||||
t.dropColumn('two_factor_enrolled_at');
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Migration 152: make events.hero_logo_visible NULL-able so NULL means
|
||||
* "inherit the global branding_logo_display_hero setting" (#756).
|
||||
*
|
||||
* Before: hero_logo_visible was `boolean NOT NULL DEFAULT true`, and every
|
||||
* event got a concrete true/false snapshotted at creation. The global
|
||||
* "Show logo in hero section" toggle (branding_logo_display_hero) was only a
|
||||
* creation-time default and never affected existing galleries — so disabling
|
||||
* it did nothing to already-published galleries.
|
||||
*
|
||||
* After: NULL = inherit. gallery read-resolution falls back to the global
|
||||
* setting when the per-event value is NULL, so the global toggle controls
|
||||
* every gallery that hasn't been deliberately overridden per-event.
|
||||
*
|
||||
* Data backfill: NULL out the DEFAULTED `true` rows so they start inheriting
|
||||
* the global. A deliberate per-gallery hide (`false`) is kept — we can't tell a
|
||||
* defaulted-true from a chosen-true, but `false` is almost always a conscious
|
||||
* "hide it here", and nulling it could silently re-show a hidden logo.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_visible'))) return;
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible DROP DEFAULT');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible DROP NOT NULL');
|
||||
} else {
|
||||
// SQLite (and others): knex recreates the table without the NOT NULL/default.
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.boolean('hero_logo_visible').nullable().alter();
|
||||
});
|
||||
}
|
||||
|
||||
// Existing defaulted-`true` galleries now inherit the global toggle.
|
||||
await knex('events').where('hero_logo_visible', true).update({ hero_logo_visible: null });
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_visible'))) return;
|
||||
// Re-materialise NULLs as the old default (true) before restoring NOT NULL.
|
||||
await knex('events').whereNull('hero_logo_visible').update({ hero_logo_visible: true });
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible SET DEFAULT true');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible SET NOT NULL');
|
||||
} else {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.boolean('hero_logo_visible').notNullable().defaultTo(true).alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Migration 153: make events.hero_logo_size NULL-able so NULL means "inherit
|
||||
* the global branding_logo_size" (#756 follow-up — the size counterpart of 152).
|
||||
*
|
||||
* Before: hero_logo_size was `varchar NOT NULL DEFAULT 'medium'`, snapshotted
|
||||
* from the global branding_logo_size at creation. The two gallery render paths
|
||||
* then disagreed — GalleryLayout read the global size live, while the
|
||||
* hero-header path used the per-event snapshot — so a hero logo could render at
|
||||
* different sizes on different layouts, and changing the global size didn't
|
||||
* update hero-header galleries.
|
||||
*
|
||||
* After: NULL = inherit. gallery read-resolution falls back to
|
||||
* branding_logo_size when the per-event value is NULL, and both render paths
|
||||
* consume that resolved size.
|
||||
*
|
||||
* Data backfill: NULL out ALL existing hero_logo_size so every gallery inherits
|
||||
* the global size going forward. Unlike a boolean we can't tell a defaulted
|
||||
* value from a chosen one — but nulling is the safe choice here: it restores the
|
||||
* live-global behaviour GalleryLayout already had, and the per-event size can be
|
||||
* re-set from the event's edit page.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_size'))) return;
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size DROP DEFAULT');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size DROP NOT NULL');
|
||||
} else {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.string('hero_logo_size', 20).nullable().alter();
|
||||
});
|
||||
}
|
||||
|
||||
await knex('events').update({ hero_logo_size: null });
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_size'))) return;
|
||||
await knex('events').whereNull('hero_logo_size').update({ hero_logo_size: 'medium' });
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw("ALTER TABLE events ALTER COLUMN hero_logo_size SET DEFAULT 'medium'");
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size SET NOT NULL');
|
||||
} else {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.string('hero_logo_size', 20).notNullable().defaultTo('medium').alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 A–Z 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();
|
||||
};
|
||||
Generated
+2
-72
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.80.0-beta.0",
|
||||
"version": "3.74.0-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.80.0-beta.0",
|
||||
"version": "3.74.0-beta.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -40,7 +40,6 @@
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
@@ -2704,56 +2703,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/plugin-crypto": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
|
||||
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-thirty-two": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
|
||||
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"thirty-two": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-default": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
|
||||
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-v11": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
|
||||
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
@@ -9422,17 +9371,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
||||
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/preset-default": "^12.0.1",
|
||||
"@otplib/preset-v11": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -11730,14 +11668,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/thirty-two": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
|
||||
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
|
||||
"engines": {
|
||||
"node": ">=0.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.89.0-beta.0",
|
||||
"version": "3.79.1-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,11 +46,9 @@
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
"postcss": "8.5.10",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -60,10 +57,11 @@
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"swissqrbill": "^4.3.0",
|
||||
"tar": ">=7.5.16",
|
||||
"uuid": "^11.1.1",
|
||||
"winston": "^3.8.2",
|
||||
"zxcvbn": "^4.4.2"
|
||||
"zxcvbn": "^4.4.2",
|
||||
"postcss": "8.5.10",
|
||||
"tar": ">=7.5.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.40.0",
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* reset-admin-mfa.js — disable two-factor auth for a locked-out admin (#738).
|
||||
*
|
||||
* Break-glass recovery for when an admin loses their authenticator AND their
|
||||
* recovery codes. Clears the MFA state so the admin can log in with just their
|
||||
* password and re-enroll from Settings.
|
||||
*
|
||||
* Usage (inside the running backend container):
|
||||
* docker compose exec backend node scripts/reset-admin-mfa.js --email admin@example.com
|
||||
* docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
*
|
||||
* Flags:
|
||||
* --email <addr> target a single admin by email (or --username <name>)
|
||||
* --all reset MFA for EVERY admin (full lockout / break-glass)
|
||||
* --yes non-interactive (skip the confirmation prompt)
|
||||
*/
|
||||
|
||||
const readline = require('readline');
|
||||
const { db, logActivity } = require('../src/database/db');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const hasFlag = (f) => args.includes(f);
|
||||
const getOption = (name) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
|
||||
};
|
||||
|
||||
const force = hasFlag('--yes') || hasFlag('--force') || hasFlag('--non-interactive');
|
||||
const all = hasFlag('--all');
|
||||
const email = getOption('email');
|
||||
const username = getOption('username');
|
||||
|
||||
const MFA_CLEAR = {
|
||||
two_factor_enabled: false,
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
function ask(prompt) {
|
||||
if (force) return Promise.resolve('yes');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => rl.question(prompt, (a) => { rl.close(); resolve(a); }));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n========================================');
|
||||
console.log('PicPeak Admin MFA Reset Tool');
|
||||
console.log('========================================\n');
|
||||
|
||||
if (!all && !email && !username) {
|
||||
console.error('❌ Specify a target: --email <addr>, --username <name>, or --all');
|
||||
console.log(' e.g. node scripts/reset-admin-mfa.js --email admin@example.com');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve target admins.
|
||||
let targets;
|
||||
if (all) {
|
||||
targets = await db('admin_users').select('id', 'username', 'email', 'two_factor_enabled');
|
||||
} else {
|
||||
const q = db('admin_users');
|
||||
if (email) q.where({ email });
|
||||
if (username) q.where({ username });
|
||||
targets = await q.select('id', 'username', 'email', 'two_factor_enabled');
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
console.error('❌ No matching admin user found.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enrolled = targets.filter((t) => t.two_factor_enabled === true || t.two_factor_enabled === 1);
|
||||
console.log(`Matched ${targets.length} admin(s); ${enrolled.length} currently have MFA enabled:`);
|
||||
for (const t of targets) {
|
||||
const flag = (t.two_factor_enabled === true || t.two_factor_enabled === 1) ? 'MFA ON' : 'mfa off';
|
||||
console.log(` - ${t.username} <${t.email}> [${flag}]`);
|
||||
}
|
||||
|
||||
const confirm = await ask('\nDisable MFA for the above? (yes/no): ');
|
||||
const normalized = String(confirm).trim().toLowerCase();
|
||||
if (normalized !== 'yes' && normalized !== 'y') {
|
||||
console.log('\n❌ Cancelled. No changes made.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ids = targets.map((t) => t.id);
|
||||
const updated = await db('admin_users').whereIn('id', ids).update(MFA_CLEAR);
|
||||
|
||||
for (const t of targets) {
|
||||
try {
|
||||
await logActivity('admin_mfa_reset_cli',
|
||||
{ admin_id: t.id, via: 'cli' },
|
||||
null,
|
||||
{ type: 'system', id: 0, name: 'reset-admin-mfa.js' }
|
||||
);
|
||||
} catch (_) { /* activity log is best-effort */ }
|
||||
}
|
||||
|
||||
console.log(`\n✅ MFA disabled for ${updated} admin(s). They can now log in with just their password and re-enroll from Settings → Security.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('❌ Failed to reset MFA:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -343,34 +343,12 @@ describe('isSocialCrawler — extended bot coverage (#521)', () => {
|
||||
// 3rd-party preview services used by business-messaging stacks
|
||||
'LinkPreview/1.0',
|
||||
'Slack-ImgProxy/1.0',
|
||||
// Viber + broader crawler set (#699 follow-up)
|
||||
'Mozilla/5.0 (compatible; Viber)',
|
||||
'Mozilla/5.0 (compatible; Bluesky Cardyb/1.1)',
|
||||
'facebookcatalog/1.0',
|
||||
'kakaotalk-scrap/1.0',
|
||||
'Mozilla/5.0 (compatible; Synapse/1.98)',
|
||||
'Rocket.Chat/6.0',
|
||||
];
|
||||
for (const ua of knownBots) {
|
||||
expect(isSocialCrawler(ua)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT match human in-app-browser UAs (our OG response is meta-only, no redirect)', () => {
|
||||
// These share a token with a preview bot but are also sent by real users
|
||||
// browsing inside the app's webview — matching them would serve a human
|
||||
// the bare OG stub. Deliberately excluded; guard against re-adding them.
|
||||
const inAppBrowsers = [
|
||||
'Mozilla/5.0 (iPhone) AppleWebKit MicroMessenger/8.0.0', // WeChat in-app
|
||||
'Mozilla/5.0 (iPhone) AppleWebKit Line/13.0.0', // LINE in-app
|
||||
'Mozilla/5.0 (Linux; Android) Zalo', // Zalo in-app
|
||||
'Mozilla/5.0 (Macintosh) Chrome/120.0 Safari/537.36 boxing', // "XING" substring trap
|
||||
];
|
||||
for (const ua of inAppBrowsers) {
|
||||
expect(isSocialCrawler(ua)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not match a regular browser UA', () => {
|
||||
const browsers = [
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||||
|
||||
@@ -101,7 +101,6 @@ describe('verifyGalleryAccess — customer-minted JWT with active assignment', (
|
||||
it('allows access when the event_customer_assignments row exists', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
eventId: 42,
|
||||
via: 'customer',
|
||||
customerId: 7,
|
||||
@@ -132,7 +131,6 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
|
||||
it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
eventId: 42,
|
||||
via: 'customer',
|
||||
customerId: 7,
|
||||
@@ -162,7 +160,6 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
|
||||
// and start 403'ing per-event-password sessions.
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
eventId: 42,
|
||||
customerId: 7,
|
||||
// intentionally no `via` claim
|
||||
@@ -194,7 +191,6 @@ describe('verifyGalleryAccess — per-event-password JWT', () => {
|
||||
it('does NOT touch event_customer_assignments and passes through', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
eventId: 42,
|
||||
// No via, no customerId — this is the legacy per-event-password
|
||||
// flow where every guest mints their own JWT after entering the
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -19,7 +18,6 @@ async function adminAuth(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
@@ -39,13 +37,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') {
|
||||
@@ -149,7 +140,6 @@ async function galleryAuth(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
@@ -165,12 +155,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' });
|
||||
@@ -224,7 +209,7 @@ async function photoAuth(req, res, next) {
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
@@ -234,11 +219,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')
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -35,7 +34,6 @@ async function customerAuth(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
const verified = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true,
|
||||
});
|
||||
@@ -62,11 +60,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,
|
||||
|
||||
@@ -66,29 +66,18 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (error) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
|
||||
|
||||
// Only gallery-scoped tokens grant gallery access. Every legitimate
|
||||
// path (password login, share link, client access, customer-minted,
|
||||
// slideshow) mints type:'gallery'. Reject anything else — e.g. a guest
|
||||
// identity token (type:'guest', for feedback attribution) that carries a
|
||||
// matching eventId — instead of relying on other token types incidentally
|
||||
// lacking an eventId to fail the id match below.
|
||||
if (decoded.type !== 'gallery') {
|
||||
return res.status(403).json({ error: 'Invalid token type for gallery access' });
|
||||
}
|
||||
|
||||
|
||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||
if (requestedSlug) {
|
||||
// Verify by slug and ensure it matches the token's event
|
||||
|
||||
@@ -23,7 +23,6 @@ async function resolveGuest(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
const verified = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true,
|
||||
});
|
||||
|
||||
@@ -73,10 +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',
|
||||
'/api/auth/session',
|
||||
'/api/public/settings',
|
||||
'/health'
|
||||
|
||||
@@ -32,36 +32,4 @@ function requireEventOwnership(req, res, next) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the subset of `eventIds` the admin may act on, mirroring
|
||||
* requireEventOwnership for bulk routes that can't use it (they take an
|
||||
* array in the body, not an :id param). super_admin gets everything;
|
||||
* other roles get events they created plus ownerless legacy/system
|
||||
* events (created_by IS NULL). Ids that are foreign OR non-existent both
|
||||
* land in `denied` — deliberately indistinguishable, so bulk routes
|
||||
* don't become an ownership/existence oracle.
|
||||
*
|
||||
* @returns {Promise<{allowed: Array, denied: Array}>}
|
||||
*/
|
||||
async function filterOwnedEventIds(admin, eventIds) {
|
||||
if (admin.roleName === 'super_admin') {
|
||||
return { allowed: [...eventIds], denied: [] };
|
||||
}
|
||||
const rows = await db('events')
|
||||
.whereIn('id', eventIds)
|
||||
.andWhere((q) => q.whereNull('created_by').orWhere('created_by', admin.id))
|
||||
.select('id');
|
||||
const allowedSet = new Set(rows.map((r) => r.id));
|
||||
const allowed = [];
|
||||
const denied = [];
|
||||
for (const id of eventIds) {
|
||||
if (allowedSet.has(id) || allowedSet.has(Number(id))) {
|
||||
allowed.push(id);
|
||||
} else {
|
||||
denied.push(id);
|
||||
}
|
||||
}
|
||||
return { allowed, denied };
|
||||
}
|
||||
|
||||
module.exports = { requireEventOwnership, filterOwnedEventIds };
|
||||
module.exports = { requireEventOwnership };
|
||||
|
||||
@@ -28,13 +28,12 @@ async function photoAuth(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (issuerError) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} else {
|
||||
throw issuerError;
|
||||
}
|
||||
@@ -44,36 +43,24 @@ async function photoAuth(req, res, next) {
|
||||
if (decoded.type === 'gallery') {
|
||||
// For thumbnails, we need to verify the token is for a valid event
|
||||
if (!eventSlug) {
|
||||
// Resolve the token's event (by id, or legacy slug fallback)...
|
||||
let event = null;
|
||||
// Extract event ID from the decoded token
|
||||
if (decoded.eventId) {
|
||||
event = await db('events')
|
||||
const event = await db('events')
|
||||
.where({ id: decoded.eventId, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
}
|
||||
if (!event && decoded.eventSlug) {
|
||||
event = await db('events')
|
||||
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
}
|
||||
// ...then confirm the REQUESTED thumbnail actually belongs to
|
||||
// that event. Thumbnails are stored flat (thumbnails/thumb_<name>)
|
||||
// with deterministic, enumerable filenames derived from the
|
||||
// public event name + a sequential counter. Without this
|
||||
// ownership check any holder of a gallery token for any event
|
||||
// could enumerate and fetch another (password-protected) event's
|
||||
// entire thumbnail set, defeating the gallery password. A
|
||||
// traversal or foreign filename simply fails to match → denied.
|
||||
if (event) {
|
||||
const requestedKey = `thumbnails${req.path}`;
|
||||
const ownsThumbnail = await db('photos')
|
||||
.where({ event_id: event.id, thumbnail_path: requestedKey })
|
||||
.first();
|
||||
if (ownsThumbnail) {
|
||||
if (event) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
// Fallback to slug
|
||||
const event = await db('events')
|
||||
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
if (event) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
// For regular photos, check if token matches the event
|
||||
else if (decoded.eventSlug === eventSlug) {
|
||||
|
||||
@@ -87,7 +87,7 @@ async function sessionTimeoutMiddleware(req, res, next) {
|
||||
|
||||
try {
|
||||
// Verify token is valid
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Check if this is an admin token
|
||||
if (!decoded.id) {
|
||||
@@ -128,7 +128,7 @@ async function sessionTimeoutMiddleware(req, res, next) {
|
||||
for (const [oldToken, _] of sessions.entries()) {
|
||||
if (oldToken !== token) {
|
||||
try {
|
||||
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET);
|
||||
if (oldDecoded.id === userId) {
|
||||
sessions.delete(oldToken);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -10,7 +10,6 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
|
||||
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
const mfaService = require('../services/mfaService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get admin profile
|
||||
@@ -185,175 +184,4 @@ router.post('/logout', adminAuth, handleAsync(async (req, res) => {
|
||||
successResponse(res, { message: 'Logged out successfully' });
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-factor authentication (TOTP) — issue #738.
|
||||
//
|
||||
// All endpoints operate on the AUTHENTICATED admin's own account
|
||||
// (req.admin.id) — enrollment is per-user and works for every role,
|
||||
// super_admin included (closes #735). The TOTP secret is stored encrypted
|
||||
// at rest and recovery codes are hashed; see services/mfaService.js.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isMfaEnabled = mfaService.isEnrolled;
|
||||
|
||||
// Current MFA state for the logged-in admin.
|
||||
router.get('/mfa/status', adminAuth, handleAsync(async (req, res) => {
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
const enabled = isMfaEnabled(admin);
|
||||
res.json({
|
||||
enabled,
|
||||
enrolledAt: enabled ? admin.two_factor_enrolled_at || null : null,
|
||||
recoveryCodesRemaining: enabled
|
||||
? mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes).length
|
||||
: 0
|
||||
});
|
||||
}));
|
||||
|
||||
// Begin enrollment: mint a provisional secret, store it encrypted (NOT yet
|
||||
// enabled), and return the otpauth URI + QR for the authenticator app. Calling
|
||||
// this again before /enable simply regenerates the provisional secret.
|
||||
router.post('/mfa/setup', adminAuth, handleAsync(async (req, res) => {
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (isMfaEnabled(admin)) {
|
||||
throw new ConflictError('Two-factor authentication is already enabled');
|
||||
}
|
||||
|
||||
const secret = mfaService.generateSecret();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_secret: mfaService.encryptSecret(secret),
|
||||
two_factor_enabled: false,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
const accountName = admin.email || admin.username;
|
||||
const otpauthUri = mfaService.buildOtpauthUri(accountName, secret);
|
||||
const qr = await mfaService.buildQrDataUrl(otpauthUri);
|
||||
|
||||
res.json({
|
||||
// `secret` is returned for manual entry when a QR can't be scanned.
|
||||
secret,
|
||||
otpauthUri,
|
||||
qr,
|
||||
issuer: mfaService.ISSUER,
|
||||
account: accountName
|
||||
});
|
||||
}));
|
||||
|
||||
// Complete enrollment: verify a code against the provisional secret, enable
|
||||
// MFA, and return one-time recovery codes (shown exactly once).
|
||||
router.post('/mfa/enable', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('Verification code is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (isMfaEnabled(admin)) {
|
||||
throw new ConflictError('Two-factor authentication is already enabled');
|
||||
}
|
||||
if (!admin.two_factor_secret) {
|
||||
throw new ValidationError('Start setup before enabling two-factor authentication');
|
||||
}
|
||||
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_enabled: true,
|
||||
two_factor_enrolled_at: new Date(),
|
||||
two_factor_recovery_codes: JSON.stringify(hashed),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_enabled',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Two-factor authentication enabled',
|
||||
recoveryCodes: plain
|
||||
});
|
||||
}));
|
||||
|
||||
// Disable MFA. Requires a fresh TOTP or recovery code so a hijacked session
|
||||
// can't silently strip the second factor.
|
||||
router.post('/mfa/disable', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('A current code is required to disable 2FA')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (!isMfaEnabled(admin)) {
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const totpOk = mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret);
|
||||
let recoveryOk = false;
|
||||
if (!totpOk) {
|
||||
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
|
||||
recoveryOk = (await mfaService.consumeRecoveryCode(req.body.code, stored)).matched;
|
||||
}
|
||||
if (!totpOk && !recoveryOk) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_enabled: false,
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_disabled',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, { message: 'Two-factor authentication disabled' });
|
||||
}));
|
||||
|
||||
// Regenerate recovery codes (invalidates the old set). Requires a fresh TOTP
|
||||
// code. Returns the new codes once.
|
||||
router.post('/mfa/recovery-codes', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('A current authenticator code is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (!isMfaEnabled(admin)) {
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_recovery_codes: JSON.stringify(hashed),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_recovery_regenerated',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Recovery codes regenerated',
|
||||
recoveryCodes: plain
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -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({
|
||||
@@ -142,99 +129,6 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r
|
||||
}
|
||||
});
|
||||
|
||||
// Generate + download a portable ".picpeak" export — an engine-neutral logical
|
||||
// snapshot (DB rows as NDJSON + PDFs/business-docs) that can be re-uploaded to
|
||||
// another instance via the web UI. `?includePhotos=true` also bundles original
|
||||
// gallery photos (larger); otherwise the admin re-uploads them per gallery.
|
||||
//
|
||||
// SECURITY: the file contains plaintext secrets (SMTP password, admin password
|
||||
// hashes, API keys). The download UI must warn before offering it. We surface
|
||||
// the flag as a response header too so the client can double-confirm.
|
||||
router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||
const fsSync = require('fs');
|
||||
try {
|
||||
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
|
||||
const { createPicpeak } = require('../services/picpeakExportService');
|
||||
const { filePath } = await createPicpeak({ includePhotos });
|
||||
const filename = path.basename(filePath);
|
||||
res.setHeader('X-Picpeak-Contains-Secrets', 'true');
|
||||
res.download(filePath, filename, (err) => {
|
||||
// Best-effort cleanup of the temp .picpeak (and its temp dir) after send.
|
||||
fsSync.rm(path.dirname(filePath), { recursive: true, force: true }, () => {});
|
||||
if (err) logger.error('[picpeak-export] download failed', { error: err.message });
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[picpeak-export] failed to create export', { error: error.message });
|
||||
if (!res.headersSent) res.status(500).json({ error: 'Failed to create .picpeak export' });
|
||||
}
|
||||
});
|
||||
|
||||
// Multipart upload for .picpeak restore — streamed to a temp file. Runs AFTER
|
||||
// auth so an unauthenticated request can't push a large file to disk.
|
||||
const os = require('os');
|
||||
const multer = require('multer');
|
||||
const picpeakUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, os.tmpdir()),
|
||||
filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`),
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large
|
||||
});
|
||||
|
||||
// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of
|
||||
// all data except the current logged-in account (the client shows an explicit
|
||||
// confirmation before calling this). Returns `usesExternalMedia` so the UI can
|
||||
// prompt the admin to reconfigure the external-media mount afterwards.
|
||||
router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), picpeakUpload.single('backup'), async (req, res) => {
|
||||
const fsSync = require('fs');
|
||||
if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' });
|
||||
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);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
tables: result.tables,
|
||||
filesRestored: result.filesRestored,
|
||||
usesExternalMedia: result.usesExternalMedia,
|
||||
sessionInvalidated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error.statusCode || 500;
|
||||
logger.error('[picpeak-import] restore failed', { error: error.message });
|
||||
res.status(status).json({ error: error.message || 'Restore failed', validation: error.validation });
|
||||
} finally {
|
||||
fsSync.unlink(picpeakPath, () => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Get backup run details
|
||||
router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,7 +7,6 @@ const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const eventRenameService = require('../services/eventRenameService');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
@@ -16,7 +15,7 @@ const router = express.Router();
|
||||
* POST /api/admin/events/:eventId/rename
|
||||
* Rename an event
|
||||
*/
|
||||
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
||||
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
|
||||
body('newEventName')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
@@ -61,7 +60,7 @@ router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), req
|
||||
* POST /api/admin/events/:eventId/validate-rename
|
||||
* Validate a potential rename without executing it
|
||||
*/
|
||||
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
||||
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [
|
||||
body('newEventName')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const { requirePermission } = require('../../middleware/permissions');
|
||||
const { archiveEvent } = require('../../services/archiveService');
|
||||
const logger = require('../../utils/logger');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { requireEventOwnership, filterOwnedEventIds } = require('../../middleware/ownership');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { deleteEventCascade } = require('./helpers');
|
||||
|
||||
|
||||
@@ -74,39 +74,25 @@ module.exports = (router) => {
|
||||
}
|
||||
|
||||
const { eventIds } = req.body;
|
||||
|
||||
|
||||
if (eventIds.length === 0) {
|
||||
return res.status(400).json({ error: 'No events selected for archiving' });
|
||||
}
|
||||
|
||||
// Ownership scope: a non-super_admin may only archive events they own.
|
||||
// Foreign/non-existent ids are dropped and reported as failures so this
|
||||
// route can't archive another admin's events (the single-event
|
||||
// /:id/archive route enforces the same via requireEventOwnership).
|
||||
const { allowed: allowedIds, denied: deniedIds } = await filterOwnedEventIds(req.admin, eventIds);
|
||||
// Get all events to archive
|
||||
const events = await db('events')
|
||||
.whereIn('id', eventIds)
|
||||
.where('is_archived', formatBoolean(false));
|
||||
|
||||
if (events.length === 0) {
|
||||
return res.status(400).json({ error: 'No valid events found to archive' });
|
||||
}
|
||||
|
||||
const results = {
|
||||
successful: [],
|
||||
failed: deniedIds.map((id) => ({ id, name: null, error: 'Access denied or event not found' }))
|
||||
failed: []
|
||||
};
|
||||
|
||||
// Get all events to archive
|
||||
const events = allowedIds.length
|
||||
? await db('events')
|
||||
.whereIn('id', allowedIds)
|
||||
.where('is_archived', formatBoolean(false))
|
||||
: [];
|
||||
|
||||
if (events.length === 0) {
|
||||
if (results.failed.length > 0) {
|
||||
return res.json({
|
||||
message: `Bulk archive completed: 0 succeeded, ${results.failed.length} failed`,
|
||||
results
|
||||
});
|
||||
}
|
||||
return res.status(400).json({ error: 'No valid events found to archive' });
|
||||
}
|
||||
|
||||
// Process each event
|
||||
for (const event of events) {
|
||||
try {
|
||||
@@ -165,21 +151,16 @@ module.exports = (router) => {
|
||||
|
||||
const { eventIds } = req.body;
|
||||
|
||||
// Ownership scope: a non-super_admin may only delete events they own.
|
||||
// The single-event DELETE /:id route enforces this via
|
||||
// requireEventOwnership; this bulk route must match it, otherwise an
|
||||
// admin/editor scoped to their own events could cascade-delete any
|
||||
// event by id. Foreign/non-existent ids are dropped and reported as
|
||||
// failures (indistinguishable, to avoid an existence oracle).
|
||||
const { allowed: allowedIds, denied: deniedIds } = await filterOwnedEventIds(req.admin, eventIds);
|
||||
// Editor-role events.delete permission is already gated by the route
|
||||
// middleware. We do NOT additionally filter to created_by here because
|
||||
// the per-event delete-cascade is global (matches DELETE /:id which
|
||||
// also has no role-based filter — that's why events.delete is a
|
||||
// sensitive permission).
|
||||
|
||||
const results = {
|
||||
successful: [],
|
||||
failed: deniedIds.map((id) => ({ id, name: null, error: 'Access denied or event not found' }))
|
||||
};
|
||||
const results = { successful: [], failed: [] };
|
||||
const adminContext = { id: req.admin.id, username: req.admin.username };
|
||||
|
||||
for (const eventId of allowedIds) {
|
||||
for (const eventId of eventIds) {
|
||||
try {
|
||||
const deleted = await deleteEventCascade(eventId, adminContext);
|
||||
results.successful.push(deleted);
|
||||
|
||||
@@ -24,7 +24,6 @@ const { normaliseEventTimeTriple } = require('../../services/eventService');
|
||||
const { hasColumnCached } = require('../../utils/schemaCache');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { clampIntOrUndefined } = require('../../utils/numericHelpers');
|
||||
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
|
||||
const downloadZipService = require('../../services/downloadZipService');
|
||||
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
|
||||
@@ -95,7 +94,7 @@ module.exports = (router) => {
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional().isBoolean(),
|
||||
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
|
||||
// Header style settings (decoupled from layout)
|
||||
body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']),
|
||||
@@ -339,16 +338,8 @@ module.exports = (router) => {
|
||||
|
||||
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
|
||||
const brandingDefaults = await getBrandingDefaults();
|
||||
// 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.
|
||||
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined
|
||||
? formatBoolean(hero_logo_visible)
|
||||
: null;
|
||||
// NULL = inherit the global branding_logo_size (#756), resolved at read
|
||||
// time. Only an explicit per-event size overrides it.
|
||||
const effectiveHeroLogoSize = req.body.hero_logo_size || null;
|
||||
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible;
|
||||
const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size;
|
||||
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
|
||||
|
||||
// Inherit "Detect dev tools" from the global Image Security setting unless
|
||||
@@ -378,13 +369,7 @@ module.exports = (router) => {
|
||||
let slideshowSeed = {};
|
||||
if (await hasColumnCached('events', 'show_interval_ms')) {
|
||||
try {
|
||||
// parseInt-first: the previous `Number.isFinite(+v)` pre-check let
|
||||
// NaN through for null/''/true (+null is 0, parseInt(null) is NaN),
|
||||
// producing show_interval_ms=NaN in the INSERT — PG rejects that
|
||||
// with "invalid input syntax for type integer" while SQLite
|
||||
// silently stores NULL, so event creation 500'd on PG whenever the
|
||||
// slideshow app_settings rows were absent.
|
||||
const intP = (v, min, max) => clampIntOrUndefined(v, min, max);
|
||||
const intP = (v, min, max) => (Number.isFinite(+v) ? Math.min(max, Math.max(min, parseInt(v, 10))) : undefined);
|
||||
const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined);
|
||||
const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000);
|
||||
const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS);
|
||||
@@ -433,8 +418,7 @@ module.exports = (router) => {
|
||||
allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'),
|
||||
require_password: formatBoolean(requirePassword),
|
||||
css_template_id: css_template_id || null,
|
||||
// Already formatBoolean-coerced above, or null = inherit global (#756).
|
||||
hero_logo_visible: effectiveHeroLogoVisible,
|
||||
hero_logo_visible: formatBoolean(effectiveHeroLogoVisible),
|
||||
hero_logo_size: effectiveHeroLogoSize,
|
||||
hero_logo_position: effectiveHeroLogoPosition,
|
||||
header_style: effectiveHeaderStyle || 'standard',
|
||||
@@ -1225,7 +1209,7 @@ module.exports = (router) => {
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional().isBoolean(),
|
||||
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
|
||||
// Header style settings (decoupled from layout)
|
||||
body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']),
|
||||
@@ -1433,13 +1417,9 @@ module.exports = (router) => {
|
||||
updates.expires_at = null;
|
||||
}
|
||||
|
||||
// Format hero logo settings if provided. null = inherit the global
|
||||
// branding_logo_display_hero toggle (#756); only an explicit true/false
|
||||
// is a per-event override.
|
||||
// Format hero logo settings if provided
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
|
||||
updates.hero_logo_visible = updates.hero_logo_visible === null
|
||||
? null
|
||||
: formatBoolean(updates.hero_logo_visible);
|
||||
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
|
||||
}
|
||||
|
||||
// Per-event opt-in for hero-photo OG share image (#474). Coerce so
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -3,7 +3,6 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const sharp = require('sharp');
|
||||
@@ -49,7 +48,7 @@ async function walkDir(dir, baseDir) {
|
||||
|
||||
// POST /api/admin/events/:id/import-external
|
||||
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } }
|
||||
router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
|
||||
router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.id);
|
||||
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
|
||||
|
||||
@@ -600,18 +600,12 @@ router.post(
|
||||
const photo = await db('photos').where({ id: req.params.photoId }).first();
|
||||
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||
|
||||
// Ownership scope: any non-super_admin may only retry photos in events
|
||||
// they own — matching requireEventOwnership (which scopes both the
|
||||
// admin and editor roles; only super_admin bypasses). Previously this
|
||||
// checked the editor role alone, leaving admin-role users able to
|
||||
// reprocess another admin's photos.
|
||||
if (req.admin.roleName !== 'super_admin') {
|
||||
// Editor role: only allow retry on photos in events they own.
|
||||
if (req.admin.roleName === 'editor') {
|
||||
const event = await db('events')
|
||||
.where({ id: photo.event_id })
|
||||
.where({ id: photo.event_id, created_by: req.admin.id })
|
||||
.first();
|
||||
if (event && event.created_by && event.created_by !== req.admin.id) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
if (!event) return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
if (photo.processing_status !== 'failed') {
|
||||
|
||||
@@ -31,19 +31,6 @@ 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.
|
||||
const RESERVED_SETTING_KEYS = ['setup_wizard_completed', 'setup_token'];
|
||||
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) => {
|
||||
@@ -160,16 +147,6 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
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.
|
||||
@@ -420,16 +397,6 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
|
||||
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.
|
||||
@@ -939,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_'));
|
||||
@@ -1050,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)) {
|
||||
@@ -1088,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.
|
||||
@@ -1143,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) {
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
+39
-188
@@ -2,10 +2,9 @@ const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
const mfaService = require('../services/mfaService');
|
||||
const {
|
||||
trackFailedAttempt,
|
||||
trackSuccessfulLogin,
|
||||
@@ -15,7 +14,6 @@ const {
|
||||
} = require('../utils/authSecurity');
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const { revokeToken } = require('../utils/tokenRevocation');
|
||||
const { timingSafeEqualStr } = require('../utils/timingSafe');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const {
|
||||
@@ -35,60 +33,6 @@ const {
|
||||
} = require('../utils/passwordValidation');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Finish a successful admin login: reset the lockout counter, stamp
|
||||
* last_login, mint the 24h admin JWT, set the HttpOnly cookie, and return the
|
||||
* user payload. Shared by the direct (no-MFA) path and the MFA-verify path so
|
||||
* 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 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
|
||||
});
|
||||
|
||||
const token = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
role: admin.role_name,
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
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
|
||||
router.post('/admin/login', [
|
||||
body('username').notEmpty().trim(),
|
||||
@@ -151,131 +95,48 @@ router.post('/admin/login', [
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
// Second factor: if this admin has TOTP enabled, do NOT complete the login
|
||||
// yet. Issue a short-lived, single-purpose mfa_pending token and require the
|
||||
// code via /admin/login/mfa. We deliberately don't reset the lockout counter
|
||||
// (trackSuccessfulLogin) or stamp last_login until the second factor passes,
|
||||
// so MFA brute-force is still gated by the account lockout. `loginId` carries
|
||||
// the typed identifier so the verify step tracks the same lockout bucket.
|
||||
if (mfaService.isEnrolled(admin)) {
|
||||
const mfaToken = jwt.sign({
|
||||
// Successful login
|
||||
await trackSuccessfulLogin(username, ipAddress, userAgent);
|
||||
|
||||
// Update last login and login metadata
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
});
|
||||
|
||||
// Generate token with additional claims including role
|
||||
const token = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
role: admin.role_name, // Add role to JWT
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
// Token is delivered via HttpOnly cookie only (not in response body)
|
||||
res.json({
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'mfa_pending',
|
||||
loginId: username
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '5m',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
return res.json({ mfaRequired: true, mfaToken });
|
||||
}
|
||||
|
||||
return await completeAdminLogin(req, res, admin, ipAddress, userAgent, username);
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : null
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Login failed');
|
||||
}
|
||||
});
|
||||
|
||||
// Second-factor verification. Exchanges the short-lived mfa_pending token
|
||||
// (from /admin/login) plus a TOTP or recovery code for a full admin session.
|
||||
router.post('/admin/login/mfa', [
|
||||
body('mfaToken').notEmpty(),
|
||||
body('code').notEmpty().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { mfaToken, code } = req.body;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(mfaToken, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(401).json({
|
||||
error: 'Your verification session expired. Please sign in again.',
|
||||
code: 'MFA_SESSION_EXPIRED'
|
||||
});
|
||||
}
|
||||
|
||||
if (decoded.type !== 'mfa_pending') {
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
const lockoutKey = decoded.loginId || decoded.username;
|
||||
const lockoutStatus = await checkAccountLockout(lockoutKey);
|
||||
if (lockoutStatus.isLocked) {
|
||||
return res.status(423).json({
|
||||
error: 'Account temporarily locked due to too many failed attempts',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const admin = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', decoded.id)
|
||||
.select(
|
||||
'admin_users.*',
|
||||
'roles.name as role_name',
|
||||
'roles.display_name as role_display_name'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!admin || !admin.is_active || !mfaService.isEnrolled(admin)) {
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
// TOTP first, then a one-time recovery code.
|
||||
let ok = mfaService.verifyTotpEncrypted(code, admin.two_factor_secret);
|
||||
let usedRecovery = false;
|
||||
let remainingHashes = null;
|
||||
if (!ok) {
|
||||
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
|
||||
const result = await mfaService.consumeRecoveryCode(code, stored);
|
||||
if (result.matched) {
|
||||
ok = true;
|
||||
usedRecovery = true;
|
||||
remainingHashes = result.remainingHashes;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid verification code', code: 'MFA_INVALID' });
|
||||
}
|
||||
|
||||
if (usedRecovery) {
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_recovery_codes: JSON.stringify(remainingHashes),
|
||||
updated_at: new Date()
|
||||
});
|
||||
await logActivity('admin_mfa_recovery_used',
|
||||
{ admin_id: admin.id, remaining: remainingHashes.length },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
}
|
||||
|
||||
await logActivity('admin_mfa_login',
|
||||
{ admin_id: admin.id, method: usedRecovery ? 'recovery_code' : 'totp' },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
return await completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey);
|
||||
} catch (error) {
|
||||
logger.error('MFA verification error:', error);
|
||||
res.status(500).json({ error: 'Verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout endpoint
|
||||
router.post('/logout', async (req, res) => {
|
||||
try {
|
||||
@@ -549,23 +410,11 @@ router.post('/gallery/share-login', [
|
||||
|
||||
const expectedToken = getEventShareToken(event);
|
||||
|
||||
if (!expectedToken || !timingSafeEqualStr(token, expectedToken)) {
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
|
||||
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,
|
||||
@@ -580,6 +429,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: {
|
||||
|
||||
@@ -2,21 +2,9 @@ const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
const archiver = require('archiver');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
|
||||
// #756: a NULL per-event hero_logo_visible means "inherit the global
|
||||
// branding_logo_display_hero toggle". Only an explicit true/false is a
|
||||
// per-gallery override. `globalDefault` is branding_logo_display_hero
|
||||
// (defaults true when unset).
|
||||
function resolveHeroLogoVisible(perEvent, globalDefault) {
|
||||
if (perEvent === null || perEvent === undefined) {
|
||||
return globalDefault !== false;
|
||||
}
|
||||
return perEvent !== false && perEvent !== 0 && perEvent !== '0';
|
||||
}
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const { verifyGalleryAccess, denySlideshowToken, isAdminPreview } = require('../middleware/gallery');
|
||||
@@ -25,7 +13,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');
|
||||
@@ -195,8 +182,6 @@ router.get('/:slug/info', async (req, res) => {
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||
|
||||
res.json({
|
||||
event_name: event.event_name,
|
||||
@@ -214,9 +199,8 @@ router.get('/:slug/info', async (req, res) => {
|
||||
watermark_text: event.watermark_text,
|
||||
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
|
||||
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
|
||||
// #756: NULL per-event size inherits the global branding_logo_size.
|
||||
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
|
||||
hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0',
|
||||
hero_logo_size: event.hero_logo_size || 'medium',
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
hero_logo_url: event.hero_logo_url || null,
|
||||
header_style: event.header_style || 'standard',
|
||||
@@ -244,8 +228,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');
|
||||
@@ -253,10 +237,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
|
||||
@@ -329,9 +309,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,
|
||||
};
|
||||
@@ -364,7 +341,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,
|
||||
@@ -390,7 +367,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)),
|
||||
@@ -434,13 +411,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
|
||||
@@ -588,12 +558,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,
|
||||
@@ -667,8 +635,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
// selection back to source files. Tied to the same toggle as downloads —
|
||||
// one switch controls both surfaces.
|
||||
const useOriginalFilenames = await getUseOriginalFilenames();
|
||||
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||
|
||||
|
||||
res.json({
|
||||
event: {
|
||||
@@ -687,8 +654,8 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
watermark_text: req.event.watermark_text,
|
||||
enable_devtools_protection: req.event.enable_devtools_protection === true,
|
||||
use_canvas_rendering: req.event.use_canvas_rendering === true,
|
||||
hero_logo_visible: resolveHeroLogoVisible(req.event.hero_logo_visible, globalHeroLogoVisible),
|
||||
hero_logo_size: req.event.hero_logo_size || globalLogoSize || 'medium',
|
||||
hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0',
|
||||
hero_logo_size: req.event.hero_logo_size || 'medium',
|
||||
hero_logo_position: req.event.hero_logo_position || 'top',
|
||||
hero_logo_url: req.event.hero_logo_url || null,
|
||||
header_style: req.event.header_style || 'standard',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { photoAuth } = require('../middleware/photoAuth');
|
||||
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
||||
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||
const { resolveGuest } = require('../middleware/guestAuth');
|
||||
|
||||
@@ -9,7 +9,6 @@ const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/ph
|
||||
const { withLocalCopy } = require('../services/imageProcessor');
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../utils/logger');
|
||||
const { timingSafeEqualStr } = require('../utils/timingSafe');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -34,9 +33,9 @@ function verifyImageToken(token) {
|
||||
const decoded = Buffer.from(data, 'base64').toString();
|
||||
const [photoId, expires] = decoded.split(':');
|
||||
|
||||
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
|
||||
// Verify signature
|
||||
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
|
||||
if (!timingSafeEqualStr(signature, expectedSignature)) {
|
||||
if (signature !== expectedSignature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -23,7 +23,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
|
||||
// Add slug to request for verifyGalleryAccess
|
||||
req.requestedSlug = req.params.slug;
|
||||
next();
|
||||
}, verifyGalleryAccess, denySlideshowToken, async (req, res) => {
|
||||
}, verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const { photoId, accessType = 'view' } = req.body;
|
||||
|
||||
@@ -273,7 +273,6 @@ router.get('/:slug/secure-download/:photoId/:token',
|
||||
next();
|
||||
},
|
||||
verifyGalleryAccess,
|
||||
denySlideshowToken,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId, token } = req.params;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -726,12 +726,9 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
throw new Error('Email configuration not found');
|
||||
}
|
||||
|
||||
// Determine recipient language. An explicit `__language` in the email data
|
||||
// wins (CRM/billing emails set it to the customer/invoice language so a
|
||||
// gallery event's language can't override a dunning notice — see #760);
|
||||
// otherwise fall back to the event-first recipient resolution.
|
||||
const language = variables.__language || await getRecipientLanguage(to, variables.eventId || null);
|
||||
|
||||
// Determine recipient language (pass eventId if available in variables)
|
||||
const language = await getRecipientLanguage(to, variables.eventId || null);
|
||||
|
||||
// Process template with variables
|
||||
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
|
||||
|
||||
@@ -775,62 +772,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
|
||||
@@ -842,7 +783,7 @@ async function sendRawEmail({ to, cc, subject, html, text, attachments, accountK
|
||||
async function renderQueuedEmail(templateKey, variables = {}, to = '') {
|
||||
const template = await db('email_templates').where('template_key', templateKey).first();
|
||||
if (!template) return null;
|
||||
const language = variables.__language || await getRecipientLanguage(to, variables.eventId || null);
|
||||
const language = await getRecipientLanguage(to, variables.eventId || null);
|
||||
const { subject, htmlBody } = await processTemplate(template, variables, language);
|
||||
return { subject, html: htmlBody };
|
||||
}
|
||||
@@ -1164,7 +1105,6 @@ module.exports = {
|
||||
initializeTransporter,
|
||||
startEmailQueueProcessor,
|
||||
sendTemplateEmail,
|
||||
sendRawEmail,
|
||||
renderQueuedEmail,
|
||||
processEmailQueue,
|
||||
queueEmail,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 document→event 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
|
||||
};
|
||||
|
||||
@@ -34,29 +34,7 @@ const SOCIAL_CRAWLER_PATTERNS = [
|
||||
// messaging stacks (Twilio, LinkPreview.net, etc.). Match the
|
||||
// canonical lowercase substring; the /i flag handles case.
|
||||
/LinkPreview/i,
|
||||
/Slack-ImgProxy/i,
|
||||
// Viber's link-preview fetcher — was never detected, so shared links
|
||||
// showed no rich preview in Viber (#699 follow-up). Keep in sync with the
|
||||
// UA list in frontend/nginx.conf.
|
||||
/Viber/i,
|
||||
// Broader crawler coverage (#699 follow-up, from alexvaltchev's field list).
|
||||
// IMPORTANT: only CRAWLER-EXCLUSIVE tokens are added here. Our OG response is
|
||||
// meta-only (no client redirect), so a UA shared with a real human in-app
|
||||
// browser would serve that human the bare stub. That rules out WeChat
|
||||
// (MicroMessenger), LINE (Line/), Zalo, and generic strings like
|
||||
// "InAppBrowser"/"preview"/"unfurl" — deliberately NOT added.
|
||||
/Cardyb/i, // Bluesky's link-card service (the actual fetcher UA)
|
||||
/facebookcatalog/i, // Facebook catalog crawler
|
||||
/Signal/i, // Signal link preview
|
||||
/Misskey/i, // fediverse (server-side preview fetch)
|
||||
/Pleroma/i, // fediverse
|
||||
/Synapse/i, // Matrix homeserver URL preview
|
||||
/Nextcloud/i, // Nextcloud Talk/News link crawler
|
||||
/Rocket\.Chat/i, // Rocket.Chat server preview
|
||||
/kakaotalk-scrap/i, // KakaoTalk's scraper (NOT the in-app browser UA)
|
||||
/Google-PageRenderer/i, // Google Chat previews (not Search)
|
||||
/OdklBot/i, // Odnoklassniki
|
||||
/ZoomBot/i // Zoom Team Chat
|
||||
/Slack-ImgProxy/i
|
||||
];
|
||||
|
||||
function isSocialCrawler(userAgent) {
|
||||
|
||||
@@ -185,8 +185,6 @@ async function queueInvoicePaidAdminNotification({
|
||||
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|
||||
|| customer?.email || '',
|
||||
event_name: invoice.event_name || '',
|
||||
// Keep the body language consistent with the locale-formatted amounts.
|
||||
__language: locale,
|
||||
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
|
||||
paid_amount: formatMajor(paidTotalMinor, invoice.currency, locale),
|
||||
payment_method: paymentMethod || '',
|
||||
@@ -280,16 +278,13 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {})
|
||||
: null;
|
||||
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, adminContact.email,
|
||||
'invoice_payment_check', {
|
||||
'invoice_payment_check_admin', {
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer?.company_name
|
||||
|| customer?.display_name
|
||||
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|
||||
|| customer?.email || '',
|
||||
event_name: invoice.event_name || '',
|
||||
// Keep the body language consistent with the locale the amounts are
|
||||
// formatted in, instead of event-first resolution (admin-facing gate).
|
||||
__language: locale,
|
||||
due_date: formatShortDate(invoice.due_date),
|
||||
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
|
||||
paid_amount: formatMajor(paidMinor, invoice.currency, locale),
|
||||
|
||||
@@ -141,7 +141,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
||||
const rawDaysOverdue = Math.floor((Date.now() - new Date(invoice.due_date).getTime()) / 86400000);
|
||||
const daysOverdue = Math.max(1, rawDaysOverdue);
|
||||
const templateKey = level === 1 ? 'invoice_reminder_first' : 'invoice_reminder_second';
|
||||
const locale = ctx.locale || customer.preferred_language || invoice.language || 'de';
|
||||
const locale = ctx.locale || invoice.language || 'de';
|
||||
const outstandingMinor = Math.max(0, newTotal - Number(invoice.paid_amount_minor || 0));
|
||||
|
||||
// Attach the (unchanged) original invoice PDF + the new Mahnung.
|
||||
@@ -154,8 +154,6 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
||||
const { to: reminderTo, cc: reminderCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email);
|
||||
try {
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, reminderTo, templateKey, {
|
||||
// Render in the customer/invoice language, not the gallery event's (#760).
|
||||
__language: locale,
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -127,9 +127,6 @@ async function sendInvoice(id, adminId) {
|
||||
installment_label: invoice.installment_label || '',
|
||||
installment_index: invoice.installment_index + 1,
|
||||
installment_total: invoice.installment_total,
|
||||
// Send in the customer's language (matches the ctx.locale-formatted amounts
|
||||
// above) rather than the event-first default resolution.
|
||||
__language: ctx.locale,
|
||||
cc: invoiceCc,
|
||||
attachments: [{
|
||||
filename: `${invoice.invoice_number}.pdf`,
|
||||
@@ -371,8 +368,6 @@ async function sendStorno(stornoId, adminId) {
|
||||
original_issue_date: originalRow?.issue_date ? formatShortDate(originalRow.issue_date) : '',
|
||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||
total_amount: formatMajor(Math.abs(storno.total_amount_minor), storno.currency, ctx.locale),
|
||||
// Match the customer's language (as with the ctx.locale-formatted amount).
|
||||
__language: ctx.locale,
|
||||
cc: stornoCc,
|
||||
attachments: [{
|
||||
filename: `${storno.invoice_number}.pdf`,
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* mfaService — TOTP (RFC 6238) multi-factor auth for admin accounts (#738).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - generate/verify TOTP secrets (otplib, standard SHA1/6-digit/30s so
|
||||
* Google Authenticator / Authy / 1Password all work);
|
||||
* - encrypt the secret at rest (AES-256-GCM) so a DB leak alone doesn't
|
||||
* yield working authenticator seeds;
|
||||
* - generate/verify one-time recovery codes, hashed (bcrypt) and single-use;
|
||||
* - build the otpauth:// URI + QR data-URL for enrollment.
|
||||
*
|
||||
* The encryption key is derived (scrypt) from MFA_ENCRYPTION_KEY when set,
|
||||
* otherwise from JWT_SECRET. Rotating either invalidates stored secrets —
|
||||
* the same blast radius as rotating JWT_SECRET already has for sessions, and
|
||||
* `reset-admin-mfa.js` is the recovery path.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { authenticator } = require('otplib');
|
||||
const QRCode = require('qrcode');
|
||||
|
||||
// Standard TOTP params; window:1 tolerates ±1 step (30s) of clock drift.
|
||||
authenticator.options = { window: 1 };
|
||||
|
||||
const ISSUER = 'PicPeak';
|
||||
const RECOVERY_CODE_COUNT = 10;
|
||||
const RECOVERY_CODE_BYTES = 10; // ~80 bits of entropy per code
|
||||
const RECOVERY_BCRYPT_ROUNDS = 10;
|
||||
|
||||
const ENC_ALGO = 'aes-256-gcm';
|
||||
const ENC_SALT = 'picpeak-mfa-secret-v1'; // fixed: derivation must be stable
|
||||
|
||||
function getEncryptionKey() {
|
||||
const material = process.env.MFA_ENCRYPTION_KEY || process.env.JWT_SECRET;
|
||||
if (!material) {
|
||||
throw new Error('mfaService: MFA_ENCRYPTION_KEY or JWT_SECRET must be set');
|
||||
}
|
||||
return crypto.scryptSync(material, ENC_SALT, 32);
|
||||
}
|
||||
|
||||
/** Generate a fresh base32 TOTP secret. */
|
||||
function generateSecret() {
|
||||
return authenticator.generateSecret();
|
||||
}
|
||||
|
||||
/** AES-256-GCM encrypt a secret → "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('mfaService: 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');
|
||||
}
|
||||
|
||||
/** Verify a 6-digit TOTP code against the (plaintext) secret. */
|
||||
function verifyTotp(code, plainSecret) {
|
||||
if (!code || !plainSecret) return false;
|
||||
try {
|
||||
return authenticator.verify({ token: String(code).replace(/\s+/g, ''), secret: plainSecret });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify a code against a STORED (encrypted) secret. */
|
||||
function verifyTotpEncrypted(code, storedSecret) {
|
||||
try {
|
||||
return verifyTotp(code, decryptSecret(storedSecret));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** otpauth:// URI for an authenticator app. */
|
||||
function buildOtpauthUri(accountName, plainSecret) {
|
||||
return authenticator.keyuri(accountName, ISSUER, plainSecret);
|
||||
}
|
||||
|
||||
/** QR code (PNG data URL) for the otpauth URI. */
|
||||
async function buildQrDataUrl(otpauthUri) {
|
||||
return QRCode.toDataURL(otpauthUri, { errorCorrectionLevel: 'M', margin: 1, width: 240 });
|
||||
}
|
||||
|
||||
/** Format a raw code as human-friendly groups, e.g. "abcd-efgh-jk". */
|
||||
function formatRecoveryCode(raw) {
|
||||
return raw.match(/.{1,4}/g).join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate RECOVERY_CODE_COUNT one-time codes. Returns the plaintext codes
|
||||
* (shown to the admin ONCE) and their bcrypt hashes (persisted).
|
||||
*/
|
||||
async function generateRecoveryCodes() {
|
||||
const plain = [];
|
||||
const hashed = [];
|
||||
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
|
||||
// base32-ish, lowercase, no ambiguous chars
|
||||
const raw = crypto.randomBytes(RECOVERY_CODE_BYTES)
|
||||
.toString('base64')
|
||||
.replace(/[^a-zA-Z0-9]/g, '')
|
||||
.toLowerCase()
|
||||
.slice(0, 10);
|
||||
const code = formatRecoveryCode(raw);
|
||||
plain.push(code);
|
||||
hashed.push(await bcrypt.hash(code, RECOVERY_BCRYPT_ROUNDS));
|
||||
}
|
||||
return { plain, hashed };
|
||||
}
|
||||
|
||||
function normalizeRecoveryInput(code) {
|
||||
return String(code || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a submitted recovery code against the stored hash array. On match,
|
||||
* returns the remaining hashes (matched one removed — single use). On miss,
|
||||
* matched:false and the array unchanged.
|
||||
*
|
||||
* @param {string[]} storedHashes
|
||||
* @returns {Promise<{matched: boolean, remainingHashes: string[]}>}
|
||||
*/
|
||||
async function consumeRecoveryCode(code, storedHashes) {
|
||||
const input = normalizeRecoveryInput(code);
|
||||
const hashes = Array.isArray(storedHashes) ? storedHashes : [];
|
||||
if (!input) return { matched: false, remainingHashes: hashes };
|
||||
for (let i = 0; i < hashes.length; i++) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await bcrypt.compare(input, hashes[i])) {
|
||||
const remaining = hashes.slice(0, i).concat(hashes.slice(i + 1));
|
||||
return { matched: true, remainingHashes: remaining };
|
||||
}
|
||||
}
|
||||
return { matched: false, remainingHashes: hashes };
|
||||
}
|
||||
|
||||
/** True when an admin row has MFA enabled (coerces SQLite/PG boolean shapes). */
|
||||
function isEnrolled(admin) {
|
||||
const v = admin && admin.two_factor_enabled;
|
||||
return v === true || v === 1 || v === '1';
|
||||
}
|
||||
|
||||
/** Parse the DB column (JSON text) into an array of hashes. */
|
||||
function parseRecoveryCodes(raw) {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateSecret,
|
||||
encryptSecret,
|
||||
decryptSecret,
|
||||
verifyTotp,
|
||||
verifyTotpEncrypted,
|
||||
buildOtpauthUri,
|
||||
buildQrDataUrl,
|
||||
generateRecoveryCodes,
|
||||
consumeRecoveryCode,
|
||||
parseRecoveryCodes,
|
||||
isEnrolled,
|
||||
formatRecoveryCode,
|
||||
ISSUER,
|
||||
RECOVERY_CODE_COUNT,
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// Portable ".picpeak" export — a single, self-describing archive that can be
|
||||
// downloaded from one instance and re-uploaded to another via the web UI only
|
||||
// (see picpeakImportService for the receiving half).
|
||||
//
|
||||
// Deliberately ENGINE-NEUTRAL: instead of a native pg_dump / sqlite .backup
|
||||
// (which can only ever restore into the same engine and version), each table is
|
||||
// written as NDJSON. The target rebuilds its own schema by running migrations,
|
||||
// then loads these rows into it — so an older backup restores cleanly onto a
|
||||
// newer target (forward-only), and pg↔pg / sqlite↔sqlite both work.
|
||||
//
|
||||
// This module is purely additive: it introduces a new artifact and touches no
|
||||
// existing backup/restore path.
|
||||
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const archiver = require('archiver');
|
||||
const { db } = require('../database/db');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const logger = require('../utils/logger');
|
||||
const packageJson = require('../../package.json');
|
||||
|
||||
// Bump only on a breaking change to the on-disk layout below.
|
||||
const PICPEAK_FORMAT_VERSION = 1;
|
||||
|
||||
// Never exported as data — the target owns these (its own migrations set them).
|
||||
const EXCLUDED_TABLES = new Set(['knex_migrations', 'knex_migrations_lock']);
|
||||
|
||||
// Storage subdirs holding non-recalculable blobs — always included.
|
||||
const DOC_DIRS = ['business-docs', 'uploads'];
|
||||
// Original gallery photos — only when includePhotos is true (large; otherwise
|
||||
// the admin re-uploads originals per gallery and previews are re-rendered).
|
||||
const PHOTO_DIRS = ['events/active', 'events/archived'];
|
||||
|
||||
const isPostgres = () => knexConfig.client === 'pg';
|
||||
|
||||
// db.raw returns `{ rows: [...] }` on Postgres and a bare array on SQLite.
|
||||
const rawRows = (result) => (isPostgres() ? result.rows : result);
|
||||
|
||||
// All user tables, minus knex bookkeeping. Introspected at runtime so the
|
||||
// export never rots as tables are added (no hardcoded list to maintain).
|
||||
async function listDataTables() {
|
||||
let names;
|
||||
if (isPostgres()) {
|
||||
const result = await db.raw(`
|
||||
SELECT table_name AS name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
names = rawRows(result).map((r) => r.name);
|
||||
} else {
|
||||
const result = await db.raw(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`);
|
||||
names = rawRows(result).map((r) => r.name);
|
||||
}
|
||||
return names.filter((n) => !EXCLUDED_TABLES.has(n));
|
||||
}
|
||||
|
||||
// The latest applied migration — recorded in the manifest so the importer can
|
||||
// refuse a backup that is NEWER than the target (forward-only guarantee).
|
||||
async function getLatestMigration() {
|
||||
try {
|
||||
const rows = await db('knex_migrations').orderBy('id', 'desc').limit(1);
|
||||
return rows[0]?.name || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Write one table to <dataDir>/<table>.ndjson (one JSON object per line).
|
||||
// Returns { rowCount, checksum } for the manifest. JSON.stringify serialises
|
||||
// Dates to ISO strings, which re-import cleanly on both engines.
|
||||
//
|
||||
// Uses a plain select rather than knex `.stream()`: streaming on Postgres pulls
|
||||
// in the optional `pg-query-stream` dependency (not bundled), so it throws on
|
||||
// pg. A select works on both engines with no extra dependency. Rows are DB
|
||||
// metadata (blobs live on disk under files/), so holding a table in memory is
|
||||
// fine for the instance sizes PicPeak targets.
|
||||
async function writeTableNdjson(table, dataDir) {
|
||||
const outPath = path.join(dataDir, `${table}.ndjson`);
|
||||
const hash = crypto.createHash('sha256');
|
||||
const rows = await db(table).select('*');
|
||||
const lines = rows.map((row) => {
|
||||
const line = JSON.stringify(row);
|
||||
hash.update(`${line}\n`);
|
||||
return line;
|
||||
});
|
||||
await fsp.writeFile(outPath, lines.length ? `${lines.join('\n')}\n` : '', 'utf8');
|
||||
return { rowCount: rows.length, checksum: hash.digest('hex') };
|
||||
}
|
||||
|
||||
// Recursively collect files under a storage subdir as { abs, rel } where rel is
|
||||
// relative to the storage root (so the importer restores the same layout).
|
||||
async function collectDir(subdir, storageRoot, acc) {
|
||||
const abs = path.join(storageRoot, subdir);
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(abs, { withFileTypes: true });
|
||||
} catch (_) {
|
||||
return; // subdir may not exist on this install — skip silently
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const childRel = path.join(subdir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await collectDir(childRel, storageRoot, acc);
|
||||
} else if (entry.isFile()) {
|
||||
acc.push({ abs: path.join(storageRoot, childRel), rel: childRel });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectFiles(includePhotos) {
|
||||
const storageRoot = getStoragePath();
|
||||
const dirs = includePhotos ? [...DOC_DIRS, ...PHOTO_DIRS] : [...DOC_DIRS];
|
||||
const acc = [];
|
||||
for (const d of dirs) {
|
||||
await collectDir(d, storageRoot, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a .picpeak archive.
|
||||
* @param {Object} opts
|
||||
* @param {boolean} [opts.includePhotos=false] include original gallery photos
|
||||
* @param {string} [opts.outDir] where to write the file (defaults to a temp dir)
|
||||
* @returns {Promise<{ filePath: string, manifest: object }>}
|
||||
*/
|
||||
async function createPicpeak({ includePhotos = false, outDir } = {}) {
|
||||
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-export-'));
|
||||
const dataDir = path.join(staging, 'data');
|
||||
await fsp.mkdir(dataDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// 1. Dump every table to NDJSON, tracking counts + checksums.
|
||||
const tables = await listDataTables();
|
||||
const tableMeta = {};
|
||||
for (const table of tables) {
|
||||
tableMeta[table] = await writeTableNdjson(table, dataDir);
|
||||
}
|
||||
|
||||
// 2. Gather the non-recalculable blobs (PDFs, business-docs, uploads, and
|
||||
// optionally original photos).
|
||||
const files = await collectFiles(includePhotos);
|
||||
|
||||
// 3. Manifest — everything the importer needs to validate + reconstruct.
|
||||
const manifest = {
|
||||
format: PICPEAK_FORMAT_VERSION,
|
||||
kind: 'picpeak-backup',
|
||||
created_at: new Date().toISOString(),
|
||||
app_version: packageJson.version || null,
|
||||
database: {
|
||||
engine: isPostgres() ? 'pg' : 'sqlite',
|
||||
latest_migration: await getLatestMigration(),
|
||||
},
|
||||
options: { includePhotos: !!includePhotos },
|
||||
tables: tableMeta,
|
||||
file_count: files.length,
|
||||
// NOTE: contains secrets (SMTP password, admin hashes, API keys) in plain
|
||||
// text — the download surface must warn about this.
|
||||
contains_secrets: true,
|
||||
};
|
||||
await fsp.writeFile(
|
||||
path.join(staging, 'manifest.json'),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// 4. Zip staging (manifest + data/) plus the blobs under files/. The final
|
||||
// .picpeak lands in outDir (caller-managed) or a fresh temp dir; either
|
||||
// way the NDJSON scratch (which holds plaintext secrets) is always
|
||||
// removed in `finally` below.
|
||||
const targetDir = outDir || (await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-out-')));
|
||||
await fsp.mkdir(targetDir, { recursive: true });
|
||||
const stamp = manifest.created_at.replace(/[:.]/g, '-');
|
||||
const filePath = path.join(targetDir, `picpeak-backup-${stamp}.picpeak`);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(filePath);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
output.on('close', resolve);
|
||||
output.on('error', reject);
|
||||
archive.on('error', reject);
|
||||
// Surface archiver warnings (e.g. a file vanished mid-run) instead of
|
||||
// silently shipping an incomplete archive.
|
||||
archive.on('warning', (err) => reject(err));
|
||||
archive.pipe(output);
|
||||
archive.file(path.join(staging, 'manifest.json'), { name: 'manifest.json' });
|
||||
archive.directory(dataDir, 'data');
|
||||
for (const f of files) {
|
||||
archive.file(f.abs, { name: path.posix.join('files', f.rel.split(path.sep).join('/')) });
|
||||
}
|
||||
archive.finalize();
|
||||
});
|
||||
} catch (err) {
|
||||
// Archiver failed → the partial .picpeak holds plaintext secrets and is
|
||||
// useless; remove our own temp out dir so it isn't orphaned. A
|
||||
// caller-supplied outDir is left untouched.
|
||||
if (!outDir) await fsp.rm(targetDir, { recursive: true, force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[picpeak-export] wrote ${filePath} (${tables.length} tables, ${files.length} files, includePhotos=${!!includePhotos})`
|
||||
);
|
||||
return { filePath, manifest };
|
||||
} finally {
|
||||
// Always remove the NDJSON scratch dir — it contains a plaintext dump of
|
||||
// every table (secrets included). The final .picpeak is elsewhere.
|
||||
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PICPEAK_FORMAT_VERSION,
|
||||
EXCLUDED_TABLES,
|
||||
createPicpeak,
|
||||
// exported for reuse/testing
|
||||
listDataTables,
|
||||
collectFiles,
|
||||
};
|
||||
@@ -1,435 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// Receiving half of the GUI-only backup roundtrip: takes a ".picpeak" produced
|
||||
// by picpeakExportService and restores it onto THIS instance.
|
||||
//
|
||||
// Restore semantics (agreed design): FULL OVERRIDE — every table is wiped and
|
||||
// replaced by the backup's rows — EXCEPT the current logged-in admin account,
|
||||
// which is preserved so the operator is never locked out. A backup admin whose
|
||||
// email collides with the current account is overwritten with the current
|
||||
// account's credentials (so the operator's known password keeps working).
|
||||
//
|
||||
// Same-engine only (pg↔pg / sqlite↔sqlite) and forward-only (an older backup
|
||||
// restores onto a newer instance; a newer backup is refused). The target's own
|
||||
// schema is used as-is — we never replay the backup's DDL.
|
||||
|
||||
const fs = require('fs');
|
||||
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');
|
||||
|
||||
const isPostgres = () => knexConfig.client === 'pg';
|
||||
|
||||
// Compare migrations by their numeric filename prefix (001_, 107_, 129_ …).
|
||||
function migrationOrder(name) {
|
||||
const m = String(name || '').match(/^(\d+)/);
|
||||
return m ? parseInt(m[1], 10) : -1;
|
||||
}
|
||||
|
||||
async function readManifestFromZip(picpeakPath) {
|
||||
const zip = new StreamZip.async({ file: picpeakPath });
|
||||
try {
|
||||
return JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
|
||||
} finally {
|
||||
await zip.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns an array of human-readable blockers ([] = OK to restore).
|
||||
async function validateManifest(manifest) {
|
||||
const errors = [];
|
||||
if (!manifest || manifest.kind !== 'picpeak-backup') {
|
||||
return ['This file is not a PicPeak backup (.picpeak).'];
|
||||
}
|
||||
if (Number(manifest.format) > PICPEAK_FORMAT_VERSION) {
|
||||
errors.push('This backup was created by a newer version of PicPeak. Update this instance first.');
|
||||
}
|
||||
const engine = isPostgres() ? 'pg' : 'sqlite';
|
||||
if (manifest.database && manifest.database.engine && manifest.database.engine !== engine) {
|
||||
errors.push(`Database engine mismatch: the backup is "${manifest.database.engine}" but this instance is "${engine}". Restore is only supported between matching engines.`);
|
||||
}
|
||||
// Forward-only: the target schema must be at least as new as the backup's.
|
||||
let targetLatest = null;
|
||||
try {
|
||||
const applied = await db('knex_migrations').orderBy('id', 'desc').limit(1);
|
||||
targetLatest = applied[0] ? applied[0].name : null;
|
||||
} catch (_) {
|
||||
// No knex_migrations table (e.g. some test harnesses) — skip the check.
|
||||
}
|
||||
const backupLatest = manifest.database ? manifest.database.latest_migration : null;
|
||||
if (backupLatest && targetLatest && migrationOrder(backupLatest) > migrationOrder(targetLatest)) {
|
||||
errors.push('This backup is from a newer database schema than this instance. Update this instance to at least the backup version before restoring.');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function parseNdjson(filePath) {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
return fs
|
||||
.readFileSync(filePath, 'utf8')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
// 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.
|
||||
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;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// unquoted and pg rejects it ("invalid input syntax for type json").
|
||||
async function jsonColumnsFor(trx, table) {
|
||||
if (!isPostgres()) return new Set();
|
||||
const res = await trx.raw(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? AND data_type IN ('json', 'jsonb')",
|
||||
[table]
|
||||
);
|
||||
return new Set(res.rows.map((r) => r.column_name));
|
||||
}
|
||||
|
||||
function serialiseJsonColumns(rows, jsonCols) {
|
||||
if (!jsonCols.size) return rows;
|
||||
return rows.map((row) => {
|
||||
const out = { ...row };
|
||||
for (const col of jsonCols) {
|
||||
if (out[col] !== undefined && out[col] !== null) out[col] = JSON.stringify(out[col]);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// Whole-DB replace in one transaction with FK enforcement suspended (pg:
|
||||
// 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) {
|
||||
await db.transaction(async (trx) => {
|
||||
if (isPostgres()) {
|
||||
try {
|
||||
await trx.raw("SET session_replication_role = 'replica'");
|
||||
} catch (_) {
|
||||
// session_replication_role requires a Postgres SUPERUSER. The bundled
|
||||
// postgres image's role is one; managed Postgres (RDS / Cloud SQL / …)
|
||||
// app users usually are not. Fail fast with a clear message BEFORE any
|
||||
// rows are deleted — the transaction rolls back, so nothing is wiped.
|
||||
const err = new Error(
|
||||
'Restore needs a PostgreSQL superuser to suspend foreign-key checks during the full replace, but this instance’s database user is not a superuser (common on managed Postgres such as RDS or Cloud SQL). Restore onto the bundled Postgres, or grant the role superuser for the restore.'
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
await trx.raw('PRAGMA defer_foreign_keys = ON');
|
||||
}
|
||||
|
||||
for (const table of tables) {
|
||||
await trx(table).del();
|
||||
}
|
||||
for (const table of tables) {
|
||||
const rows = parseNdjson(path.join(dataDir, `${table}.ndjson`));
|
||||
if (!rows.length) continue;
|
||||
const jsonCols = await jsonColumnsFor(trx, table);
|
||||
await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100);
|
||||
}
|
||||
|
||||
const operatorId = await reinjectCurrentAdmin(trx, currentAdmin);
|
||||
if (operatorId && roleSnapshot) {
|
||||
await preserveOperatorRole(trx, operatorId, roleSnapshot);
|
||||
}
|
||||
|
||||
// Reset the pg session flag BEFORE the connection returns to the pool.
|
||||
if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'");
|
||||
});
|
||||
}
|
||||
|
||||
// Copy the archive's files/ tree into storage, overwriting existing files.
|
||||
async function restoreFiles(stagingDir) {
|
||||
const src = path.join(stagingDir, 'files');
|
||||
if (!fs.existsSync(src)) return 0;
|
||||
const storageRoot = getStoragePath();
|
||||
let count = 0;
|
||||
async function walk(rel) {
|
||||
const abs = path.join(src, rel);
|
||||
for (const entry of await fsp.readdir(abs, { withFileTypes: true })) {
|
||||
const childRel = path.join(rel, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(childRel);
|
||||
} else if (entry.isFile()) {
|
||||
const dest = path.join(storageRoot, childRel);
|
||||
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
||||
await fsp.copyFile(path.join(src, childRel), dest);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk('');
|
||||
return count;
|
||||
}
|
||||
|
||||
// Does the restored data reference an external-media library? If so the caller
|
||||
// shows a banner telling the admin to (re)configure the external-media mount on
|
||||
// this instance — those files are NOT in the backup by design.
|
||||
async function detectExternalMedia() {
|
||||
try {
|
||||
if (await hasColumnCached('events', 'external_path')) {
|
||||
const row = await db('events').whereNotNull('external_path').first();
|
||||
if (row) return true;
|
||||
}
|
||||
if (await hasColumnCached('photos', 'external_relpath')) {
|
||||
const row = await db('photos').whereNotNull('external_relpath').first();
|
||||
if (row) return true;
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort — a detection miss is not worth failing the restore.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a .picpeak onto this instance.
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.picpeakPath path to the uploaded/staged .picpeak
|
||||
* @param {number} [opts.currentAdminId] admin to preserve across the wipe
|
||||
* @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, manifest:object}>}
|
||||
*/
|
||||
async function importFromPicpeak({ picpeakPath, currentAdminId }) {
|
||||
const manifest = await readManifestFromZip(picpeakPath);
|
||||
const blockers = await validateManifest(manifest);
|
||||
if (blockers.length) {
|
||||
const err = new Error(blockers[0]);
|
||||
err.statusCode = 400;
|
||||
err.validation = blockers;
|
||||
throw err;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
const dataDir = path.join(staging, 'data');
|
||||
// Only touch tables that (a) the uploaded manifest lists AND (b) actually
|
||||
// exist as real tables in THIS database. listDataTables() already excludes
|
||||
// knex_migrations/_lock (EXCLUDED_TABLES), so a crafted or corrupted
|
||||
// .picpeak can never make the restore delete the migration bookkeeping — or
|
||||
// any table that isn't a genuine data table here.
|
||||
const dbTables = new Set(await listDataTables());
|
||||
const manifestTables = Object.keys(manifest.tables || {});
|
||||
const tables = manifestTables.filter((tbl) => dbTables.has(tbl) && !EXCLUDED_TABLES.has(tbl));
|
||||
const skipped = manifestTables.filter((tbl) => !tables.includes(tbl));
|
||||
if (skipped.length) {
|
||||
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));
|
||||
|
||||
const filesRestored = await restoreFiles(staging);
|
||||
const usesExternalMedia = await detectExternalMedia();
|
||||
|
||||
logger.info(
|
||||
`[picpeak-import] restored ${tables.length} tables, ${filesRestored} files (externalMedia=${usesExternalMedia})`
|
||||
);
|
||||
return { restored: true, tables: tables.length, filesRestored, usesExternalMedia, manifest };
|
||||
} finally {
|
||||
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
importFromPicpeak,
|
||||
readManifestFromZip,
|
||||
validateManifest,
|
||||
reinjectCurrentAdmin,
|
||||
captureOperatorRole,
|
||||
preserveOperatorRole,
|
||||
resyncSequences,
|
||||
};
|
||||
@@ -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 quote→event 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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -31,20 +31,4 @@ function ensureNumber(value, fallback = 0) {
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a value as an integer clamped to [min, max]; `undefined` on
|
||||
* anything that doesn't parse (null, undefined, '', booleans, garbage).
|
||||
*
|
||||
* Exists because the inline guard `Number.isFinite(+v) ? parseInt(v)`
|
||||
* disagrees with itself for null/''/true (`+null` is 0 but
|
||||
* `parseInt(null)` is NaN), which let NaN through Math.min/Math.max
|
||||
* and into an INSERT — PostgreSQL rejects NaN for integer columns
|
||||
* while SQLite silently stores NULL, so it only failed on PG.
|
||||
*/
|
||||
function clampIntOrUndefined(value, min, max) {
|
||||
const n = parseInt(value, 10);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
module.exports = { ensureInt, ensureNumber, clampIntOrUndefined };
|
||||
module.exports = { ensureInt, ensureNumber };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user