Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f83d144f28 | |||
| b62cd2c290 | |||
| eaa8b41ba3 | |||
| d46397d92a | |||
| e46260ad07 | |||
| e9fadd2ef4 | |||
| d977e3e296 | |||
| da44f1947b | |||
| dc9e3cdc5e | |||
| 7598e20f55 | |||
| 32db1c8052 | |||
| 9833237d37 | |||
| 83290a0f1a | |||
| 6df42ab22c | |||
| 45ffe64b7c | |||
| 84eab88801 | |||
| 10d5cf54a5 | |||
| 376311cb90 | |||
| 88fa3c5297 | |||
| 980378a17b | |||
| 0a999795cc | |||
| ed4e32c4df | |||
| 9003b34c8a | |||
| de459c701f | |||
| 8b6cd3c74f | |||
| fb3d0b08b2 | |||
| 945e63ae86 | |||
| 93d4ae68f4 | |||
| 2bdb1204fe | |||
| cee0a380a6 | |||
| c01d8d8d2e | |||
| bf9bd76278 | |||
| 0fe5792a7d | |||
| 3f7364be8e | |||
| 2d0e6ab2dc | |||
| cc49f6997a | |||
| fecc18cbc8 | |||
| 7f27e6771f | |||
| 4e99897313 | |||
| ccab9024d4 | |||
| 11f9f584de | |||
| 3b88036fda | |||
| 0c73bf2cdc | |||
| 2c7b5dfd02 | |||
| 5d5db4e766 | |||
| e5dccf1664 | |||
| bfafecedc7 | |||
| 2c5a094c5c | |||
| 2462ba6897 | |||
| 90275f88e9 | |||
| 34a7b1c013 | |||
| 7419c68337 | |||
| fc99e2b233 | |||
| 7974b9c6d7 | |||
| 60cbda5b22 | |||
| a27d19b4d1 | |||
| d68d84e5c8 | |||
| 6891769124 | |||
| b32ba1ed6b |
+3
-2
@@ -56,8 +56,9 @@ DB_NAME=picpeak_prod
|
||||
# Admin Account (initial setup) — OPTIONAL
|
||||
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
|
||||
# open /admin and PicPeak shows a setup screen. The one-time setup token is
|
||||
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
|
||||
# and saved to data/SETUP_TOKEN.
|
||||
# written to data/SETUP_TOKEN with mode 0600 — read it with
|
||||
# `docker compose exec backend cat /app/data/SETUP_TOKEN`. It is NOT logged
|
||||
# unless that write fails, so it never sits in `docker logs`.
|
||||
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
|
||||
# credentials written to data/ADMIN_CREDENTIALS.txt).
|
||||
#ADMIN_USERNAME=admin
|
||||
|
||||
@@ -203,6 +203,14 @@ jobs:
|
||||
format: 'sarif'
|
||||
output: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
# Base-image CVEs with no released fix are not actionable: the
|
||||
# Dockerfiles already run `apt-get upgrade -y` behind a CACHEBUST,
|
||||
# so a fix lands in the next build automatically. Reporting them
|
||||
# buries the findings someone can actually do something about.
|
||||
# Dropping them is also the precondition for ever setting
|
||||
# exit-code: 1, which build-backend's comment flags as a
|
||||
# deliberate follow-up.
|
||||
ignore-unfixed: true
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
@@ -425,6 +433,14 @@ jobs:
|
||||
format: 'sarif'
|
||||
output: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
# Base-image CVEs with no released fix are not actionable: the
|
||||
# Dockerfiles already run `apt-get upgrade -y` behind a CACHEBUST,
|
||||
# so a fix lands in the next build automatically. Reporting them
|
||||
# buries the findings someone can actually do something about.
|
||||
# Dropping them is also the precondition for ever setting
|
||||
# exit-code: 1, which build-backend's comment flags as a
|
||||
# deliberate follow-up.
|
||||
ignore-unfixed: true
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
|
||||
@@ -27,32 +27,12 @@ jobs:
|
||||
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
|
||||
# whenever no PAT is configured.
|
||||
- name: Auto-approve and enable auto-merge on the release PR
|
||||
if: ${{ steps.release.outputs.release_created != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
# No checkout in this job — set the repo explicitly so gh works
|
||||
# without a git remote (same pattern as whatsnew, 2a5f0a8).
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_PAT" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
|
||||
exit 0
|
||||
fi
|
||||
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
|
||||
if [ -n "$pr" ]; then
|
||||
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
|
||||
# is a valid review; enable auto-merge as the PAT so the merge commit is
|
||||
# attributed to a real identity and triggers the tag-cutting run (#719).
|
||||
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
|
||||
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
|
||||
else
|
||||
echo "No open release PR to auto-merge."
|
||||
fi
|
||||
# NOTE: stable release PRs are intentionally NOT auto-merged here
|
||||
# anymore. Fixes accumulate in the rolling release PR and are cut as
|
||||
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
|
||||
# or on demand via workflow_dispatch / a manual merge of the release
|
||||
# PR). Beta keeps instant releases — see release-please-beta.yml —
|
||||
# because same-day reporter verification depends on it.
|
||||
|
||||
- name: Output Release Info
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Cut Stable Release (daily batch)
|
||||
|
||||
# Stable fixes accumulate in release-please's rolling release PR instead of
|
||||
# each cutting its own patch version (the old per-merge auto-merge produced
|
||||
# e.g. 3.45.8 AND 3.45.9 on the same day). This workflow merges the open
|
||||
# stable release PR once a day, so a day of N bugfixes ships as ONE version
|
||||
# with all N changelog entries — and one Docker build instead of N.
|
||||
#
|
||||
# - schedule only fires from the default branch (main); the stable copy of
|
||||
# this file is inert and exists to keep the branches in sync.
|
||||
# - Need a release NOW? Run this via workflow_dispatch, or merge the
|
||||
# release PR by hand — the schedule is a default, not a gate.
|
||||
# - Approval/merge mechanics mirror the old inline step (#719): approve as
|
||||
# github-actions[bot] (GITHUB_TOKEN, a valid distinct reviewer), enable
|
||||
# auto-merge as the PAT so the merge attributes to a real identity and
|
||||
# triggers the tag-cutting run. --auto waits for green checks.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
merge-stable-release-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Approve and enable auto-merge on the open stable release PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
# No checkout — set the repo explicitly so gh works without a
|
||||
# git remote (same pattern as whatsnew, 2a5f0a8).
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_PAT" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN not set — skipping (manual review required)."
|
||||
exit 0
|
||||
fi
|
||||
# Strict selection (review P1): this job runs daily even without a
|
||||
# stable push, and `gh pr list --head` matches the branch NAME only
|
||||
# — a fork PR can spoof `release-please--branches--stable`. Pin the
|
||||
# base to stable AND require a same-repo head (isCrossRepository
|
||||
# == false); a fork PR is cross-repository, so it can never be
|
||||
# picked and auto-merged with the privileged PAT.
|
||||
pr=$(gh pr list \
|
||||
--base stable \
|
||||
--head release-please--branches--stable \
|
||||
--state open \
|
||||
--json number,isCrossRepository \
|
||||
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // empty')
|
||||
if [ -z "$pr" ]; then
|
||||
echo "No open same-repo stable release PR — nothing to cut today."
|
||||
exit 0
|
||||
fi
|
||||
# Approve is tolerant — a pre-existing approval already satisfies
|
||||
# branch protection and re-approving can return non-zero.
|
||||
gh pr review "$pr" --approve --body "Automated approval — daily stable release batch (release-please version bump + changelog)." || echo "::warning::approve returned non-zero (PR may already be approved)"
|
||||
# But the auto-merge enable is the load-bearing step: this scheduled
|
||||
# job is the ONLY automatic stable cut, so DON'T swallow its failure
|
||||
# (review P2) — an expired/under-scoped PAT would otherwise stop
|
||||
# releases while the workflow stays green.
|
||||
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto
|
||||
# `gh pr merge --auto` merges IMMEDIATELY when the required checks
|
||||
# are already green — the normal case at 18:00, since the fixes
|
||||
# merged hours earlier and CI passed. So success is EITHER the PR is
|
||||
# already merged OR an auto-merge request is now pending; only a PR
|
||||
# that is still open with no auto-merge request is a real failure
|
||||
# (expired/under-scoped PAT) worth failing the job on (review round 2).
|
||||
# One snapshot of both fields (review round 3): querying state and
|
||||
# autoMergeRequest separately races — auto-merge can complete
|
||||
# between the two calls, so the first sees OPEN and the second sees
|
||||
# the request already cleared on the now-merged PR → false failure.
|
||||
read -r state automerge < <(gh pr view "$pr" --json state,autoMergeRequest \
|
||||
--jq '[.state, (.autoMergeRequest != null)] | @tsv')
|
||||
if [ "$state" = "MERGED" ]; then
|
||||
echo "Stable release PR #$pr merged immediately (checks were already green)."
|
||||
elif [ "$automerge" = "true" ]; then
|
||||
echo "Auto-merge enabled on stable release PR #$pr — merges when checks are green."
|
||||
else
|
||||
echo "::error::stable release PR #$pr is still open with no auto-merge — check RELEASE_PLEASE_TOKEN scope/expiry."
|
||||
exit 1
|
||||
fi
|
||||
@@ -30,6 +30,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
# The .picpeak restore suites gate their real-Postgres cases behind
|
||||
# PICPEAK_PG_TEST_URL and `describe.skip` themselves out when it is
|
||||
# unset — so until now they never ran here. That hid the half that
|
||||
# matters: sequence resync, operator/role preservation across a
|
||||
# cross-instance restore, and (with #1041) whether a SQLite-shaped
|
||||
# row actually lands in Postgres with the right STORED VALUES rather
|
||||
# than merely not throwing. Everything else in the suite still runs
|
||||
# on SQLite; this service only un-gates those cases.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_USER: picpeak
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: picpeak_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U picpeak -d picpeak_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 2s
|
||||
--health-retries 30
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -52,6 +75,9 @@ jobs:
|
||||
# The S3 path itself is covered separately by the integration
|
||||
# suite when MinIO is provisioned.
|
||||
SKIP_S3_TESTS: 'true'
|
||||
# Un-gates the real-Postgres cases in the .picpeak restore suites
|
||||
# (see the `services:` note above). Absent it they silently skip.
|
||||
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
|
||||
run: |
|
||||
# Excluded suites — fail on upstream/beta too, tracked
|
||||
# separately as test-infra debt:
|
||||
|
||||
+5
-2
@@ -130,5 +130,8 @@ docker-compose.dev.yml
|
||||
# New layout development files
|
||||
new-layouts/
|
||||
|
||||
# Generated CRM/accounting documents (runtime) — never commit
|
||||
backend/storage/business-docs/
|
||||
# Backend runtime storage (generated media, previews, thumbnails,
|
||||
# CRM/accounting documents) — never commit. Matches main: a dev instance
|
||||
# writes event photos into backend/storage/, and the narrower
|
||||
# business-docs-only rule let `git add -A` sweep them into a commit.
|
||||
backend/storage/
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"3.45.9"}
|
||||
{".":"3.46.4"}
|
||||
|
||||
+117
@@ -5,6 +5,123 @@ 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.46.4](https://github.com/PicPeak/picpeak/compare/v3.46.3...v3.46.4) (2026-08-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** a guest's own hidden feedback is hidden from them too ([#1150](https://github.com/PicPeak/picpeak/issues/1150)) ([#1157](https://github.com/PicPeak/picpeak/issues/1157)) ([b62cd2c](https://github.com/PicPeak/picpeak/commit/b62cd2c290d54820e8f58d11719d48592a1cd1f1))
|
||||
* **gallery:** guest filters respect show_feedback_to_guests ([#1044](https://github.com/PicPeak/picpeak/issues/1044)) ([#1156](https://github.com/PicPeak/picpeak/issues/1156)) ([eaa8b41](https://github.com/PicPeak/picpeak/commit/eaa8b41ba323c7eac22e04947fead8e468e9c6c2))
|
||||
* **gallery:** no Logout button on galleries that don't require a password ([#1149](https://github.com/PicPeak/picpeak/issues/1149)) ([#1154](https://github.com/PicPeak/picpeak/issues/1154)) ([d46397d](https://github.com/PicPeak/picpeak/commit/d46397d92a7648910075fb774b14abf77d893865))
|
||||
* **scripts:** regenerate-thumbnails resolves external sources through ensureThumbnail ([#1148](https://github.com/PicPeak/picpeak/issues/1148)) ([#1155](https://github.com/PicPeak/picpeak/issues/1155)) ([e46260a](https://github.com/PicPeak/picpeak/commit/e46260ad0799bd411a4158c4cc31d587ba85d4ca))
|
||||
|
||||
## [3.46.3](https://github.com/PicPeak/picpeak/compare/v3.46.2...v3.46.3) (2026-08-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** a missing file must not take the backend down ([#1128](https://github.com/PicPeak/picpeak/issues/1128)) ([da44f19](https://github.com/PicPeak/picpeak/commit/da44f1947b8317b47271f4f2a98b284b25d752c1))
|
||||
* **gallery:** give masonry tiles their real shape back ([#1130](https://github.com/PicPeak/picpeak/issues/1130), [#1131](https://github.com/PicPeak/picpeak/issues/1131)) ([d977e3e](https://github.com/PicPeak/picpeak/commit/d977e3e296deeb19c26f1e5a98258eec323d120d))
|
||||
* **thumbnails:** regenerate external photos, and stop destroying good ones ([#1129](https://github.com/PicPeak/picpeak/issues/1129)) ([dc9e3cd](https://github.com/PicPeak/picpeak/commit/dc9e3cdc5e00ac634f581e8d6b13107fe4839152))
|
||||
|
||||
## [3.46.2](https://github.com/PicPeak/picpeak/compare/v3.46.1...v3.46.2) (2026-08-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ui:** stop iOS Safari zooming in on 14px form fields ([#1114](https://github.com/PicPeak/picpeak/issues/1114)) ([32db1c8](https://github.com/PicPeak/picpeak/commit/32db1c8052d324b09462a17859c7adb5ccfe56e3))
|
||||
|
||||
## [3.46.1](https://github.com/PicPeak/picpeak/compare/v3.46.0...v3.46.1) (2026-08-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **preview:** generate lightbox previews for external/reference photos ([#1078](https://github.com/PicPeak/picpeak/issues/1078)) ([#1080](https://github.com/PicPeak/picpeak/issues/1080)) ([6df42ab](https://github.com/PicPeak/picpeak/commit/6df42ab22c705bcb731862db1ed5a27de0a64f30))
|
||||
|
||||
## [3.46.0](https://github.com/PicPeak/picpeak/compare/v3.45.16...v3.46.0) (2026-08-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backup:** open sqlite → pg .picpeak restore as the supported upgrade direction ([#1041](https://github.com/PicPeak/picpeak/issues/1041)) ([#1059](https://github.com/PicPeak/picpeak/issues/1059)) ([980378a](https://github.com/PicPeak/picpeak/commit/980378a17ba873d0e2f3d76048dacb3b8d7a4eb2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **pdf:** RFC 6266-encode Content-Disposition on quote/invoice PDFs ([#1024](https://github.com/PicPeak/picpeak/issues/1024)) ([#1062](https://github.com/PicPeak/picpeak/issues/1062)) ([376311c](https://github.com/PicPeak/picpeak/commit/376311cb9091ff1726e8b383312f22c607dcc8a0))
|
||||
* **storage:** add S3 client timeouts so a dropped connection can't wedge uploads ([#1049](https://github.com/PicPeak/picpeak/issues/1049)) ([#1054](https://github.com/PicPeak/picpeak/issues/1054)) ([88fa3c5](https://github.com/PicPeak/picpeak/commit/88fa3c52973fa122f8d4e7b21ba1ffc89f9f9c2e))
|
||||
|
||||
## [3.45.16](https://github.com/PicPeak/picpeak/compare/v3.45.15...v3.45.16) (2026-08-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** default NODE_ENV=production so non-compose deploys don't fall back to SQLite ([#1038](https://github.com/PicPeak/picpeak/issues/1038)) ([#1040](https://github.com/PicPeak/picpeak/issues/1040)) ([9003b34](https://github.com/PicPeak/picpeak/commit/9003b34c8a0396cd28906f089aef33f38a23ffb7))
|
||||
* **events:** make event_date/expires_at nullable on SQLite ([#1029](https://github.com/PicPeak/picpeak/issues/1029)) ([#1036](https://github.com/PicPeak/picpeak/issues/1036)) ([fb3d0b0](https://github.com/PicPeak/picpeak/commit/fb3d0b08b2dc34f7e7dab7da754a3522c52a9eb1))
|
||||
* **feedback:** persist guest feedback settings, unshadow the guest route ([#1030](https://github.com/PicPeak/picpeak/issues/1030)) ([#1032](https://github.com/PicPeak/picpeak/issues/1032)) ([de459c7](https://github.com/PicPeak/picpeak/commit/de459c701f28532ca53d52773b02de44c9978073))
|
||||
* **gallery:** coerce SQLite 0/1 booleans in the guest surface ([#1028](https://github.com/PicPeak/picpeak/issues/1028)) ([#1037](https://github.com/PicPeak/picpeak/issues/1037)) ([8b6cd3c](https://github.com/PicPeak/picpeak/commit/8b6cd3c74f2aeb5d38ebfeee04bbc211d6fa2c0c))
|
||||
|
||||
## [3.45.15](https://github.com/PicPeak/picpeak/compare/v3.45.14...v3.45.15) (2026-08-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** bump nanoid and js-yaml out of two HIGH advisories (stable) ([#1014](https://github.com/PicPeak/picpeak/issues/1014)) ([cee0a38](https://github.com/PicPeak/picpeak/commit/cee0a380a6faf2bb0a5c802057ba3140670840d2))
|
||||
* **slideshow:** stop "no crop" fit letterboxing a pre-cropped frame (stable) ([#1015](https://github.com/PicPeak/picpeak/issues/1015)) ([#1019](https://github.com/PicPeak/picpeak/issues/1019)) ([2bdb120](https://github.com/PicPeak/picpeak/commit/2bdb1204fe61a9b6cd704b35ccfd39efa15ed118))
|
||||
|
||||
## [3.45.14](https://github.com/PicPeak/picpeak/compare/v3.45.13...v3.45.14) (2026-08-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** bump ip-address, brace-expansion and postcss for open CVEs (stable) ([#988](https://github.com/PicPeak/picpeak/issues/988)) ([0fe5792](https://github.com/PicPeak/picpeak/commit/0fe5792a7d30bd948d6430642ca0bec35ddc2ca6))
|
||||
* **security:** vet the destination project when linking a deal (stable) ([#992](https://github.com/PicPeak/picpeak/issues/992)) ([bf9bd76](https://github.com/PicPeak/picpeak/commit/bf9bd762783a2a675f0a6fcd965addf0f47cec57))
|
||||
|
||||
## [3.45.13](https://github.com/PicPeak/picpeak/compare/v3.45.12...v3.45.13) (2026-08-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** fail closed when the adminAuth roles join errors (stable) ([#975](https://github.com/PicPeak/picpeak/issues/975)) ([cc49f69](https://github.com/PicPeak/picpeak/commit/cc49f6997ac54c3e25d5562721c446b7dac7f074))
|
||||
* **projects:** stop the cockpit offering email controls the API rejects (stable) ([#977](https://github.com/PicPeak/picpeak/issues/977)) ([2d0e6ab](https://github.com/PicPeak/picpeak/commit/2d0e6ab2dca84cf74c6c6b5c40ecae5c5cde814c))
|
||||
* **security:** backup/restore hardening — public-dir DB dump, restore path allowlist, gunzip bound, manifest keying (stable) ([#962](https://github.com/PicPeak/picpeak/issues/962)) ([3b88036](https://github.com/PicPeak/picpeak/commit/3b88036fda871b3a1ca2e933c39fa96e37950fe6))
|
||||
* **security:** bound inbound-mail resources, redact secrets from logs (stable) ([#965](https://github.com/PicPeak/picpeak/issues/965)) ([ccab902](https://github.com/PicPeak/picpeak/commit/ccab9024d4ef2f556169bbca8c6bba4801afe3a0))
|
||||
* **security:** enforce event ownership on the v1 API surface (GHSA-9697) (stable) ([#963](https://github.com/PicPeak/picpeak/issues/963)) ([4e99897](https://github.com/PicPeak/picpeak/commit/4e9989731390f9c067b048f4fa56ed3bf8ec472d))
|
||||
* **security:** enforce project ownership on project + project-email routes (stable) ([#966](https://github.com/PicPeak/picpeak/issues/966)) ([fecc18c](https://github.com/PicPeak/picpeak/commit/fecc18cbc837507bf30dd7502786de4e067855a5))
|
||||
* **security:** escape brand tokens, block tracker redirects, trim logo diagnostic (stable) ([#967](https://github.com/PicPeak/picpeak/issues/967)) ([7f27e67](https://github.com/PicPeak/picpeak/commit/7f27e6771f666a40ec0581dc7702be3a1de8330d))
|
||||
* **security:** scope dashboard stats/analytics/activity to the caller's events (stable) ([#964](https://github.com/PicPeak/picpeak/issues/964)) ([11f9f58](https://github.com/PicPeak/picpeak/commit/11f9f584ded5f777a61dc2e1e637d478a61ac377))
|
||||
|
||||
## [3.45.12](https://github.com/PicPeak/picpeak/compare/v3.45.11...v3.45.12) (2026-08-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** authz/ownership gaps (token binding, auth revocation, feedback/customer ownership, token logging) (stable) ([#951](https://github.com/PicPeak/picpeak/issues/951)) ([5d5db4e](https://github.com/PicPeak/picpeak/commit/5d5db4e766eee23a6678b399cdb9cc449fcea198))
|
||||
* **security:** neutralize spreadsheet formulas in all CSV/export cell-writers (CSV injection cluster) ([#949](https://github.com/PicPeak/picpeak/issues/949)) ([e5dccf1](https://github.com/PicPeak/picpeak/commit/e5dccf166419bb571b052990aada14801e16ac79))
|
||||
* **security:** redact gallery share tokens from analytics tracking (GHSA-7m6c) (stable) ([#953](https://github.com/PicPeak/picpeak/issues/953)) ([2c7b5df](https://github.com/PicPeak/picpeak/commit/2c7b5dfd020ac1fb2acdb7667998b2f373ce99d9))
|
||||
* **security:** unauth share_token leak (HIGH) + restore path-traversal, logo file-read (stable) ([#947](https://github.com/PicPeak/picpeak/issues/947)) ([bfafece](https://github.com/PicPeak/picpeak/commit/bfafecedc755790374565281287b9777ae0f5315))
|
||||
|
||||
## [3.45.11](https://github.com/PicPeak/picpeak/compare/v3.45.10...v3.45.11) (2026-08-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** block guest access to hidden/client-only photos across bulk + secure routes (stable) ([#940](https://github.com/PicPeak/picpeak/issues/940)) ([34a7b1c](https://github.com/PicPeak/picpeak/commit/34a7b1c0137cf4cba53f17270f619ce9755c4c98))
|
||||
* **security:** bump sanitize-html to 2.17.5 (CVE-2026-53606) (stable) ([#938](https://github.com/PicPeak/picpeak/issues/938)) ([7419c68](https://github.com/PicPeak/picpeak/commit/7419c683375d12650c448caa47ffe0444a7e1458))
|
||||
* **security:** close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs) (stable) ([#944](https://github.com/PicPeak/picpeak/issues/944)) ([2462ba6](https://github.com/PicPeak/picpeak/commit/2462ba6897c93b3f0834d60cc2e6827a45487062))
|
||||
* **security:** resolve DNS before vetting external hostnames (SSRF cluster) (stable) ([#942](https://github.com/PicPeak/picpeak/issues/942)) ([90275f8](https://github.com/PicPeak/picpeak/commit/90275f88e9af523ba3cc254cef2ca6f48a2a4129))
|
||||
* **uploads:** prevent cross-photo contamination from filename collisions and non-atomic writes ([#931](https://github.com/PicPeak/picpeak/issues/931)) (stable) ([#934](https://github.com/PicPeak/picpeak/issues/934)) ([fc99e2b](https://github.com/PicPeak/picpeak/commit/fc99e2b233b4a7c81c410f7d557e7dff270437bd))
|
||||
|
||||
## [3.45.10](https://github.com/PicPeak/picpeak/compare/v3.45.9...v3.45.10) (2026-07-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** expose view/download counters in the admin photos list ([#895](https://github.com/PicPeak/picpeak/issues/895) follow-up) (stable) ([#915](https://github.com/PicPeak/picpeak/issues/915)) ([a27d19b](https://github.com/PicPeak/picpeak/commit/a27d19b4d147c33166b47a6bac12b8d1be32daf1))
|
||||
* **admin:** serve videos with their real MIME type in the admin photo view ([#908](https://github.com/PicPeak/picpeak/issues/908)) (stable) ([#911](https://github.com/PicPeak/picpeak/issues/911)) ([d68d84e](https://github.com/PicPeak/picpeak/commit/d68d84e5c8cfcd123c95e474d2b87d153764f710))
|
||||
* **admin:** stop marking events expired up to 24h early ([#909](https://github.com/PicPeak/picpeak/issues/909)) (stable) ([#917](https://github.com/PicPeak/picpeak/issues/917)) ([6891769](https://github.com/PicPeak/picpeak/commit/6891769124f77d5af1bbe8eb0932a86753d89e7d))
|
||||
* **security:** close GHSA-g94x (cross-gallery photo read) + GHSA-pv6w (admin DB export) (stable) ([#925](https://github.com/PicPeak/picpeak/issues/925)) ([60cbda5](https://github.com/PicPeak/picpeak/commit/60cbda5b2228e0bddf5356eae279d9e7916e9ac2))
|
||||
|
||||
## [3.45.9](https://github.com/PicPeak/picpeak/compare/v3.45.8...v3.45.9) (2026-07-29)
|
||||
|
||||
|
||||
|
||||
@@ -111,10 +111,15 @@ docker compose up -d
|
||||
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
|
||||
|
||||
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
|
||||
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
|
||||
2. Read the **one-time setup token** from the 0600 file the backend writes it to
|
||||
(it is deliberately *not* printed to the logs — that would leave a live
|
||||
bootstrap credential in `docker logs`):
|
||||
```bash
|
||||
docker compose logs backend | grep -i "setup token"
|
||||
docker compose exec backend cat /app/data/SETUP_TOKEN
|
||||
```
|
||||
It is bind-mounted, so `sudo cat data/SETUP_TOKEN` on the host works too. Only
|
||||
if that file could not be written does the backend fall back to logging the
|
||||
token (`docker compose logs backend | grep -i "setup token"`).
|
||||
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
|
||||
|
||||
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
|
||||
|
||||
+4
-2
@@ -170,10 +170,12 @@ If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your ad
|
||||
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
|
||||
|
||||
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
|
||||
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
|
||||
2. Read the **one-time setup token** from the 0600 file the backend writes it to
|
||||
(it is not logged — that would leave a live credential in `docker logs`):
|
||||
```bash
|
||||
docker compose logs backend | grep -i "setup token"
|
||||
docker compose exec backend cat /app/data/SETUP_TOKEN
|
||||
```
|
||||
Only if that write fails does the backend log the token instead.
|
||||
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
|
||||
|
||||
## 🌐 Access Methods
|
||||
|
||||
@@ -27,6 +27,15 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# knexfile.js picks its config block by NODE_ENV, and the `development` block
|
||||
# defaults to sqlite3. Leaving NODE_ENV unset here meant every deployment that
|
||||
# doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
|
||||
# silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD, while
|
||||
# wait-for-db.sh (shell, reads DB_HOST directly) reported "PostgreSQL is up" in
|
||||
# the same log. The compose files still override this, so nothing changes for
|
||||
# compose users. See #1038.
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* POST /admin/thumbnails/regenerate for external/reference photos (#1129).
|
||||
*
|
||||
* STABLE TWIN. Diverges from the main version in one place: stable has no
|
||||
* responsive ?w= tiers (#1095/#1109), so there is no deleteThumbnailTiers call
|
||||
* to assert and the "drops the tiers first" test is absent here. Everything
|
||||
* else — the external rebuild, the thumbnail_path:null contract, video
|
||||
* skipping, per-event scoping and the superseded-key deletion — is identical.
|
||||
*
|
||||
* The route used to resolve every source as `storage/events/active/<path>` and
|
||||
* `fs.access` it. External and reference rows do not live there — their
|
||||
* originals sit under `events.external_path` — so every one of them failed the
|
||||
* check and was counted as an error.
|
||||
*
|
||||
* That alone would be inert. What made it destructive is that the tier
|
||||
* deletion runs FIRST (deliberately, so S3 and external rows are not skipped):
|
||||
* on a reference install the button dropped every ?w= tier and rebuilt
|
||||
* nothing, while the UI reported success — the response is sent before the
|
||||
* background loop starts.
|
||||
*
|
||||
* The background work is fired with setImmediate, so every assertion here has
|
||||
* to wait for it to drain rather than trusting the response.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('admin thumbnail regeneration (#1129)', () => {
|
||||
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
// One instance, not a fresh object per call — the route and the
|
||||
// assertions have to be looking at the same mock.
|
||||
jest.doMock('../../src/services/storage', () => {
|
||||
const instance = { delete: jest.fn().mockResolvedValue(undefined) };
|
||||
return { getStorage: () => instance };
|
||||
});
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
ensureThumbnail: jest.fn().mockResolvedValue('thumbnails/thumb_ext1_shot.jpg'),
|
||||
ensurePreviewImage: jest.fn().mockResolvedValue('previews/p.jpg'),
|
||||
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// bootCrmDb, not run-migrations: the latter calls process.exit(0) on
|
||||
// success, which ends the jest worker mid-suite.
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
storage = require('../../src/services/storage').getStorage();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/admin/thumbnails', require('../../src/routes/adminThumbnails'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
const [row] = await db('events').insert({
|
||||
slug: 'nas-wedding', event_type: 'wedding', event_name: 'nas',
|
||||
event_date: '2026-01-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
||||
password_hash: 'x', share_link: 'nas-share', expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference', external_path: 'weddings/2026-08',
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function seedPhoto(eventId, overrides = {}) {
|
||||
const [row] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'shot.jpg', path: 'nas-wedding/shot.jpg',
|
||||
type: 'individual', ...overrides,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** The work runs in setImmediate; give it room to finish. */
|
||||
const drain = () => new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
thumbnail_path: 'thumbnails/stale.jpg',
|
||||
});
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
expect(res.status).toBe(200);
|
||||
await drain();
|
||||
|
||||
// The whole bug: this used to be zero calls and one logged
|
||||
// "Original file not found" per photo.
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('nulls thumbnail_path so the valid-thumbnail short-circuit cannot skip the rebuild', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
thumbnail_path: 'thumbnails/still-on-disk.jpg',
|
||||
});
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// Without this the endpoint is a no-op whenever the OLD thumbnail is still
|
||||
// readable — which is the normal case after a settings change, and exactly
|
||||
// when the admin pressed the button.
|
||||
const [photoArg] = imageProcessor.ensureThumbnail.mock.calls[0];
|
||||
expect(photoArg.thumbnail_path).toBeNull();
|
||||
expect(photoArg.source_origin).toBe('external');
|
||||
// Carried through so ensureThumbnail can resolve off the mount rather than
|
||||
// under events/active.
|
||||
expect(photoArg.external_relpath).toBe('shot.jpg');
|
||||
});
|
||||
|
||||
it('leaves videos alone rather than handing a container file to Sharp', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'managed', media_type: 'video', filename: 'clip.mp4' });
|
||||
await seedPhoto(eventId, { source_origin: 'managed', filename: 'still.jpg' });
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
expect(imageProcessor.ensureThumbnail.mock.calls[0][0].filename).toBe('still.jpg');
|
||||
});
|
||||
|
||||
/**
|
||||
* On S3, ensureThumbnail downloads the source to a randomly-named temp file,
|
||||
* and for non-RAW input withProcessableImage passes no outputBasename — so
|
||||
* generateThumbnail derives the key from that random name and it differs on
|
||||
* every run. Nulling thumbnail_path hides the old key from everything that
|
||||
* would otherwise clean it up, so each regeneration would strand a full
|
||||
* thumbnail in the bucket, once per photo per run.
|
||||
*/
|
||||
describe('superseded canonical renditions', () => {
|
||||
it('removes the old thumbnail when the key moved', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_OLDRANDOM_shot.jpg',
|
||||
});
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEWRANDOM_shot.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).toHaveBeenCalledWith('thumbnails/thumb_OLDRANDOM_shot.jpg');
|
||||
});
|
||||
|
||||
it('does NOT delete when the key is unchanged — that is the new file', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_stable.jpg',
|
||||
});
|
||||
// Local storage resolves to a stable path, so the key is identical.
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_stable.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a Windows-style legacy path', 'thumbnails\\thumb_ext1_shot.jpg'],
|
||||
['a leading ./', './thumbnails/thumb_ext1_shot.jpg'],
|
||||
['a doubled separator', 'thumbnails//thumb_ext1_shot.jpg'],
|
||||
])('does not delete the file it just wrote when the old path is %s', async (_name, stored) => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'managed', thumbnail_path: stored });
|
||||
// Both storage backends fold these to the same key, so this is the SAME
|
||||
// object — deleting it would remove the freshly generated thumbnail and
|
||||
// leave the row pointing at nothing.
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_ext1_shot.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts the photo as regenerated even if the old object cannot be removed', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_OLD.jpg',
|
||||
});
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEW.jpg');
|
||||
storage.delete.mockRejectedValueOnce(new Error('bucket said no'));
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// Losing the old object is untidy; the regeneration itself succeeded.
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes to one event when asked', async () => {
|
||||
const a = await seedEvent();
|
||||
await seedPhoto(a, { source_origin: 'external', external_relpath: 'a.jpg' });
|
||||
const [b] = await db('events').insert({
|
||||
slug: 'other', event_type: 'wedding', event_name: 'other', event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: 'other-share', expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
await seedPhoto(typeof b === 'object' ? b.id : b, { source_origin: 'managed' });
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({ eventId: a });
|
||||
await drain();
|
||||
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Guest filters must respect show_feedback_to_guests (#1044 follow-up).
|
||||
*
|
||||
* Every filter token on /photos is an OR of two halves: what THIS viewer
|
||||
* marked, and what ANYONE marked. The response fields built from the second
|
||||
* half — like_count, comment_count — are all gated on
|
||||
* show_feedback_to_guests. The FILTER was not.
|
||||
*
|
||||
* So with the setting off, the numbers were hidden but `?filter=liked` still
|
||||
* returned exactly the photos other people had liked: the same information as
|
||||
* a set instead of a count, one token at a time. These tests pin the gate on
|
||||
* every token, and pin that the viewer's own half is never gated — filtering
|
||||
* by what you yourself marked is yours to do regardless.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'filter-visibility-secret';
|
||||
|
||||
const SLUG = 'filter-visibility-event';
|
||||
const ME = 'guest-me-identifier';
|
||||
const SOMEONE_ELSE = 'guest-other-identifier';
|
||||
|
||||
describe('guest filters and show_feedback_to_guests (#1044)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let mine;
|
||||
let theirs;
|
||||
let myGuestRowId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const setVisibility = (visible) => db('event_feedback_settings')
|
||||
.where({ event_id: eventId })
|
||||
.update({ show_feedback_to_guests: visible });
|
||||
|
||||
// A real verified guest, which is how the viewer's own feedback is actually
|
||||
// identified — NOT the `guest_id` query parameter the frontend invents.
|
||||
const guestToken = () => jwt.sign(
|
||||
{ type: 'guest', guestId: myGuestRowId, eventId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const filter = async (token, { as = 'me', claimGuestId } = {}) => {
|
||||
const req = request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.query({ filter: token, ...(claimGuestId ? { guest_id: claimGuestId } : {}) })
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
if (as === 'me') req.set('x-guest-token', guestToken());
|
||||
const res = await req;
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Filter Visibility',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'filter-visibility-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
const addPhoto = async (name) => {
|
||||
const p = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: name,
|
||||
path: `events/filter/${name}`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return p[0]?.id ?? p[0];
|
||||
};
|
||||
mine = await addPhoto('mine.jpg');
|
||||
theirs = await addPhoto('theirs.jpg');
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId,
|
||||
feedback_enabled: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_ratings: true,
|
||||
allow_favorites: true,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
});
|
||||
|
||||
const guestRow = await db('gallery_guests').insert({
|
||||
event_id: eventId,
|
||||
name: 'Me',
|
||||
identifier: ME,
|
||||
created_at: new Date().toISOString(),
|
||||
last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
}).returning('id');
|
||||
myGuestRowId = guestRow[0]?.id ?? guestRow[0];
|
||||
|
||||
const feedback = (photoId, who, type, extra = {}) => db('photo_feedback').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
guest_identifier: who,
|
||||
// Submission links to the per-person guest row when one is present, and
|
||||
// that is the column the viewer's own half resolves through.
|
||||
guest_id: who === ME ? myGuestRowId : null,
|
||||
feedback_type: type,
|
||||
is_approved: true,
|
||||
is_hidden: false,
|
||||
created_at: new Date().toISOString(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
// Everything on `theirs` belongs to somebody else; `mine` is this viewer's.
|
||||
await feedback(mine, ME, 'like');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'like');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'favorite');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'comment', { comment_text: 'lovely' });
|
||||
await feedback(theirs, SOMEONE_ELSE, 'rating', { rating: 5 });
|
||||
|
||||
// The denormalized counters the aggregate half of the filter reads.
|
||||
await db('photos').where('id', theirs).update({
|
||||
like_count: 1, favorite_count: 1, comment_count: 1, average_rating: 5,
|
||||
});
|
||||
await db('photos').where('id', mine).update({ like_count: 1 });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('with feedback visible to guests', () => {
|
||||
beforeAll(() => setVisibility(true));
|
||||
|
||||
it('shows other people\'s marks through every token, as before', async () => {
|
||||
expect(await filter('liked')).toEqual([mine, theirs].sort((a, b) => a - b));
|
||||
expect(await filter('favorited')).toEqual([theirs]);
|
||||
expect(await filter('rated')).toEqual([theirs]);
|
||||
expect(await filter('commented')).toEqual([theirs]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with feedback hidden from guests', () => {
|
||||
beforeAll(() => setVisibility(false));
|
||||
|
||||
it('stops every token from selecting on other people\'s marks', async () => {
|
||||
// `theirs` is the photo only other guests marked. It must not come back
|
||||
// through any token — a filter that selects on hidden feedback reports
|
||||
// that feedback just as surely as a count would.
|
||||
expect(await filter('favorited')).toEqual([]);
|
||||
expect(await filter('rated')).toEqual([]);
|
||||
expect(await filter('commented')).toEqual([]);
|
||||
});
|
||||
|
||||
it('still filters by what the viewer marked themselves', async () => {
|
||||
// The viewer's own half is never gated: this is their own action, and
|
||||
// hiding it would break "show me the ones I liked" for no privacy gain.
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
});
|
||||
|
||||
it('drops the viewer\'s own feedback once an admin hides it', async () => {
|
||||
// Moderation has to reach the filter too. getPhotoFeedback excludes
|
||||
// hidden rows for the guest's OWN feedback, so a photo matching here
|
||||
// would come back with nothing visible on it to explain why.
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
expect(await filter('liked')).toEqual([]);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: false });
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
});
|
||||
|
||||
it('ignores a guest_id supplied by the caller', async () => {
|
||||
// The own-half is resolved from the request identity. If it honoured the
|
||||
// query string instead, anyone holding another guest's identifier could
|
||||
// read that guest's hidden memberships one token at a time — straight
|
||||
// back through the gate this file exists to pin.
|
||||
expect(await filter('favorited', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
|
||||
// And an anonymous caller claiming to be me gets nothing of mine.
|
||||
expect(await filter('liked', { as: 'anon', claimGuestId: ME })).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Hidden feedback, seen from the guest who left it (#1150).
|
||||
*
|
||||
* Everything in the system treats a hidden row as absent: getPhotoFeedback
|
||||
* drops it even for the guest's own feedback, the /photos filters drop it, and
|
||||
* updatePhotoFeedbackStats does not count it. One place disagreed — the
|
||||
* per-viewer `is_liked` heart — so a like the photographer had hidden still
|
||||
* showed as liked on a photo whose like_count was zero. (The `my_color_label`
|
||||
* badge has the same shape on main; colour labels are not on this branch.)
|
||||
*
|
||||
* Making those two agree exposes the second half: the duplicate check that
|
||||
* powers like/favorite toggling did NOT skip hidden rows, so the now-empty
|
||||
* heart, when clicked, found the hidden row and toggled it OFF. The click
|
||||
* appeared to do nothing and it took two more to get back to a filled heart.
|
||||
*
|
||||
* Hiding a non-comment is deliberate, not an accident of the raw route: #839
|
||||
* and #1044 both ship it, with tests asserting that a hidden reaction or
|
||||
* colour label stops counting. So the fix is to make hidden mean absent
|
||||
* consistently — not to stop admins hiding these.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-feedback-secret';
|
||||
|
||||
const SLUG = 'hidden-own-feedback';
|
||||
const ME = 'guest-me-identifier';
|
||||
|
||||
describe('a guest\'s own hidden feedback (#1150)', () => {
|
||||
let db; let cleanup; let app; let feedbackService;
|
||||
let eventId; let photoId; let myGuestRowId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
const guestToken = () => jwt.sign(
|
||||
{ type: 'guest', guestId: myGuestRowId, eventId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const getPhoto = async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.set('x-guest-token', guestToken());
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return (photos || []).find((p) => p.id === photoId);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
feedbackService = require('../../src/services/feedbackService');
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Hidden Own Feedback',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'hidden-own-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'shot.jpg', path: 'events/hidden/shot.jpg',
|
||||
type: 'individual', uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const [g] = await db('gallery_guests').insert({
|
||||
event_id: eventId, name: 'Me', identifier: ME,
|
||||
created_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
}).returning('id');
|
||||
myGuestRowId = typeof g === 'object' ? g.id : g;
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId, feedback_enabled: true, allow_likes: true,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
});
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
const like = () => db('photo_feedback').insert({
|
||||
photo_id: photoId, event_id: eventId, guest_identifier: ME,
|
||||
guest_id: myGuestRowId, feedback_type: 'like',
|
||||
is_approved: true, is_hidden: false, created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photo_feedback').where({ photo_id: photoId }).del();
|
||||
await db('photos').where('id', photoId).update({ like_count: 0 });
|
||||
});
|
||||
|
||||
describe('the read surfaces agree with each other', () => {
|
||||
it('un-fills the heart once the like is hidden', async () => {
|
||||
await like();
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
expect((await getPhoto()).is_liked).toBe(true);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
|
||||
const photo = await getPhoto();
|
||||
// like_count already ignored hidden rows, so the heart was the only
|
||||
// thing still claiming this photo was liked.
|
||||
expect(photo.like_count).toBe(0);
|
||||
expect(photo.is_liked).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('and every other surface agrees', () => {
|
||||
it('keeps a hidden like out of /my-feedback', async () => {
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/my-feedback`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.set('x-guest-token', guestToken());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// In guest identity mode the Liked/Favorited/Rated chips and their
|
||||
// filters are built from THIS array, not from is_liked — so a hidden
|
||||
// like left an empty heart while the chip still counted it.
|
||||
expect(res.body.filter((f) => f.feedback_type === 'like')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not count a hidden row against the guest cap', async () => {
|
||||
await db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ max_likes_per_guest: 1 });
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
// The hidden row is room, not an occupant: the guest sees an empty
|
||||
// heart, and meeting that click with limit_reached leaves the control
|
||||
// dead until they un-like something they can still see.
|
||||
const result = await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
|
||||
});
|
||||
expect(result.limit_reached).toBeUndefined();
|
||||
|
||||
await db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ max_likes_per_guest: null });
|
||||
});
|
||||
|
||||
it('leaves other anonymous rows alone when there is no identity to scope by', async () => {
|
||||
// With neither guest_id nor guest_identifier the collapse scope degrades
|
||||
// to `guest_identifier IS NULL` — every identifier-less row on the
|
||||
// photo, i.e. other people's.
|
||||
const anon = (extra) => ({
|
||||
photo_id: photoId, event_id: eventId, feedback_type: 'like',
|
||||
is_approved: true, created_at: new Date().toISOString(), ...extra,
|
||||
});
|
||||
const [h] = await db('photo_feedback').insert(anon({ is_hidden: true })).returning('id');
|
||||
const hiddenId = typeof h === 'object' ? h.id : h;
|
||||
await db('photo_feedback').insert(anon({ is_hidden: false }));
|
||||
await db('photo_feedback').insert(anon({ is_hidden: false }));
|
||||
|
||||
await feedbackService.moderateFeedback(hiddenId, 'approve', 1);
|
||||
|
||||
expect(await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false }))
|
||||
.toHaveLength(3);
|
||||
});
|
||||
|
||||
it('collapses the replacement when an admin unhides the original', async () => {
|
||||
await like();
|
||||
const original = await db('photo_feedback').where({ photo_id: photoId }).first();
|
||||
await db('photo_feedback').where('id', original.id).update({ is_hidden: true });
|
||||
|
||||
await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
|
||||
});
|
||||
expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(2);
|
||||
|
||||
await feedbackService.moderateFeedback(original.id, 'approve', 1);
|
||||
|
||||
// Two visible rows for one guest would double-count in the tallies and
|
||||
// need two toggles to clear, since each deletes a single row.
|
||||
const visible = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
|
||||
expect(visible).toHaveLength(1);
|
||||
expect(visible[0].id).toBe(original.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and clicking still works afterwards', () => {
|
||||
it('re-liking creates a fresh row instead of toggling the hidden one off', async () => {
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
// What the guest sees is an empty heart, so this is an ADD.
|
||||
const result = await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like',
|
||||
guest_identifier: ME,
|
||||
guest_id: myGuestRowId,
|
||||
});
|
||||
|
||||
// Before this, the duplicate check found the hidden row and deleted it —
|
||||
// `removed: true` — so the click did nothing visible and the moderation
|
||||
// was silently undone.
|
||||
expect(result.removed).toBeUndefined();
|
||||
|
||||
const visible = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
|
||||
expect(visible).toHaveLength(1);
|
||||
expect((await getPhoto()).is_liked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Cross-engine .picpeak restore policy (#1041): a SQLite archive restored onto
|
||||
* a PostgreSQL instance — the official small-install → full-stack upgrade
|
||||
* path — now allowed by validateManifest's direction rule instead of the
|
||||
* former CLI-only allowEngineSwitch flag. The coercion engine itself
|
||||
* (typedColumnsFor / epochToIso / coerceForTargetEngine) landed with #1039;
|
||||
* these tests pin the direction policy and the coercion's cross-engine
|
||||
* value-correctness.
|
||||
*
|
||||
* Ungated: validateManifest direction rules and the pure coercion units.
|
||||
* The reverse direction (pg backup onto a sqlite instance) staying blocked is
|
||||
* pinned by picpeakRoundtrip.test.js, which runs on the real sqlite harness.
|
||||
*
|
||||
* Gated on PICPEAK_PG_TEST_URL (same contract as picpeakRestorePg.test.js):
|
||||
* sqlite-shaped NDJSON rows land in real Postgres with correct stored VALUES,
|
||||
* not just row counts, e.g.
|
||||
* PICPEAK_PG_TEST_URL="postgres://picpeak:pw@127.0.0.1:7102/picpeak_xengine_test" \
|
||||
* npx jest __tests__/integration/picpeakCrossEngine.test.js
|
||||
*/
|
||||
const knexLib = require('knex');
|
||||
|
||||
describe('validateManifest cross-engine direction (pg target)', () => {
|
||||
let validateManifest;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetModules();
|
||||
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
|
||||
// validateManifest wraps its knex_migrations lookup in try/catch — a
|
||||
// throwing stub simply skips the forward-only check, which is not under
|
||||
// test here.
|
||||
jest.doMock('../../src/database/db', () => ({ db: () => { throw new Error('stub'); } }));
|
||||
({ validateManifest } = require('../../src/services/picpeakImportService'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.dontMock('../../src/database/db');
|
||||
jest.dontMock('../../knexfile');
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('allows a sqlite backup onto a pg instance (upgrade direction)', async () => {
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1, database: { engine: 'sqlite' }, tables: {},
|
||||
});
|
||||
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still allows same-engine pg → pg', async () => {
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
|
||||
});
|
||||
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('epochToIso (landed with #1039)', () => {
|
||||
let epochToIso;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetModules();
|
||||
({ epochToIso } = require('../../src/services/picpeakImportService'));
|
||||
});
|
||||
|
||||
it('converts epoch milliseconds', () => {
|
||||
expect(epochToIso(1723400000000)).toBe('2024-08-11T18:13:20.000Z');
|
||||
});
|
||||
|
||||
it('converts epoch SECONDS to the same instant, not January 1970', () => {
|
||||
expect(epochToIso(1723400000)).toBe('2024-08-11T18:13:20.000Z');
|
||||
});
|
||||
|
||||
it('converts numeric strings', () => {
|
||||
expect(epochToIso('1723400000000')).toBe('2024-08-11T18:13:20.000Z');
|
||||
});
|
||||
|
||||
it('passes non-numeric values through untouched', () => {
|
||||
expect(epochToIso('2026-08-12 10:00:00')).toBe('2026-08-12 10:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerceForTargetEngine on sqlite-shaped rows', () => {
|
||||
let coerceForTargetEngine;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetModules();
|
||||
({ coerceForTargetEngine } = require('../../src/services/picpeakImportService'));
|
||||
});
|
||||
|
||||
const types = { timestamps: ['created_at', 'expires_at'], booleans: ['is_active'] };
|
||||
|
||||
it('coerces 0/1 booleans and epoch timestamps, leaves date strings alone', () => {
|
||||
const [row] = coerceForTargetEngine(
|
||||
[{ id: 1, is_active: 1, created_at: 1723400000000, expires_at: '2026-09-01 12:00:00' }],
|
||||
types
|
||||
);
|
||||
expect(row.is_active).toBe(true);
|
||||
expect(row.created_at).toBe('2024-08-11T18:13:20.000Z');
|
||||
expect(row.expires_at).toBe('2026-09-01 12:00:00'); // pg parses this natively
|
||||
});
|
||||
|
||||
it('coerces falsy variants and passes null/empty through', () => {
|
||||
const [row] = coerceForTargetEngine(
|
||||
[{ is_active: 0, created_at: null, expires_at: '' }],
|
||||
types
|
||||
);
|
||||
expect(row.is_active).toBe(false);
|
||||
expect(row.created_at).toBeNull();
|
||||
expect(row.expires_at).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Real-Postgres integration (gated) ────────────────────────────────────────
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
|
||||
maybe('sqlite-shaped rows land correctly in real Postgres', () => {
|
||||
let pgDb;
|
||||
let svc;
|
||||
|
||||
beforeAll(async () => {
|
||||
pgDb = knexLib({ client: 'pg', connection: PG_URL });
|
||||
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
|
||||
await pgDb.schema.createTable('xengine_events', (t) => {
|
||||
t.increments('id');
|
||||
t.string('slug');
|
||||
t.boolean('is_active').defaultTo(true);
|
||||
t.boolean('allow_downloads').defaultTo(true);
|
||||
t.timestamp('created_at');
|
||||
t.timestamp('expires_at');
|
||||
});
|
||||
await pgDb.schema.createTable('xengine_settings', (t) => {
|
||||
t.increments('id');
|
||||
t.string('setting_key').notNullable().unique();
|
||||
t.jsonb('setting_value');
|
||||
});
|
||||
|
||||
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.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
|
||||
await pgDb.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('typedColumnsFor classifies boolean and timestamp columns via columnInfo()', async () => {
|
||||
const types = await svc.typedColumnsFor(pgDb, 'xengine_events');
|
||||
expect(types.booleans.sort()).toEqual(['allow_downloads', 'is_active']);
|
||||
expect(types.timestamps.sort()).toEqual(['created_at', 'expires_at']);
|
||||
});
|
||||
|
||||
it('inserts a sqlite archive row (0/1 booleans, epoch dates, json text) with correct stored values', async () => {
|
||||
// Exactly what a sqlite-created .picpeak carries: integers for booleans,
|
||||
// epoch numbers for #485-shape timestamps (ms here, seconds covered by the
|
||||
// epochToIso unit), a "YYYY-MM-DD HH:MM:SS" string for clean ones, and
|
||||
// json columns as TEXT (the crossEngine path skips serialiseJsonColumns —
|
||||
// the text is already what pg wants).
|
||||
const epoch = 1723400000000;
|
||||
const eventRows = [
|
||||
{ id: 1, slug: 'wedding', is_active: 1, allow_downloads: 0, created_at: epoch, expires_at: '2026-09-01 12:00:00' },
|
||||
];
|
||||
const settingRows = [{ id: 1, setting_key: 'brand', setting_value: '{"name":"PicPeak","dark":true}' }];
|
||||
|
||||
await pgDb.transaction(async (trx) => {
|
||||
const evTypes = await svc.typedColumnsFor(trx, 'xengine_events');
|
||||
await trx.batchInsert('xengine_events', svc.coerceForTargetEngine(eventRows, evTypes), 100);
|
||||
const stTypes = await svc.typedColumnsFor(trx, 'xengine_settings');
|
||||
await trx.batchInsert('xengine_settings', svc.coerceForTargetEngine(settingRows, stTypes), 100);
|
||||
});
|
||||
|
||||
const ev = await pgDb('xengine_events').where({ id: 1 }).first();
|
||||
expect(ev.is_active).toBe(true); // 1 → true, not backwards (#1028 class)
|
||||
expect(ev.allow_downloads).toBe(false); // 0 → false
|
||||
expect(new Date(ev.created_at).getTime()).toBe(epoch);
|
||||
expect(new Date(ev.expires_at).toISOString().slice(0, 10)).toBe('2026-09-01');
|
||||
|
||||
const st = await pgDb('xengine_settings').where({ id: 1 }).first();
|
||||
// jsonb parsed back by the driver — value intact, no double encoding.
|
||||
expect(st.setting_value).toEqual({ name: 'PicPeak', dark: true });
|
||||
});
|
||||
|
||||
it('id sequence works after explicit-id insert + resync (next natural insert)', async () => {
|
||||
await svc.resyncSequences(['xengine_events']);
|
||||
const [next] = await pgDb('xengine_events')
|
||||
.insert({ slug: 'fresh', is_active: true })
|
||||
.returning('id');
|
||||
expect(Number(next.id || next)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* scripts/regenerate-thumbnails.js against external photos (#1148).
|
||||
*
|
||||
* The same defect #1129 fixed in the admin route, still standing in the CLI
|
||||
* fallback: the script resolved every source as
|
||||
* `storage/events/active/<photo.path>` and fs.access'd it. External and
|
||||
* reference rows do not live there — their originals sit under
|
||||
* `events.external_path` — so every one failed the check and was counted as an
|
||||
* error. On an install where all photos are external the script did nothing at
|
||||
* all, while reporting one error per photo.
|
||||
*
|
||||
* Driven against a REAL file on a REAL external mount with the real
|
||||
* imageProcessor, not a mock: the whole point is that the source resolves off
|
||||
* the mount, and a mocked ensureThumbnail would assert nothing about that.
|
||||
*
|
||||
* Responsive tiers (#1095/#1109) do not exist on this branch, so the tier
|
||||
* backfill in the main twin has nothing to port. Everything else does.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const sharp = require('sharp');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
describe('regenerate-thumbnails script (#1148)', () => {
|
||||
let tmpDir; let db; let cleanup; let regenerateThumbnails;
|
||||
let eventId; let externalPhotoId; let videoPhotoId; let watcherVideoId; let repairPhotoId;
|
||||
let vanishingPhotoId;
|
||||
let externalRoot;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-script-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
// External sources are sandboxed under EXTERNAL_MEDIA_ROOT; event
|
||||
// external_path is relative to it, exactly as on a real install.
|
||||
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpDir, 'media');
|
||||
externalRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'wedding');
|
||||
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
await fs.promises.mkdir(externalRoot, { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
// A real image on the external mount — never under events/active.
|
||||
await sharp({
|
||||
create: { width: 1200, height: 800, channels: 3, background: { r: 10, g: 90, b: 160 } },
|
||||
}).jpeg().toFile(path.join(externalRoot, 'shot.jpg'));
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: 'regen-script-event',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Regen Script',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/regen-script-event/share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
source_mode: 'reference',
|
||||
external_path: 'wedding',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'shot.jpg',
|
||||
// `path` is what the old script joined onto events/active. Left
|
||||
// populated on purpose: the fix must ignore it for an external row.
|
||||
path: 'regen-script-event/shot.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
externalPhotoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const [v] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'clip.mp4',
|
||||
path: 'regen-script-event/clip.mp4',
|
||||
type: 'individual',
|
||||
media_type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'clip.mp4',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
videoPhotoId = typeof v === 'object' ? v.id : v;
|
||||
|
||||
// How fileWatcher.processNewPhoto actually writes a video: `type` and
|
||||
// `mime_type` set, media_type left to its 'image' default. A media_type-only
|
||||
// filter lets this through and hands the container to Sharp.
|
||||
//
|
||||
// The file has to EXIST, otherwise the row fails resolution and looks
|
||||
// skipped for the wrong reason — the bug is Sharp being handed a video, not
|
||||
// a missing source. Real MP4 header bytes, no image in sight.
|
||||
await fs.promises.writeFile(
|
||||
path.join(externalRoot, 'watched.mp4'),
|
||||
Buffer.from('00000018667479706d70343200000000', 'hex')
|
||||
);
|
||||
const [wv] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'watched.mp4',
|
||||
path: 'regen-script-event/watched.mp4',
|
||||
type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'watched.mp4',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
watcherVideoId = typeof wv === 'object' ? wv.id : wv;
|
||||
expect((await db('photos').where('id', watcherVideoId).first()).media_type).not.toBe('video');
|
||||
|
||||
// A photo whose thumbnail_path points at something that is no longer there.
|
||||
await sharp({
|
||||
create: { width: 900, height: 600, channels: 3, background: { r: 200, g: 40, b: 40 } },
|
||||
}).jpeg().toFile(path.join(externalRoot, 'repair.jpg'));
|
||||
const [rp] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'repair.jpg',
|
||||
path: 'regen-script-event/repair.jpg',
|
||||
type: 'individual',
|
||||
thumbnail_path: 'thumbnails/thumb_ext_missing_repair.jpg',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'repair.jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
repairPhotoId = typeof rp === 'object' ? rp.id : rp;
|
||||
|
||||
// A photo whose source is not on the mount at all — an unavailable mount,
|
||||
// which is the failure an operator most needs to hear about.
|
||||
const [vp] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'missing.jpg',
|
||||
path: 'regen-script-event/missing.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'missing.jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
vanishingPhotoId = typeof vp === 'object' ? vp.id : vp;
|
||||
|
||||
({ regenerateThumbnails } = require('../../scripts/regenerate-thumbnails'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('builds a thumbnail for an external photo instead of erroring on events/active', async () => {
|
||||
// The location the old script computed and fs.access'd. Nothing is there,
|
||||
// which is the whole defect — it is not where an external original lives.
|
||||
// (The old script cannot be driven from a test directly: it had no export
|
||||
// and ran on require, calling process.exit. Making it importable is part
|
||||
// of this fix.)
|
||||
const legacyPath = path.join(process.env.STORAGE_PATH, 'events/active', 'regen-script-event/shot.jpg');
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
|
||||
const result = await regenerateThumbnails(eventId);
|
||||
|
||||
// The old script reported an error for this photo and wrote nothing.
|
||||
// The unresolvable row fails; the external photo and the repair row build.
|
||||
expect(result.errorCount).toBe(1);
|
||||
expect(result.successCount).toBe(2);
|
||||
|
||||
const row = await db('photos').where('id', externalPhotoId).first();
|
||||
expect(row.thumbnail_path).toBeTruthy();
|
||||
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
// Named per-photo so two events referencing one NAS basename cannot
|
||||
// clobber each other — the property ensureThumbnail owns and the reason
|
||||
// the script must not build this name itself.
|
||||
expect(path.basename(row.thumbnail_path)).toContain(`ext${externalPhotoId}_`);
|
||||
});
|
||||
|
||||
it('leaves videos alone', async () => {
|
||||
// A video thumbnail is a poster frame from videoProcessor; handing the
|
||||
// container to Sharp produced one error per video row.
|
||||
const row = await db('photos').where('id', videoPhotoId).first();
|
||||
expect(row.thumbnail_path).toBeFalsy();
|
||||
});
|
||||
|
||||
it('leaves a watcher-imported video alone, which carries no media_type', async () => {
|
||||
// fileWatcher writes type + mime_type and lets media_type default to
|
||||
// 'image', so filtering on media_type alone still fed these to Sharp. The
|
||||
// signal is errorCount: the images are already done by now, so the only
|
||||
// NEW thing that could fail this run is a video reaching Sharp. One error
|
||||
// is the deliberately unresolvable row; two would be the video.
|
||||
const result = await regenerateThumbnails(eventId);
|
||||
|
||||
expect(result.errorCount).toBe(1);
|
||||
const row = await db('photos').where('id', watcherVideoId).first();
|
||||
expect(row.thumbnail_path).toBeFalsy();
|
||||
});
|
||||
|
||||
it('is idempotent — a second run skips instead of rebuilding', async () => {
|
||||
const before = await db('photos').where('id', externalPhotoId).first();
|
||||
const result = await regenerateThumbnails(eventId);
|
||||
|
||||
expect(result.errorCount).toBe(1);
|
||||
expect(result.successCount).toBe(0);
|
||||
expect(result.skipCount).toBe(2);
|
||||
|
||||
const after = await db('photos').where('id', externalPhotoId).first();
|
||||
expect(after.thumbnail_path).toBe(before.thumbnail_path);
|
||||
});
|
||||
|
||||
it('counts a repaired thumbnail as generated, not skipped', async () => {
|
||||
// Both images are valid at this point. Destroy ONE thumbnail object while
|
||||
// leaving thumbnail_path pointing at it — the corrupt/missing case.
|
||||
const row = await db('photos').where('id', repairPhotoId).first();
|
||||
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
|
||||
await fs.promises.rm(onDisk);
|
||||
|
||||
const result = await regenerateThumbnails(eventId);
|
||||
|
||||
// On local and external storage the rebuilt key is identical, so inferring
|
||||
// "skipped" from an unchanged path reports this repair as already valid —
|
||||
// the one number an operator running this is actually reading.
|
||||
expect(result.successCount).toBe(1);
|
||||
expect(result.skipCount).toBe(1);
|
||||
expect(result.errorCount).toBe(1);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
});
|
||||
|
||||
/** Run the CLI the way cron does, and hand back its exit status. */
|
||||
const runCli = (args = []) => new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[path.join(__dirname, '..', '..', 'scripts', 'regenerate-thumbnails.js'), ...args],
|
||||
{ env: { ...process.env }, cwd: path.join(__dirname, '..', '..') },
|
||||
(error, stdout, stderr) => resolve({ code: error?.code ?? 0, stdout, stderr })
|
||||
);
|
||||
});
|
||||
|
||||
it('exits nonzero when a photo could not be built', async () => {
|
||||
// Exit status is the only thing a cron job reads, and `missing.jpg` has no
|
||||
// source on the mount.
|
||||
const failed = await runCli([String(eventId)]);
|
||||
expect(failed.code).toBe(1);
|
||||
expect(failed.stderr).toContain('completed with failures');
|
||||
}, 120000);
|
||||
|
||||
it('exits zero when every photo resolves', async () => {
|
||||
// Drop the unresolvable row: a clean run must not cry wolf at automation.
|
||||
await db('photos').where('id', vanishingPhotoId).del();
|
||||
const ok = await runCli([String(eventId)]);
|
||||
expect(ok.code).toBe(0);
|
||||
expect(ok.stdout).toContain('Script completed successfully');
|
||||
}, 120000);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Slideshow photo source (#1015).
|
||||
*
|
||||
* The bug: with `lightbox_preview_enabled` off (the default), /photos emitted
|
||||
* `preview_url: null`, so the slideshow's `preview_url || hero_url || url`
|
||||
* chain fell through to `hero_url` — a 1920x1080 `fit: 'cover'` centre crop
|
||||
* meant for gallery header banners. With the "Black Bars (No crop)" fit the
|
||||
* show then letterboxed an already-cropped frame: portrait photos lost their
|
||||
* top and bottom and the setting looked broken.
|
||||
*
|
||||
* The contract pinned here: `slideshow_url` points at the aspect-preserved
|
||||
* preview tier and is emitted for image photos REGARDLESS of the lightbox
|
||||
* toggle, so the slideshow never has a reason to reach for `hero_url`.
|
||||
* `preview_url` itself must stay gated — the lightbox opt-in is unchanged.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-src-test-secret';
|
||||
|
||||
const SLUG = 'slideshow-source-event';
|
||||
|
||||
describe('Slideshow photo source (#1015)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let imagePhotoId;
|
||||
let videoPhotoId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const setLightboxPreview = async (on) => {
|
||||
await db('app_settings').where({ setting_key: 'lightbox_preview_enabled' }).del();
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'lightbox_preview_enabled',
|
||||
setting_value: JSON.stringify(on),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const fetchPhotos = async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.expect(200);
|
||||
return res.body.photos;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Slideshow Source Test',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'slideshow-source-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
const img = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'portrait.jpg',
|
||||
path: 'events/slideshow-source/portrait.jpg',
|
||||
type: 'individual',
|
||||
mime_type: 'image/jpeg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
imagePhotoId = img[0]?.id ?? img[0];
|
||||
|
||||
const vid = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'clip.mp4',
|
||||
path: 'events/slideshow-source/clip.mp4',
|
||||
type: 'individual',
|
||||
media_type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
videoPhotoId = vid[0]?.id ?? vid[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('emits slideshow_url for image photos even when lightbox previews are OFF', async () => {
|
||||
await setLightboxPreview(false);
|
||||
const photos = await fetchPhotos();
|
||||
const image = photos.find((p) => p.id === imagePhotoId);
|
||||
|
||||
expect(image.slideshow_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
|
||||
// The regression: this is what used to be null, pushing the show to hero.
|
||||
expect(image.preview_url).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves preview_url gated so the lightbox opt-in is unchanged', async () => {
|
||||
await setLightboxPreview(true);
|
||||
const photos = await fetchPhotos();
|
||||
const image = photos.find((p) => p.id === imagePhotoId);
|
||||
|
||||
expect(image.preview_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
|
||||
expect(image.slideshow_url).toBe(image.preview_url);
|
||||
});
|
||||
|
||||
it('never points the slideshow at the cover-cropped hero tier', async () => {
|
||||
await setLightboxPreview(false);
|
||||
const photos = await fetchPhotos();
|
||||
const image = photos.find((p) => p.id === imagePhotoId);
|
||||
|
||||
// hero_url still ships (the gallery header uses it) — it just must not be
|
||||
// what the slideshow resolves to.
|
||||
expect(image.hero_url).toBe(`/api/gallery/${SLUG}/hero/${imagePhotoId}`);
|
||||
expect(image.slideshow_url).not.toBe(image.hero_url);
|
||||
});
|
||||
|
||||
it('emits slideshow_url: null for videos, which have no preview tier', async () => {
|
||||
await setLightboxPreview(false);
|
||||
const photos = await fetchPhotos();
|
||||
const video = photos.find((p) => p.id === videoPhotoId);
|
||||
|
||||
expect(video.slideshow_url).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* The roles-join fallback in adminAuth fabricates `role_name = 'super_admin'`
|
||||
* to keep existing sessions working across the RBAC upgrade window. The catch
|
||||
* around it used to be unconditional, so ANY transient database failure —
|
||||
* connection reset, deadlock, statement timeout, pool exhaustion — took the
|
||||
* same branch and handed the caller super_admin for the duration of the fault.
|
||||
*
|
||||
* `roleName` is the sole discriminator for every ownership check (ownership.js,
|
||||
* adminProjects, adminUsers, adminApiTokens, projectService, ...), so that
|
||||
* inverted the whole authorization model rather than failing the request.
|
||||
* Issue #968. Same treatment apiTokenAuth already got for the v1 surface.
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
|
||||
jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), info: jest.fn() }));
|
||||
|
||||
// The joined query throws whatever the test stages; the role-less fallback
|
||||
// query (no .leftJoin) always succeeds, which is what made the original bug
|
||||
// reachable — it is the cheaper single-table read.
|
||||
// `mock`-prefixed so jest's module-factory hoisting allows the reference.
|
||||
let mockJoinError = null;
|
||||
const mockAdminRow = { id: 7, username: 'scoped', email: 's@example.com', password_changed_at: null };
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => ({
|
||||
_joined: false,
|
||||
leftJoin() { this._joined = true; return this; },
|
||||
where() { return this; },
|
||||
select() { return this; },
|
||||
first() {
|
||||
if (this._joined && mockJoinError) return Promise.reject(mockJoinError);
|
||||
return Promise.resolve({ ...mockAdminRow });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { adminAuth } = require('../../src/middleware/auth');
|
||||
|
||||
const SECRET = 'test-secret-for-admin-auth-fallback';
|
||||
|
||||
function makeReq() {
|
||||
const token = jwt.sign(
|
||||
{ id: mockAdminRow.id, type: 'admin' },
|
||||
SECRET,
|
||||
{ algorithm: 'HS256', issuer: 'picpeak-auth' },
|
||||
);
|
||||
return { headers: { authorization: `Bearer ${token}` }, ip: '127.0.0.1', connection: {} };
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
return {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
}
|
||||
|
||||
describe('adminAuth roles-join fallback (#968)', () => {
|
||||
const OLD_SECRET = process.env.JWT_SECRET;
|
||||
beforeAll(() => { process.env.JWT_SECRET = SECRET; });
|
||||
afterAll(() => { process.env.JWT_SECRET = OLD_SECRET; });
|
||||
beforeEach(() => { mockJoinError = null; });
|
||||
|
||||
it('grants the upgrade-window fallback only for a genuinely missing roles table', async () => {
|
||||
mockJoinError = new Error('SQLITE_ERROR: no such table: roles');
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.admin.roleName).toBe('super_admin');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['connection reset', new Error('Connection terminated unexpectedly')],
|
||||
['deadlock', new Error('deadlock detected')],
|
||||
['pool exhaustion', new Error('Knex: Timeout acquiring a connection')],
|
||||
['statement timeout', new Error('canceling statement due to statement timeout')],
|
||||
])('does NOT fabricate super_admin on a transient failure (%s)', async (_label, err) => {
|
||||
mockJoinError = err;
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
// Fails closed: request rejected, req.admin never populated. The specific
|
||||
// status is 401 (adminAuth's blanket outer catch) — what matters is that
|
||||
// the caller is not elevated and does not reach the route.
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(req.admin).toBeUndefined();
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('does NOT fabricate super_admin when an unrelated table is missing', async () => {
|
||||
mockJoinError = new Error('SQLITE_ERROR: no such table: admin_sessions');
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(req.admin).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The roles-join fallback in apiTokenAuth grants `super_admin` (upgrade-path
|
||||
* parity with adminAuth). It must therefore fire ONLY when the roles schema is
|
||||
* genuinely absent — a catch-all turns any transient database failure into a
|
||||
* privilege escalation that reopens GHSA-9697 for a demoted token owner.
|
||||
*/
|
||||
|
||||
const { isMissingRolesSchema } = require('../../src/middleware/apiTokenAuth');
|
||||
|
||||
describe('apiTokenAuth roles-schema fallback predicate (GHSA-9697)', () => {
|
||||
it('accepts a genuinely missing roles table on both engines', () => {
|
||||
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: roles'))).toBe(true);
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(new Error('relation "roles" does not exist'), { code: '42P01' }),
|
||||
)).toBe(true);
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(new Error('column roles.name does not exist'), { code: '42703' }),
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects transient failures that must not elevate the caller', () => {
|
||||
expect(isMissingRolesSchema(new Error('Connection terminated unexpectedly'))).toBe(false);
|
||||
expect(isMissingRolesSchema(new Error('deadlock detected'))).toBe(false);
|
||||
expect(isMissingRolesSchema(new Error('Knex: Timeout acquiring a connection'))).toBe(false);
|
||||
expect(isMissingRolesSchema(new Error('canceling statement due to statement timeout'))).toBe(false);
|
||||
expect(isMissingRolesSchema(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing-table error for an unrelated table', () => {
|
||||
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: api_tokens'))).toBe(false);
|
||||
});
|
||||
|
||||
// knex prefixes the failing SQL to err.message, and that SQL always names
|
||||
// `roles` on this join — so the message substring proves nothing about the
|
||||
// error, and only an exact driver phrase (or a SQLSTATE) may be trusted.
|
||||
// These are real knex message shapes, captured from the actual query.
|
||||
describe('with knex\'s SQL prefix on the message (#968)', () => {
|
||||
const withSql = (driverMessage) => new Error(
|
||||
'select `roles`.`name` as `role_name` from `admin_users` '
|
||||
+ 'left join `roles` on `roles`.`id` = `admin_users`.`role_id` '
|
||||
+ `where \`admin_users\`.\`id\` = 1 limit 1 - ${driverMessage}`,
|
||||
);
|
||||
|
||||
it('accepts both legitimate upgrade-window states', () => {
|
||||
// pre-054: the roles table does not exist yet
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(withSql('SQLITE_ERROR: no such table: roles'), { code: 'SQLITE_ERROR' }),
|
||||
)).toBe(true);
|
||||
// post-054, pre-057: roles exists, admin_users.role_id not added yet
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(withSql('SQLITE_ERROR: no such column: admin_users.role_id'), { code: 'SQLITE_ERROR' }),
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unrelated "does not exist" fault despite the SQL naming roles', () => {
|
||||
// pgbouncer transaction pooling loses a named prepared statement
|
||||
// (SQLSTATE 26000). Transient — the fallback query would succeed on a
|
||||
// fresh connection, so accepting this would fabricate super_admin.
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(withSql('prepared statement "S_1" does not exist'), { code: '26000' }),
|
||||
)).toBe(false);
|
||||
// The DB role/user, not the roles table.
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(withSql('role "picpeak" does not exist'), { code: '28000' }),
|
||||
)).toBe(false);
|
||||
expect(isMissingRolesSchema(
|
||||
Object.assign(withSql('database "picpeak" does not exist'), { code: '3D000' }),
|
||||
)).toBe(false);
|
||||
expect(isMissingRolesSchema(withSql('Connection terminated unexpectedly'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Migration 167 (projects.created_by) — idempotent on re-run, reversible,
|
||||
* and backfills the owner from a project's single linked event (GHSA-wrg5).
|
||||
*/
|
||||
const path=require('path'), fs=require('fs'), os=require('os');
|
||||
process.env.NODE_ENV='test';
|
||||
process.env.TEST_DATABASE_PATH=path.join(fs.mkdtempSync(path.join(os.tmpdir(),'picpeak-mig167-')),'db.sqlite');
|
||||
process.env.JWT_SECRET='mig';
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const mig = require('../../migrations/core/167_add_projects_created_by');
|
||||
describe('migration 167', () => {
|
||||
let db, cleanup;
|
||||
beforeAll(async()=>{ ({db,cleanup}=await bootCrmDb()); await seedMinimal(db); },120000);
|
||||
afterAll(async()=>{ if(cleanup) await cleanup(); });
|
||||
it('is idempotent on re-run and reversible', async () => {
|
||||
await mig.up(db); // already applied by boot; must no-op
|
||||
await mig.up(db); // and again
|
||||
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
|
||||
await mig.down(db);
|
||||
expect(await db.schema.hasColumn('projects','created_by')).toBe(false);
|
||||
await mig.up(db); // re-apply cleanly
|
||||
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
|
||||
});
|
||||
it('backfills created_by from a single linked event owner', async () => {
|
||||
const p = await db('projects').insert({name:'bf',status:'active',created_at:new Date(),updated_at:new Date()}).returning('id');
|
||||
const pid = p[0]?.id ?? p[0];
|
||||
await db('events').insert({slug:'bf-ev',event_type:'wedding',event_name:'bf',event_date:'2026-08-01',
|
||||
host_email:'h@e.com',admin_email:'a@e.com',password_hash:'x',share_token:'t1',share_link:'/g/bf-ev/t1',
|
||||
created_by: 4242, project_id: pid, expires_at:new Date(Date.now()+864e5).toISOString(),
|
||||
is_active:1,is_archived:0,is_draft:0,created_at:new Date().toISOString()});
|
||||
await mig.up(db);
|
||||
const row = await db('projects').where({id:pid}).first();
|
||||
expect(row.created_by).toBe(4242);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* GHSA-jhcf round 3: scoping the activity feed does nothing about the rows
|
||||
* already on disk. expenseService used to pass adminId into logActivity's
|
||||
* `eventId` slot, so upgraded instances carry accounting rows whose event_id
|
||||
* is an ADMIN id — and the scope predicate happily matches those against a
|
||||
* same-numbered event the caller owns.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-mig168-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mig168-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('../integration/helpers/crmDb');
|
||||
const migration = require('../../migrations/core/168_fix_expense_activity_event_id');
|
||||
|
||||
describe('migration 168 — legacy accounting activity rows (GHSA-jhcf)', () => {
|
||||
let db; let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('re-attributes the admin id and clears event_id, leaving real rows alone', async () => {
|
||||
await db('activity_logs').insert([
|
||||
// Legacy shape: event_id is really admin #7, no actor recorded.
|
||||
{
|
||||
activity_type: 'expense_created',
|
||||
actor_type: 'system',
|
||||
actor_id: null,
|
||||
event_id: 7,
|
||||
metadata: JSON.stringify({ expenseId: 1 }),
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
activity_type: 'incoming_invoice_captured',
|
||||
actor_type: 'system',
|
||||
actor_id: null,
|
||||
event_id: 9,
|
||||
metadata: JSON.stringify({ inboundDocumentId: 2 }),
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
// A genuine event-scoped row from another subsystem must survive intact.
|
||||
{
|
||||
activity_type: 'photo_uploaded',
|
||||
actor_type: 'admin',
|
||||
actor_id: 3,
|
||||
event_id: 7,
|
||||
metadata: JSON.stringify({}),
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
await migration.up(db);
|
||||
|
||||
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
|
||||
expect(expense.event_id == null).toBe(true);
|
||||
expect(Number(expense.actor_id)).toBe(7);
|
||||
expect(expense.actor_type).toBe('admin');
|
||||
|
||||
const captured = await db('activity_logs').where({ activity_type: 'incoming_invoice_captured' }).first();
|
||||
expect(captured.event_id == null).toBe(true);
|
||||
expect(Number(captured.actor_id)).toBe(9);
|
||||
|
||||
const photo = await db('activity_logs').where({ activity_type: 'photo_uploaded' }).first();
|
||||
expect(Number(photo.event_id)).toBe(7);
|
||||
expect(Number(photo.actor_id)).toBe(3);
|
||||
});
|
||||
|
||||
it('is idempotent on re-run', async () => {
|
||||
await expect(migration.up(db)).resolves.toBeUndefined();
|
||||
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
|
||||
expect(Number(expense.actor_id)).toBe(7);
|
||||
expect(expense.event_id == null).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Repairing the bundled templates' fixed image height (#1131).
|
||||
*
|
||||
* The risk in a migration that rewrites user-visible CSS is doing too much,
|
||||
* so most of what is pinned here is what it must NOT touch: the other pixel
|
||||
* heights inside the very same templates (a 1px divider, an 8px scrollbar),
|
||||
* and any rule a user wrote themselves.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const migration = require('../../migrations/core/175_fix_css_template_photo_height');
|
||||
|
||||
const ELEGANT_DARK = `
|
||||
.photo-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
`;
|
||||
|
||||
const LIQUID_GLASS_DARK = `
|
||||
.gallery-page::after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #fff, transparent);
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
object-fit: cover;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.gallery-page ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.photo-card img {
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
describe('migration 175 — CSS template image height (#1131)', () => {
|
||||
let knex; let tmpDir;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig175-'));
|
||||
knex = require('knex')({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: path.join(tmpDir, 'db.sqlite') },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
await knex.schema.createTable('css_templates', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('name');
|
||||
t.text('css_content');
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (knex) await knex.destroy();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => { await knex('css_templates').del(); });
|
||||
|
||||
const contentOf = async (name) =>
|
||||
(await knex('css_templates').where({ name }).first()).css_content;
|
||||
|
||||
it('relaxes the default template so the layouts h-full can win', async () => {
|
||||
await knex('css_templates').insert({ name: 'Elegant Dark', css_content: ELEGANT_DARK });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Elegant Dark');
|
||||
expect(css).toContain('height: 100%');
|
||||
expect(css).not.toContain('height: 200px');
|
||||
// Everything else about the rule survives.
|
||||
expect(css).toContain('object-fit: cover');
|
||||
expect(css).toContain('transition: transform 0.3s ease');
|
||||
});
|
||||
|
||||
it('fixes both the base rule and the mobile override of the dark glass template', async () => {
|
||||
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Liquid Glass Dark');
|
||||
expect(css).not.toContain('height: 240px');
|
||||
expect(css).not.toContain('height: 180px');
|
||||
expect(css.match(/height: 100%/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('leaves the divider and the scrollbar alone', async () => {
|
||||
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
// The whole reason this matches full rule bodies rather than every
|
||||
// `height: <n>px`: these are in the same stylesheet and are correct.
|
||||
const css = await contentOf('Liquid Glass Dark');
|
||||
expect(css).toContain('height: 1px');
|
||||
expect(css).toContain('width: 8px');
|
||||
expect(css).toContain('height: 8px');
|
||||
});
|
||||
|
||||
/**
|
||||
* The case that forced the scope wider. `sanitizeCSS` strips control
|
||||
* characters, so any template ever saved through the editor — including a
|
||||
* save that only changed its name — has had every newline REMOVED. An
|
||||
* exact-text migration finds nothing on those installs, is recorded as
|
||||
* applied, and leaves them broken permanently.
|
||||
*/
|
||||
it('fixes a template that has been through the editor, newlines and all', async () => {
|
||||
const { sanitizeCSS } = require('../../src/utils/cssSanitizer');
|
||||
const { sanitized } = sanitizeCSS(ELEGANT_DARK);
|
||||
// Precondition: the sanitizer really did flatten it.
|
||||
expect(sanitized).not.toContain('\n');
|
||||
expect(sanitized).toContain('height: 200px');
|
||||
await knex('css_templates').insert({ name: 'Saved Once', css_content: sanitized });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Saved Once');
|
||||
expect(css).not.toContain('200px');
|
||||
expect(css).toContain('height: 100%');
|
||||
});
|
||||
|
||||
it('relaxes a user-authored fixed height too, but only on .photo-card img', async () => {
|
||||
// Deliberately broader than the seeded text — see the migration header. A
|
||||
// pixel height on the image cannot be right under any of the seven
|
||||
// layouts, whoever wrote it; a height anywhere else is none of our
|
||||
// business.
|
||||
const mine = '.photo-card img {\n height: 220px;\n}\n.hero { height: 400px; }';
|
||||
await knex('css_templates').insert({ name: 'My Own', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('My Own');
|
||||
expect(css).toContain('height: 100%');
|
||||
expect(css).not.toContain('220px');
|
||||
expect(css).toContain('.hero { height: 400px; }');
|
||||
});
|
||||
|
||||
it('does not rewrite other properties that merely end in -height', async () => {
|
||||
// `line-height: 200px` contains `height: 200px` as a substring, so an
|
||||
// unanchored pattern silently rewrites it — in a migration that cannot be
|
||||
// undone.
|
||||
const mine = [
|
||||
'.photo-card img {',
|
||||
' line-height: 200px;',
|
||||
' max-height: 300px;',
|
||||
' min-height: 14px;',
|
||||
' --tile-height: 220px;',
|
||||
' height: 200px;',
|
||||
'}',
|
||||
].join('\n');
|
||||
await knex('css_templates').insert({ name: 'Adjacent Props', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Adjacent Props');
|
||||
expect(css).toContain('line-height: 200px');
|
||||
expect(css).toContain('max-height: 300px');
|
||||
expect(css).toContain('min-height: 14px');
|
||||
expect(css).toContain('--tile-height: 220px');
|
||||
// Only the real one moved.
|
||||
expect(css).toContain('height: 100%');
|
||||
expect(css).not.toMatch(/(?<![\w-])height:\s*200px/);
|
||||
});
|
||||
|
||||
it('handles a grouped selector list', async () => {
|
||||
// Requiring `{` straight after `img` skipped these entirely — and the
|
||||
// migration is still recorded as applied, so the template kept the bug.
|
||||
const mine = '.photo-card img, .thumbnail img {\n height: 200px;\n}';
|
||||
await knex('css_templates').insert({ name: 'Grouped', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Grouped');
|
||||
expect(css).toContain('.photo-card img, .thumbnail img {');
|
||||
expect(css).toContain('height: 100%');
|
||||
expect(css).not.toContain('200px');
|
||||
});
|
||||
|
||||
it('skips a nested rule rather than rewriting the wrong declaration', async () => {
|
||||
// Valid nested CSS that passes the validator. A brace-greedy body would
|
||||
// capture the inner block and rewrite the CAPTION's height, which cannot
|
||||
// be undone. Leaving it untouched is the lesser evil.
|
||||
const mine = '.photo-card img {\n & + .caption { height: 200px; }\n}';
|
||||
await knex('css_templates').insert({ name: 'Nested', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await contentOf('Nested')).toBe(mine);
|
||||
});
|
||||
|
||||
it('leaves non-pixel heights on the image alone', async () => {
|
||||
const mine = '.photo-card img { height: 50vh; }\n.photo-card img { height: auto; }';
|
||||
await knex('css_templates').insert({ name: 'Relative', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await contentOf('Relative')).toBe(mine);
|
||||
});
|
||||
|
||||
it('is idempotent and safe on a row with no CSS', async () => {
|
||||
await knex('css_templates').insert([
|
||||
{ name: 'Elegant Dark', css_content: ELEGANT_DARK },
|
||||
{ name: 'Empty', css_content: null },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
const once = await contentOf('Elegant Dark');
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await contentOf('Elegant Dark')).toBe(once);
|
||||
expect(await contentOf('Empty')).toBeNull();
|
||||
});
|
||||
|
||||
it('no-ops when the table does not exist yet', async () => {
|
||||
await knex.schema.dropTable('css_templates');
|
||||
await expect(migration.up(knex)).resolves.toBeUndefined();
|
||||
await knex.schema.createTable('css_templates', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('name');
|
||||
t.text('css_content');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Admin photo view route Content-Type (#908).
|
||||
*
|
||||
* The route built `image/<ext>` from the filename, producing invalid
|
||||
* types like image/mp4 for videos. AdminAuthenticatedVideo fetches this
|
||||
* URL into a blob whose type inherits the header, and browsers refuse to
|
||||
* play a <video> blob labeled image/* — blank/grey admin video preview.
|
||||
*
|
||||
* Pins (incl. external-review hardening):
|
||||
* - the header is ALWAYS image/* or video/*: a stored non-media MIME
|
||||
* (chunked uploads store the client-sent type unvalidated) is never
|
||||
* echoed — text/html inline under the app origin would be XSS
|
||||
* - stored video/ MIME wins; MIME-less videos map from the extension
|
||||
* (.mov → video/quicktime), unknown video extensions get video/mp4
|
||||
* - images IGNORE the stored MIME (migration 039 backfilled image/jpeg
|
||||
* onto every legacy row, PNGs included) and use the extension,
|
||||
* normalized (jpg → image/jpeg); extensionless files get image/jpeg
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admin-ct-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-ct-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admin-ct-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const SLUG = 'admin-ct-test-event';
|
||||
|
||||
describe('admin photo view Content-Type (#908)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let adminToken;
|
||||
|
||||
const addPhoto = async (filename, extra = {}) => {
|
||||
const dir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, filename), Buffer.from(`bytes-${filename}`));
|
||||
const r = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename,
|
||||
path: `${SLUG}/${filename}`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
...extra,
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
const getPhotoRes = (photoId) => request(app)
|
||||
.get(`/api/admin/photos/${eventId}/photo/${photoId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Admin CT Test',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'admin-ct-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
const superRole = await db('roles').where({ name: 'super_admin' }).first();
|
||||
const [rootId] = await db('admin_users').insert({
|
||||
username: 'admin-ct-admin',
|
||||
email: 'admin-ct-admin@example.com',
|
||||
password_hash: await bcrypt.hash('AdminCt123', 4),
|
||||
role_id: superRole.id,
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id').then((r) => [r[0]?.id || r[0]]);
|
||||
adminToken = jwt.sign(
|
||||
{ id: rootId, username: 'admin-ct-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('serves a video with its stored mime_type, not image/<ext>', async () => {
|
||||
const id = await addPhoto('clip.mp4', { media_type: 'video', mime_type: 'video/mp4' });
|
||||
const res = await getPhotoRes(id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('maps MIME-less videos from their extension (.mov → video/quicktime)', async () => {
|
||||
const id = await addPhoto('clip-nomime.mov', { media_type: 'video' });
|
||||
const res = await getPhotoRes(id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('video/quicktime');
|
||||
});
|
||||
|
||||
it('falls back to video/mp4 for a video with an unknown extension', async () => {
|
||||
const id = await addPhoto('clip-unknown.xyz', { media_type: 'video' });
|
||||
const res = await getPhotoRes(id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('rejects malformed video/ MIME values that would break setHeader', async () => {
|
||||
// Header-invalid chars in the stored value must not 500 the route —
|
||||
// fall back to the extension map instead.
|
||||
const id = await addPhoto('crlf.mp4', {
|
||||
media_type: 'video',
|
||||
mime_type: 'video/mp4\r\nX-Evil: 1',
|
||||
});
|
||||
const res = await getPhotoRes(id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('video/mp4');
|
||||
expect(res.headers['x-evil']).toBeUndefined();
|
||||
|
||||
const bare = await addPhoto('bare.webm', { media_type: 'video', mime_type: 'video/' });
|
||||
const res2 = await getPhotoRes(bare);
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.headers['content-type']).toBe('video/webm');
|
||||
});
|
||||
|
||||
it('preserves an auto-imported avif via the safe stored-MIME allowlist', async () => {
|
||||
// .avif isn't in EXTENSION_TO_MIME; s3AutoImporter stores image/avif.
|
||||
// Map-only would mislabel it image/jpeg — the allowlist keeps it.
|
||||
const id = await addPhoto('imported.avif', { mime_type: 'image/avif' });
|
||||
const res = await getPhotoRes(id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/avif');
|
||||
});
|
||||
|
||||
it('preserves other importer raster types too (apng, x-icon)', async () => {
|
||||
const apng = await addPhoto('anim.apng', { mime_type: 'image/apng' });
|
||||
expect((await getPhotoRes(apng)).headers['content-type']).toBe('image/apng');
|
||||
const ico = await addPhoto('fav.ico', { mime_type: 'image/x-icon' });
|
||||
expect((await getPhotoRes(ico)).headers['content-type']).toBe('image/x-icon');
|
||||
});
|
||||
|
||||
it('does NOT honor a stored scriptable image type (image/svg+xml)', async () => {
|
||||
// svg is inline-scriptable and must never be echoed — allowlist excludes it.
|
||||
const id = await addPhoto('vector.svg', { mime_type: 'image/svg+xml' });
|
||||
const res = await getPhotoRes(id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('never echoes a stored non-media MIME type (inline XSS guard)', async () => {
|
||||
const id = await addPhoto('evil.png', { mime_type: 'text/html' });
|
||||
const res = await getPhotoRes(id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
});
|
||||
|
||||
it('ignores the migration-039 image/jpeg backfill on legacy PNG rows', async () => {
|
||||
const id = await addPhoto('legacy.png', { mime_type: 'image/jpeg' });
|
||||
const res = await getPhotoRes(id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
});
|
||||
|
||||
it('normalizes jpg to the canonical image/jpeg', async () => {
|
||||
const id = await addPhoto('shot.jpg');
|
||||
const res = await getPhotoRes(id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('keeps the extension fallback for images without a stored mime_type', async () => {
|
||||
const id = await addPhoto('shot.png');
|
||||
const res = await getPhotoRes(id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
});
|
||||
|
||||
it('handles Object.prototype key extensions without a 500 (.constructor)', async () => {
|
||||
// The extension-to-MIME lookup must be own-property only — a raw
|
||||
// index access returns an inherited function for these keys and the
|
||||
// downstream startsWith throws. Serve image/jpeg instead of 500.
|
||||
const id = await addPhoto('payload.constructor');
|
||||
const res = await getPhotoRes(id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/jpeg');
|
||||
|
||||
const id2 = await addPhoto('payload.__proto__', { media_type: 'video' });
|
||||
const res2 = await getPhotoRes(id2);
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.headers['content-type']).toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('does not synthesize types from unmapped image extensions', async () => {
|
||||
// Raw interpolation would produce image/svg+xml (scriptable inline)
|
||||
// or arbitrary strings from client-controlled filenames — the shared
|
||||
// map is the allowlist, everything else is served as image/jpeg.
|
||||
const svg = await addPhoto('vector.svg+xml');
|
||||
const res = await getPhotoRes(svg);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/jpeg');
|
||||
|
||||
const weird = await addPhoto('weird.xyz');
|
||||
const res2 = await getPhotoRes(weird);
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.headers['content-type']).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('extensionless files get image/jpeg, never a bare image/', async () => {
|
||||
const id = await addPhoto('noext');
|
||||
const res = await getPhotoRes(id);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/jpeg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Source-inspection contract test for #1078.
|
||||
*
|
||||
* POST /api/admin/thumbnails/regenerate-previews hands its selected rows to
|
||||
* ensurePreviewImage, which branches on `source_origin` (and then reads
|
||||
* `external_relpath` / `filename`) to reach an external/reference photo on its
|
||||
* media mount. When the select list omitted those columns, every external row
|
||||
* looked managed, resolvePhotoStorageKey returned null, and the endpoint
|
||||
* reported success while silently generating nothing for reference galleries.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('regenerate-previews selects the columns ensurePreviewImage branches on (#1078)', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'routes', 'adminThumbnails.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// The select feeding the regenerate-previews handler, from the route
|
||||
// declaration to the end of that statement.
|
||||
const selectStatement = (() => {
|
||||
const routeIdx = src.indexOf('/regenerate-previews');
|
||||
expect(routeIdx).toBeGreaterThan(-1);
|
||||
const selectIdx = src.indexOf('.select(', routeIdx);
|
||||
expect(selectIdx).toBeGreaterThan(-1);
|
||||
return src.slice(selectIdx, src.indexOf(';', selectIdx));
|
||||
})();
|
||||
|
||||
it.each(['source_origin', 'external_relpath', 'filename'])(
|
||||
'selects %s',
|
||||
(column) => {
|
||||
expect(selectStatement).toContain(`'${column}'`);
|
||||
}
|
||||
);
|
||||
|
||||
it('still selects the columns the managed path needs', () => {
|
||||
for (const column of ['id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path']) {
|
||||
expect(selectStatement).toContain(`'${column}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -135,9 +135,9 @@ function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
|
||||
);
|
||||
}
|
||||
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) {
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) {
|
||||
return jwt.sign(
|
||||
{ eventId, eventSlug, type: 'gallery' },
|
||||
{ eventId, eventSlug, type: 'gallery', ...extra },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
@@ -288,6 +288,56 @@ describe('GET /auth/session — symmetry with protected middleware', () => {
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* What KIND of gallery session this is (#1149).
|
||||
*
|
||||
* The frontend used to keep this in sessionStorage, which is per-TAB while
|
||||
* the cookie is per-browser: a gallery reopened in a second tab lost
|
||||
* 'client' even though the backend still served it as one, and the UI hid
|
||||
* the only control that clears the privileged cookie. Reported from the
|
||||
* token so a restored session knows what it actually is.
|
||||
*/
|
||||
describe('gallery session kind', () => {
|
||||
beforeEach(() => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a PIN-client session as client', async () => {
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken({ accessLevel: 'client' })}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('client');
|
||||
expect(res.body.viaCustomer).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a customer-portal session, which looks like a guest', async () => {
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken({ via: 'customer', customerId: 7 })}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('guest');
|
||||
expect(res.body.viaCustomer).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a plain guest as neither', async () => {
|
||||
// The flags have to discriminate, or they would just hand every visitor
|
||||
// a Logout button back.
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken()}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('guest');
|
||||
expect(res.body.viaCustomer).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns valid:false when the token is revoked', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Authorization / ownership gaps (GHSA permission cluster):
|
||||
* - jm7j: API-token list must scope to the caller (non-super sees only own)
|
||||
* - gprq: API-token revoke must be owner-or-super_admin
|
||||
* - 3rqx: event update must not mass-assign identity/secret columns
|
||||
* - j2f4: category hero must belong to that category
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-authz-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'authz-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-authz-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bcrypt = require('bcrypt');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('authorization / ownership gaps', () => {
|
||||
let db; let cleanup; let app;
|
||||
let superId; let superTok; let adminId; let adminTok;
|
||||
|
||||
const grantPermissionToRole = async (roleName, permName) => {
|
||||
const role = await db('roles').where({ name: roleName }).first();
|
||||
const perm = await db('permissions').where({ name: permName }).first();
|
||||
const exists = await db('role_permissions')
|
||||
.where({ role_id: role.id, permission_id: perm.id }).first();
|
||||
if (!exists) {
|
||||
await db('role_permissions').insert({ role_id: role.id, permission_id: perm.id });
|
||||
}
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId: superId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, superId, 'super_admin');
|
||||
superTok = mintAdminToken(superId);
|
||||
|
||||
const pass = await bcrypt.hash('x', 4);
|
||||
const ins = await db('admin_users').insert({
|
||||
username: 'plain-admin', email: 'plain@example.com',
|
||||
password_hash: pass, must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
adminId = ins[0]?.id ?? ins[0];
|
||||
await assignAdminRole(db, adminId, 'admin');
|
||||
// Grant settings.edit to the admin role BEFORE any request populates the
|
||||
// 60s permission cache, so the revoke test exercises the ownership check
|
||||
// (404) rather than the missing-permission gate (403). This models a
|
||||
// custom role that carries settings.edit — the scenario GHSA-gprq needs.
|
||||
await grantPermissionToRole('admin', 'settings.edit');
|
||||
adminTok = mintAdminToken(adminId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/api-tokens', require('../../src/routes/adminApiTokens'));
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
app.use('/api/admin/categories', require('../../src/routes/adminCategories'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
|
||||
|
||||
describe('API tokens (jm7j / gprq)', () => {
|
||||
let superTokenId;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await auth(request(app).post('/api/admin/api-tokens'), superTok)
|
||||
.send({ name: 'super-token', scopes: ['read'] });
|
||||
expect(res.status).toBe(201);
|
||||
superTokenId = res.body.id;
|
||||
});
|
||||
|
||||
it('non-super admin does not see another admin\'s tokens in the list', async () => {
|
||||
const res = await auth(request(app).get('/api/admin/api-tokens'), adminTok);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.find((t) => t.id === superTokenId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('super_admin sees all tokens', async () => {
|
||||
const res = await auth(request(app).get('/api/admin/api-tokens'), superTok);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.find((t) => t.id === superTokenId)).toBeDefined();
|
||||
});
|
||||
|
||||
it('a non-owner (with settings.edit) cannot revoke another admin\'s token', async () => {
|
||||
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), adminTok);
|
||||
expect(res.status).toBe(404);
|
||||
const row = await db('api_tokens').where({ id: superTokenId }).first();
|
||||
expect(row.revoked_at).toBeFalsy();
|
||||
});
|
||||
|
||||
it('the owner can revoke their own token', async () => {
|
||||
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), superTok);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('event update mass-assignment (3rqx)', () => {
|
||||
it('ignores identity/secret columns in the request body', async () => {
|
||||
const seedShareToken = 'orig-share-token';
|
||||
const ins = await db('events').insert({
|
||||
slug: 'authz-mass-assign', event_type: 'wedding', event_name: 'Before',
|
||||
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
||||
password_hash: 'orig-hash', share_link: '/gallery/authz/share', share_token: seedShareToken, expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const eventId = ins[0]?.id ?? ins[0];
|
||||
|
||||
const res = await auth(request(app).put(`/api/admin/events/${eventId}`), superTok).send({
|
||||
event_name: 'After',
|
||||
created_by: 99999,
|
||||
slug: 'hijacked-slug',
|
||||
share_token: 'hijacked-token',
|
||||
password_hash: 'hijacked-hash',
|
||||
is_archived: 1,
|
||||
archive_path: '/hijacked/archive/path',
|
||||
hero_logo_path: '/etc/passwd',
|
||||
is_draft: 1,
|
||||
project_id: 99999,
|
||||
// Case-variant keys — SQLite matches columns case-insensitively.
|
||||
Password_Hash: 'case-hijack-hash',
|
||||
Created_By: 88888,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db('events').where({ id: eventId }).first();
|
||||
expect(row.event_name).toBe('After'); // legit field applied
|
||||
expect(row.created_by).toBe(superId); // ownership untouched (+ case-variant)
|
||||
expect(row.slug).toBe('authz-mass-assign'); // routing identity untouched
|
||||
expect(row.share_token).toBe(seedShareToken); // secret untouched
|
||||
expect(row.password_hash).toBe('orig-hash'); // secret untouched (+ case-variant)
|
||||
expect(row.is_archived).toBeFalsy(); // archive lifecycle untouched
|
||||
expect(row.archive_path).toBeFalsy(); // forged archive path rejected
|
||||
expect(row.hero_logo_path).toBeFalsy(); // fs.unlink primitive blocked
|
||||
expect(row.is_draft).toBeFalsy(); // publish workflow not bypassed
|
||||
expect(row.project_id).toBeFalsy(); // server-managed relationship untouched
|
||||
});
|
||||
|
||||
it('returns 200 (no-op) when the body contains only protected fields', async () => {
|
||||
const ins = await db('events').insert({
|
||||
slug: 'authz-empty-update', event_type: 'wedding', event_name: 'Keep',
|
||||
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
||||
password_hash: 'x', share_link: '/gallery/authz-empty/share', share_token: 'authz-empty-share',
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const id = ins[0]?.id ?? ins[0];
|
||||
// Body reduces to {} after the denylist — must not 500 (Knex rejects
|
||||
// .update({})).
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`), superTok)
|
||||
.send({ created_by: 1, slug: 'x', is_archived: 1 });
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.event_name).toBe('Keep');
|
||||
});
|
||||
});
|
||||
|
||||
describe('category hero cross-category (j2f4)', () => {
|
||||
it('rejects a hero photo that is not in the category', async () => {
|
||||
const evIns = await db('events').insert({
|
||||
slug: 'authz-cat', event_type: 'wedding', event_name: 'Cat Event',
|
||||
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
||||
password_hash: 'x', share_link: '/gallery/authz-cat/share', share_token: 'authz-cat-share', expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const evId = evIns[0]?.id ?? evIns[0];
|
||||
|
||||
const mkCat = async (name) => {
|
||||
const c = await db('photo_categories').insert({
|
||||
event_id: evId, name, slug: name.toLowerCase(), created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return c[0]?.id ?? c[0];
|
||||
};
|
||||
const cat1 = await mkCat('Cat1');
|
||||
const cat2 = await mkCat('Cat2');
|
||||
|
||||
const pIns = await db('photos').insert({
|
||||
event_id: evId, filename: 'p.jpg', path: 'authz-cat/p.jpg', type: 'individual',
|
||||
category_id: cat1, uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const photoInCat1 = pIns[0]?.id ?? pIns[0];
|
||||
|
||||
// Pointing cat2's hero at a photo that lives in cat1 must be refused.
|
||||
const bad = await auth(request(app).put(`/api/admin/categories/${cat2}/hero`), superTok)
|
||||
.send({ hero_photo_id: photoInCat1 });
|
||||
expect(bad.status).toBe(404);
|
||||
|
||||
// The photo's own category accepts it.
|
||||
const ok = await auth(request(app).put(`/api/admin/categories/${cat1}/hero`), superTok)
|
||||
.send({ hero_photo_id: photoInCat1 });
|
||||
expect(ok.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Full-instance export is super_admin only (GHSA-pv6w-rj34-wj9v).
|
||||
*
|
||||
* GET /api/admin/backup/picpeak/export dumps every table unredacted (bcrypt
|
||||
* hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3 secrets). It was gated only by
|
||||
* requirePermission('backup.create'), which the built-in `admin` role holds —
|
||||
* so any non-super_admin admin could download the whole database. Pins that
|
||||
* `admin` now gets 403 and `super_admin` passes the gate.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bkexport-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkexport-test-secret';
|
||||
|
||||
// The export otherwise walks the whole DB and writes a zip — stub it so the
|
||||
// super_admin happy path is fast and deterministic; the gate is what's tested.
|
||||
// The route deletes path.dirname(filePath) recursively after download, so the
|
||||
// stub MUST live in its own dir — a bare os.tmpdir() file would make the route
|
||||
// wipe the whole temp root (and other jest workers' DB files).
|
||||
const mockExportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-export-stub-'));
|
||||
const mockExportPath = path.join(mockExportDir, 'export.picpeak');
|
||||
fs.writeFileSync(mockExportPath, 'stub');
|
||||
jest.mock('../../src/services/picpeakExportService', () => ({
|
||||
createPicpeak: jest.fn(async () => ({ filePath: mockExportPath })),
|
||||
}));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('backup export super_admin gate (GHSA-pv6w)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let adminToken; let superToken;
|
||||
|
||||
const mkUser = async (username, roleName) => {
|
||||
const role = await db('roles').where({ name: roleName }).first();
|
||||
const r = await db('admin_users').insert({
|
||||
username,
|
||||
email: `${username}@example.com`,
|
||||
password_hash: await bcrypt.hash('Passw0rd!', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const id = r[0]?.id ?? r[0];
|
||||
return jwt.sign(
|
||||
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' },
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
adminToken = await mkUser('limited-admin', 'admin');
|
||||
superToken = await mkUser('root-admin', 'super_admin');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
fs.rmSync(mockExportDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('denies the built-in admin role (was: full DB dump)', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/backup/picpeak/export')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows super_admin', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/backup/picpeak/export')
|
||||
.set('Authorization', `Bearer ${superToken}`);
|
||||
expect(res.status).not.toBe(403);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Dashboard endpoints must not leak other admins' data to event-scoped
|
||||
* editors — GHSA-c2jj (/stats), GHSA-gqx7 (/analytics), GHSA-jhcf (/activity).
|
||||
*
|
||||
* All three are gated only by `analytics.view`, which the `editor` role holds.
|
||||
* But the events LIST restricts editors to their own rows
|
||||
* (adminEvents/crud.js: roleName === 'editor' → created_by = admin.id), so an
|
||||
* editor saw instance-wide totals — and, via /analytics topGalleries, other
|
||||
* admins' gallery names and SLUGS (the public gallery URL component) — for
|
||||
* events invisible to them everywhere else.
|
||||
*
|
||||
* Scoping deliberately keys on `editor` to mirror the events list exactly, so
|
||||
* the `admin` role's dashboard is unchanged.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dashscope-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dashscope-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('dashboard scoping (GHSA-c2jj / gqx7 / jhcf)', () => {
|
||||
let db; let cleanup; let app;
|
||||
let editorToken; let superToken;
|
||||
let ownEventId; let foreignEventId;
|
||||
|
||||
const mkAdmin = async (username, roleName) => {
|
||||
const role = await db('roles').where({ name: roleName }).first();
|
||||
const r = await db('admin_users').insert({
|
||||
username,
|
||||
email: `${username}@example.com`,
|
||||
password_hash: await bcrypt.hash('Passw0rd!', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const id = r[0]?.id ?? r[0];
|
||||
const token = jwt.sign(
|
||||
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' },
|
||||
);
|
||||
return { id, token };
|
||||
};
|
||||
|
||||
const mkEvent = async (slug, createdBy) => {
|
||||
const r = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: `${slug}-name`,
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_token: `tok-${slug}`,
|
||||
share_link: `/gallery/${slug}/tok-${slug}`,
|
||||
created_by: createdBy,
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const editor = await mkAdmin('scoped-editor', 'editor');
|
||||
const sup = await mkAdmin('root-admin', 'super_admin');
|
||||
editorToken = editor.token;
|
||||
superToken = sup.token;
|
||||
|
||||
ownEventId = await mkEvent('own-gallery', editor.id);
|
||||
foreignEventId = await mkEvent('foreign-gallery', sup.id);
|
||||
|
||||
// One photo + one view per event so the aggregates are non-zero.
|
||||
for (const [eventId, name] of [[ownEventId, 'own'], [foreignEventId, 'foreign']]) {
|
||||
await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${name}.jpg`,
|
||||
path: `events/active/${name}.jpg`,
|
||||
type: 'individual',
|
||||
size_bytes: 1000,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
});
|
||||
await db('access_logs').insert({
|
||||
event_id: eventId,
|
||||
action: 'view',
|
||||
ip_address: `10.0.0.${eventId}`,
|
||||
user_agent: 'Mozilla/5.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'photo_viewed',
|
||||
actor_type: 'admin',
|
||||
actor_name: `${name}-actor`,
|
||||
event_id: eventId,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/dashboard', require('../../src/routes/adminDashboard'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('/stats counts only the editor\'s own events and photos', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/dashboard/stats')
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Number(res.body.totalEvents)).toBe(1);
|
||||
expect(Number(res.body.totalPhotos)).toBe(1);
|
||||
expect(Number(res.body.storageUsed)).toBe(1000);
|
||||
});
|
||||
|
||||
it('/analytics does not expose a foreign gallery name or slug', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/dashboard/analytics?days=7')
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body).not.toContain('foreign-gallery');
|
||||
expect(body).not.toContain('foreign-gallery-name');
|
||||
expect(res.body.topGalleries.map((g) => g.slug)).toEqual(['own-gallery']);
|
||||
});
|
||||
|
||||
it('/activity does not surface a foreign event\'s entries', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/dashboard/activity')
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const actors = res.body.map((a) => a.actorName);
|
||||
expect(actors).toContain('own-actor');
|
||||
expect(actors).not.toContain('foreign-actor');
|
||||
});
|
||||
|
||||
it('leaves super_admin unscoped across all three', async () => {
|
||||
const stats = await request(app)
|
||||
.get('/api/admin/dashboard/stats')
|
||||
.set('Authorization', `Bearer ${superToken}`);
|
||||
expect(Number(stats.body.totalEvents)).toBe(2);
|
||||
|
||||
const analytics = await request(app)
|
||||
.get('/api/admin/dashboard/analytics?days=7')
|
||||
.set('Authorization', `Bearer ${superToken}`);
|
||||
expect(analytics.body.topGalleries.map((g) => g.slug).sort())
|
||||
.toEqual(['foreign-gallery', 'own-gallery']);
|
||||
|
||||
const activity = await request(app)
|
||||
.get('/api/admin/dashboard/activity')
|
||||
.set('Authorization', `Bearer ${superToken}`);
|
||||
expect(activity.body.map((a) => a.actorName)).toContain('foreign-actor');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Codex round 2: the /activity filter trusts `activity_logs.event_id`, but
|
||||
* expenseService was passing `adminId` into logActivity's third positional
|
||||
* parameter — which is `eventId`. Admin and event id sequences overlap, so a
|
||||
* foreign admin's expense metadata could surface under an editor's event.
|
||||
* Those writers now pass the actor instead, leaving event_id NULL.
|
||||
*/
|
||||
describe('activity writers do not put admin ids in event_id (GHSA-jhcf)', () => {
|
||||
it('expenseService passes the actor, not adminId, as the event id', () => {
|
||||
const fs2 = require('fs');
|
||||
const src = fs2.readFileSync(
|
||||
require('path').join(__dirname, '../../src/services/expenseService.js'), 'utf8',
|
||||
);
|
||||
// No logActivity call may end with a bare `, adminId)` — that slot is eventId.
|
||||
const offenders = src.split('\n').filter(
|
||||
(l) => l.includes('logActivity(') && /,\s*adminId\s*\)/.test(l),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
// And the actor form must actually be in use.
|
||||
expect(src).toContain("{ type: 'admin', id: adminId }");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Manual database backup must not honour a caller-supplied destination
|
||||
* (GHSA-jw8m-43r2-jqrm).
|
||||
*
|
||||
* POST /api/admin/database-backup/backup forwarded req.body straight into
|
||||
* databaseBackupService.backup(), which merges options over its config:
|
||||
* const { destinationPath = '/backup/database', ... } = { ...config, ...options }
|
||||
* `destinationPath` is not a persistable setting (the /config allowlist only
|
||||
* accepts `database_backup_*` keys), so the request body was its ONLY source.
|
||||
*
|
||||
* The `admin` role holds backup.create but neither settings.edit nor
|
||||
* backup.restore — so it could aim a full DB dump (bcrypt hashes, gallery
|
||||
* password hashes, encrypted SMTP creds) at the PUBLIC /uploads static mount
|
||||
* (server.js mounts it with no auth middleware) and fetch it unauthenticated.
|
||||
*
|
||||
* Pins that destinationPath from the body is ignored, while the legitimate
|
||||
* knobs still pass through.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-test-secret';
|
||||
|
||||
// Capture what the route hands the service; never run a real backup.
|
||||
const mockBackup = jest.fn(async () => ({ success: true }));
|
||||
jest.mock('../../src/services/databaseBackup', () => ({
|
||||
databaseBackupService: {
|
||||
get isRunning() { return false; },
|
||||
backup: (...args) => mockBackup(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('manual database backup destination (GHSA-jw8m)', () => {
|
||||
let db; let cleanup; let app; let adminToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const role = await db('roles').where({ name: 'admin' }).first();
|
||||
const r = await db('admin_users').insert({
|
||||
username: 'limited-admin',
|
||||
email: 'limited-admin@example.com',
|
||||
password_hash: await bcrypt.hash('Passw0rd!', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const id = r[0]?.id ?? r[0];
|
||||
adminToken = jwt.sign(
|
||||
{ id, username: 'limited-admin', type: 'admin', role: 'admin', loginTime: Date.now() },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' },
|
||||
);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/database-backup', require('../../src/routes/adminDatabaseBackup'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(() => mockBackup.mockClear());
|
||||
|
||||
it('ignores a caller-supplied destinationPath', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/database-backup/backup')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ destinationPath: '/app/storage/uploads' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Give the fire-and-forget call a tick to land.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(mockBackup).toHaveBeenCalled();
|
||||
const opts = mockBackup.mock.calls[0][0];
|
||||
expect(opts).not.toHaveProperty('destinationPath');
|
||||
expect(JSON.stringify(opts)).not.toContain('uploads');
|
||||
});
|
||||
|
||||
it('still forwards the legitimate backup knobs', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/database-backup/backup')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ compress: false, validateIntegrity: false, destinationPath: '/tmp/evil' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const opts = mockBackup.mock.calls[0][0];
|
||||
expect(opts.compress).toBe(false);
|
||||
expect(opts.validateIntegrity).toBe(false);
|
||||
expect(opts).not.toHaveProperty('destinationPath');
|
||||
});
|
||||
|
||||
it('omits absent knobs entirely so service/config defaults still apply', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/database-backup/backup')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
// An explicit `{compress: undefined}` would override config on spread —
|
||||
// absent keys must simply not be present.
|
||||
expect(mockBackup.mock.calls[0][0]).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* GHSA-2qc2 / GHSA-32h4 / GHSA-3335 — feedback moderation, deletion, and the
|
||||
* pending-moderation list are by-feedback-id (or global) and lacked ownership
|
||||
* scoping, so a restricted editor could act on / enumerate feedback for events
|
||||
* it does not own. super_admin keeps global access.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fbown-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'fbown-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fbown-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('feedback ownership scoping', () => {
|
||||
let db; let cleanup; let app;
|
||||
let superTok; let editorTok; let editorId;
|
||||
let foreignFeedbackId;
|
||||
|
||||
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId: superId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, superId, 'super_admin');
|
||||
superTok = mintAdminToken(superId);
|
||||
|
||||
const ins = await db('admin_users').insert({
|
||||
username: 'editor', email: 'editor@example.com',
|
||||
password_hash: await bcrypt.hash('x', 4), must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
editorId = ins[0]?.id ?? ins[0];
|
||||
await assignAdminRole(db, editorId, 'editor');
|
||||
editorTok = mintAdminToken(editorId);
|
||||
|
||||
// Event owned by super_admin (NOT the editor).
|
||||
const ev = await db('events').insert({
|
||||
slug: 'fbown-foreign', event_type: 'wedding', event_name: 'Foreign',
|
||||
event_date: '2026-08-01', host_email: 'h@e.com', admin_email: 'a@e.com',
|
||||
password_hash: 'x', share_link: '/g/fbown/s', share_token: 'fbown-share',
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const eventId = ev[0]?.id ?? ev[0];
|
||||
const ph = await db('photos').insert({
|
||||
event_id: eventId, filename: 'p.jpg', path: 'fbown-foreign/p.jpg', type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const photoId = ph[0]?.id ?? ph[0];
|
||||
const fb = await db('photo_feedback').insert({
|
||||
photo_id: photoId, event_id: eventId, feedback_type: 'comment',
|
||||
comment_text: 'hi', is_approved: 0, is_hidden: 0, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
foreignFeedbackId = fb[0]?.id ?? fb[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/feedback', require('../../src/routes/adminFeedback'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('editor cannot moderate feedback on an event it does not own (404)', async () => {
|
||||
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), editorTok);
|
||||
expect(res.status).toBe(404);
|
||||
const row = await db('photo_feedback').where({ id: foreignFeedbackId }).first();
|
||||
expect([false, 0]).toContain(row.is_approved); // untouched
|
||||
});
|
||||
|
||||
it('editor cannot delete foreign feedback, row survives', async () => {
|
||||
const res = await auth(request(app).delete(`/api/admin/feedback/feedback/${foreignFeedbackId}`), editorTok);
|
||||
// Denied either at the events.delete permission layer (editor lacks it →
|
||||
// 403) or the ownership layer (404) — both must leave the row intact.
|
||||
expect([403, 404]).toContain(res.status);
|
||||
expect(await db('photo_feedback').where({ id: foreignFeedbackId }).first()).toBeDefined();
|
||||
});
|
||||
|
||||
it('editor sees no foreign feedback in pending-moderation', async () => {
|
||||
const res = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), editorTok);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.find((f) => f.id === foreignFeedbackId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('super_admin CAN moderate and see it', async () => {
|
||||
const pending = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), superTok);
|
||||
expect(pending.body.find((f) => f.id === foreignFeedbackId)).toBeDefined();
|
||||
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), superTok);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* GHSA-rh8r-7x3h-36rv — the unauthenticated GET /api/gallery/resolve/:identifier
|
||||
* must NOT return a gallery's secret share_token (nor the share links that
|
||||
* embed it) for a bare *slug* lookup. Slugs appear in gallery URLs and are
|
||||
* guessable; handing back the secret turns a known slug into share-link
|
||||
* access to a no-password gallery. The token is only returned when the caller
|
||||
* resolved via the token / full share link (i.e. already holds it).
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-resolve-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'resolve-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-resolve-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const SLUG = 'resolve-test-event';
|
||||
const SHARE_TOKEN = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6';
|
||||
|
||||
describe('GET /api/gallery/resolve/:identifier (GHSA-rh8r)', () => {
|
||||
let db; let cleanup; let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Resolve Test',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/${SHARE_TOKEN}`,
|
||||
share_token: SHARE_TOKEN,
|
||||
require_password: 0, // no-password → the token IS the access credential
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('does NOT leak the share_token (or share links) for a bare slug lookup', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${SLUG}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.slug).toBe(SLUG);
|
||||
expect(res.body.matchType).toBe('slug');
|
||||
// The secret must be absent — and must not sneak out via the share links.
|
||||
expect(res.body.token).toBeUndefined();
|
||||
expect(res.body.share_link).toBeUndefined();
|
||||
expect(res.body.share_url).toBeUndefined();
|
||||
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
|
||||
});
|
||||
|
||||
it('DOES return the token when the caller already resolved via the token', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${SHARE_TOKEN}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBe(SHARE_TOKEN);
|
||||
expect(res.body.matchType).toMatch(/token/);
|
||||
});
|
||||
|
||||
it('does NOT leak the token via SQL LIKE wildcards in the link_partial fallback', async () => {
|
||||
// Before the escaping fix, an anonymous request of 32 underscores matched
|
||||
// any share_link ending in a 32-char token (`_` = single-char wildcard),
|
||||
// resolved as matchType 'link_partial', and handed back the bearer token.
|
||||
// The share_token here has no underscores, so an escaped LIKE must miss.
|
||||
const res = await request(app).get(`/api/gallery/resolve/${'_'.repeat(SHARE_TOKEN.length)}`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.token).toBeUndefined();
|
||||
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* SQLite boolean coercion in the guest gallery surface (#1028).
|
||||
*
|
||||
* SQLite stores booleans as 0/1; Postgres stores true/false. The /photos
|
||||
* payload and every download guard compared strictly against `true`/`false`,
|
||||
* so on SQLite:
|
||||
*
|
||||
* allow_downloads: 0 !== false → true (button shown while disabled)
|
||||
* allow_user_uploads: 1 === true → false (button hidden while enabled)
|
||||
* if (allow_downloads === false) → never fires, so ALL download endpoints
|
||||
* kept serving with downloads switched off
|
||||
*
|
||||
* (The download-jobs route asserted on main is #858, which is beta-only —
|
||||
* this branch covers the three download endpoints that exist here.)
|
||||
*
|
||||
* The harness runs on SQLite, so these assertions exercise the real engine
|
||||
* values rather than a mock. Every test here fails on the unfixed code.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-flags-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sqlite-flags-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-flags-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const SLUG = 'sqlite-flags-gallery';
|
||||
|
||||
describe('gallery flags survive SQLite 0/1 storage (#1028)', () => {
|
||||
let db; let cleanup; let app; let eventId; let photoId;
|
||||
|
||||
async function setEventFlags(patch) {
|
||||
await db('events').where('id', eventId).update(patch);
|
||||
}
|
||||
|
||||
async function getPayload() {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
|
||||
expect(res.status).toBe(200);
|
||||
return res.body.event;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const ev = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'SQLite Flags',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/s`,
|
||||
share_token: 'sqlite-flags-share',
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
// Password-free so verifyGalleryAccess takes the public path and loads
|
||||
// the row with SELECT * — i.e. the raw 0/1 values, same as production.
|
||||
require_password: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = ev[0]?.id ?? ev[0];
|
||||
|
||||
const ph = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'p.jpg',
|
||||
path: `${SLUG}/p.jpg`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoId = ph[0]?.id ?? ph[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
test('the engine under test really is SQLite storing 0/1', async () => {
|
||||
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
|
||||
await setEventFlags({ allow_downloads: 0 });
|
||||
const row = await db('events').where('id', eventId).first('allow_downloads');
|
||||
expect(row.allow_downloads).toBe(0);
|
||||
});
|
||||
|
||||
describe('with downloads disabled (allow_downloads = 0)', () => {
|
||||
beforeAll(async () => {
|
||||
await setEventFlags({ allow_downloads: 0, allow_user_uploads: 1 });
|
||||
});
|
||||
|
||||
test('payload reports allow_downloads false (was true — header button shown)', async () => {
|
||||
expect((await getPayload()).allow_downloads).toBe(false);
|
||||
});
|
||||
|
||||
test('payload reports allow_user_uploads true (was false — upload button hidden)', async () => {
|
||||
expect((await getPayload()).allow_user_uploads).toBe(true);
|
||||
});
|
||||
|
||||
test('single-photo download is refused', async () => {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('download-all is refused', async () => {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('download-selected is refused', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/gallery/${SLUG}/download-selected`)
|
||||
.send({ photo_ids: [photoId] });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with downloads enabled (allow_downloads = 1)', () => {
|
||||
beforeAll(async () => {
|
||||
await setEventFlags({ allow_downloads: 1, allow_user_uploads: 0 });
|
||||
});
|
||||
|
||||
test('payload reports allow_downloads true / allow_user_uploads false', async () => {
|
||||
const event = await getPayload();
|
||||
expect(event.allow_downloads).toBe(true);
|
||||
expect(event.allow_user_uploads).toBe(false);
|
||||
});
|
||||
|
||||
test('download-all is no longer refused', async () => {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('protection flags', () => {
|
||||
test('0/1 protection toggles are reported the way they are stored', async () => {
|
||||
await setEventFlags({
|
||||
disable_right_click: 1,
|
||||
enable_devtools_protection: 1,
|
||||
use_canvas_rendering: 1,
|
||||
watermark_downloads: 1,
|
||||
overlay_protection: 0,
|
||||
});
|
||||
const event = await getPayload();
|
||||
expect(event.disable_right_click).toBe(true);
|
||||
expect(event.enable_devtools_protection).toBe(true);
|
||||
expect(event.use_canvas_rendering).toBe(true);
|
||||
expect(event.watermark_downloads).toBe(true);
|
||||
expect(event.overlay_protection).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-category download blocking (#640) on SQLite', () => {
|
||||
test('a category with allow_downloads = 0 is reported as blocked', async () => {
|
||||
const cat = await db('photo_categories').insert({
|
||||
name: 'Blocked', slug: 'blocked', event_id: eventId, is_global: 0, allow_downloads: 0,
|
||||
}).returning('id');
|
||||
const categoryId = cat[0]?.id ?? cat[0];
|
||||
await db('photos').where('id', photoId).update({ category_id: categoryId });
|
||||
|
||||
await setEventFlags({ allow_downloads: 1 });
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const category = res.body.categories.find((c) => c.id === categoryId);
|
||||
expect(category.allow_downloads).toBe(false);
|
||||
const photo = res.body.photos.find((p) => p.id === photoId);
|
||||
expect(photo.category_allow_downloads).toBe(false);
|
||||
|
||||
// …and the per-category guard on the single-photo route fires.
|
||||
const dl = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
|
||||
expect(dl.status).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Hidden/client-only photo access control across the bulk + secure photo
|
||||
* routes (GHSA cluster: fpwq / ghf8 / 3jvw / 9cc4 / 2hqg / jc22).
|
||||
*
|
||||
* A photo with visibility='hidden' is client-only. The main photo-list and
|
||||
* single-photo download/view routes enforced this, but the bulk-download,
|
||||
* protected-image, and secure-image routes shipped without the check —
|
||||
* letting an ordinary guest reach hidden photos. These tests pin that
|
||||
* guests are refused and PIN-clients (accessLevel='client') still succeed.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-hidden-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-photo-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-hidden-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const SLUG = 'hidden-photo-test-event';
|
||||
|
||||
describe('hidden-photo access control (GHSA cluster)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let visibleId;
|
||||
let hiddenId;
|
||||
|
||||
const guestToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
const clientToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery', accessLevel: 'client' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Hidden Photo Test',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'hidden-photo-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0, allow_downloads: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
|
||||
fs.mkdirSync(photoDir, { recursive: true });
|
||||
|
||||
// A real 1x1 PNG so the protected /view route's Sharp processing path
|
||||
// succeeds (fake bytes 500 on metadata()). Content, not extension,
|
||||
// drives Sharp's format detection.
|
||||
const PNG_1x1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMCAQGV2rY9AAAAAElFTkSuQmCC',
|
||||
'base64'
|
||||
);
|
||||
const mkPhoto = async (filename, visibility) => {
|
||||
fs.writeFileSync(path.join(photoDir, filename), PNG_1x1);
|
||||
const p = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename,
|
||||
path: `${SLUG}/${filename}`,
|
||||
type: 'individual',
|
||||
visibility,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return p[0]?.id ?? p[0];
|
||||
};
|
||||
visibleId = await mkPhoto('visible.jpg', 'visible');
|
||||
hiddenId = await mkPhoto('hidden.jpg', 'hidden');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/images', require('../../src/routes/protectedImages'));
|
||||
app.use('/api/secure-images', require('../../src/routes/secureImages'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('download-selected (GHSA-ghf8, medium)', () => {
|
||||
it('omits a hidden photo for a guest even when its id is requested', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/gallery/${SLUG}/download-selected`)
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.send({ photo_ids: [visibleId, hiddenId] });
|
||||
// The visible photo still zips; the hidden one is filtered out. If
|
||||
// only the hidden id were requested, the filter empties the set → 404.
|
||||
expect(res.status).toBe(200);
|
||||
const solo = await request(app)
|
||||
.post(`/api/gallery/${SLUG}/download-selected`)
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.send({ photo_ids: [hiddenId] });
|
||||
expect(solo.status).toBe(404);
|
||||
});
|
||||
|
||||
it('includes the hidden photo for a client', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/gallery/${SLUG}/download-selected`)
|
||||
.set('Authorization', `Bearer ${clientToken()}`)
|
||||
.send({ photo_ids: [hiddenId] });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download-all (GHSA-fpwq, medium)', () => {
|
||||
it('streams for a guest without erroring (hidden photos filtered)', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download-all`)
|
||||
.set('Authorization', `Bearer ${guestToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('protected-image view (GHSA-9cc4)', () => {
|
||||
it('403s a hidden photo for a guest', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
|
||||
.set('Authorization', `Bearer ${guestToken()}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
it('serves a visible photo for a guest', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/images/${SLUG}/photo/${visibleId}/view`)
|
||||
.set('Authorization', `Bearer ${guestToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
it('serves a hidden photo for a client', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
|
||||
.set('Authorization', `Bearer ${clientToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signed-URL mint (GHSA-3jvw)', () => {
|
||||
it('403s minting a signed URL for a hidden photo as a guest', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
|
||||
.set('Authorization', `Bearer ${guestToken()}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
it('mints for a client', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
|
||||
.set('Authorization', `Bearer ${clientToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.url).toContain('/signed/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy secure-token mint (protectedImages generate-secure-token)', () => {
|
||||
it('403s a hidden photo for a guest', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
|
||||
.set('Authorization', `Bearer ${guestToken()}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
it('mints for a client', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
|
||||
.set('Authorization', `Bearer ${clientToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('secure-token mint (GHSA-2hqg)', () => {
|
||||
it('403s minting a secure token for a hidden photo as a guest', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/secure-images/${SLUG}/generate-token`)
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.send({ photoId: hiddenId });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
it('mints for a client', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/secure-images/${SLUG}/generate-token`)
|
||||
.set('Authorization', `Bearer ${clientToken()}`)
|
||||
.send({ photoId: hiddenId });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// A capability minted while a photo is visible must stop serving once the
|
||||
// photo is hidden — unless minted by a client (clientBypass in the token).
|
||||
describe('signed-URL TOCTOU (hidden AFTER minting)', () => {
|
||||
afterEach(async () => {
|
||||
await db('photos').where({ id: visibleId }).update({ visibility: 'visible' });
|
||||
});
|
||||
|
||||
it("a guest's pre-minted signed URL stops serving once the photo is hidden", async () => {
|
||||
const mint = await request(app)
|
||||
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
|
||||
.set('Authorization', `Bearer ${guestToken()}`);
|
||||
expect(mint.status).toBe(200);
|
||||
const url = mint.body.url;
|
||||
// Still visible → serves.
|
||||
expect((await request(app).get(url)).status).toBe(200);
|
||||
// Hide it → the guest token (no clientBypass) must now be refused.
|
||||
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
|
||||
expect((await request(app).get(url)).status).toBe(403);
|
||||
});
|
||||
|
||||
it("a client's pre-minted signed URL keeps serving after the photo is hidden", async () => {
|
||||
const mint = await request(app)
|
||||
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
|
||||
.set('Authorization', `Bearer ${clientToken()}`);
|
||||
expect(mint.status).toBe(200);
|
||||
const url = mint.body.url;
|
||||
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
|
||||
expect((await request(app).get(url)).status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Logo diagnostic must not leak the filesystem layout, and must mirror what
|
||||
* resolveLogoFile actually tries (GHSA-29vm, codex round 2).
|
||||
*
|
||||
* Round 1 relativised `resolvedTo` and the candidate paths but still echoed
|
||||
* `sources[].value` verbatim — and branding_logo_path is stored ABSOLUTE by
|
||||
* multer, so the layout went out anyway. It also dropped the raw-absolute
|
||||
* candidate, which the resolver retains (subject to containment), making the
|
||||
* diagnostic report every candidate as missing for a legitimately contained
|
||||
* absolute logo while `resolvedTo` named the file.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-logodiag-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'logodiag-test-secret';
|
||||
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('logo diagnostic disclosure (GHSA-29vm)', () => {
|
||||
let db; let cleanup; let app; let token;
|
||||
// bootCrmDb() sets STORAGE_PATH itself, so resolve these AFTER it runs.
|
||||
let STORAGE; let logoDir; let logoPath;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
// A legitimately contained absolute logo in a NON-standard storage subdir.
|
||||
STORAGE = process.env.STORAGE_PATH;
|
||||
logoDir = path.join(STORAGE, 'custom');
|
||||
logoPath = path.join(logoDir, 'logo.png');
|
||||
fs.mkdirSync(logoDir, { recursive: true });
|
||||
fs.writeFileSync(logoPath, 'png');
|
||||
|
||||
const setting = { setting_key: 'branding_logo_path', setting_value: JSON.stringify(logoPath), setting_type: 'branding' };
|
||||
const existing = await db('app_settings').where({ setting_key: 'branding_logo_path' }).first();
|
||||
if (existing) await db('app_settings').where({ setting_key: 'branding_logo_path' }).update(setting);
|
||||
else await db('app_settings').insert(setting);
|
||||
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
const r = await db('admin_users').insert({
|
||||
username: 'diag-admin', email: 'diag@example.com',
|
||||
password_hash: await bcrypt.hash('Passw0rd!', 4),
|
||||
role_id: role.id, is_active: 1,
|
||||
created_at: new Date(), updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const id = r[0]?.id ?? r[0];
|
||||
token = jwt.sign(
|
||||
{ id, username: 'diag-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
|
||||
process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' },
|
||||
);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('does not leak absolute paths, cwd or storage root anywhere in the payload', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/business-profile/logo-diagnostic')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body).not.toContain(STORAGE);
|
||||
expect(body).not.toContain(process.cwd());
|
||||
expect(res.body.storageRoot).toBeUndefined();
|
||||
expect(res.body.cwd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still finds a contained absolute logo outside the standard subdirs', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/business-profile/logo-diagnostic')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path');
|
||||
expect(source).toBeTruthy();
|
||||
// The resolver keeps the contained absolute candidate, so the diagnostic
|
||||
// must show it existing rather than reporting everything missing.
|
||||
expect(source.candidates.some((c) => c.exists)).toBe(true);
|
||||
expect(res.body.resolvedTo).toMatch(/^<STORAGE>\//);
|
||||
});
|
||||
|
||||
it('shows the <STORAGE>/<value> candidate for a ROOT-RELATIVE logo URL (round 3)', async () => {
|
||||
// `/custom/logo.png` is a URL, not a disk path, but path.isAbsolute() says
|
||||
// true for both. Gating the stripped joins on isAbsolute() therefore hid
|
||||
// `<STORAGE>/custom/logo.png` — a candidate resolveLogoFile does try and
|
||||
// can resolve — so the diagnostic claimed nothing existed for a logo that
|
||||
// renders fine, and collapsed the configured value to its basename.
|
||||
await db('app_settings').where({ setting_key: 'branding_logo_path' })
|
||||
.update({ setting_value: JSON.stringify('/custom/logo.png') });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/business-profile/logo-diagnostic')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path');
|
||||
expect(source.candidates.some((c) => c.path === '<STORAGE>/custom/logo.png' && c.exists)).toBe(true);
|
||||
|
||||
// …and the disclosure guarantee still holds for this shape.
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body).not.toContain(STORAGE);
|
||||
expect(body).not.toContain(process.cwd());
|
||||
|
||||
await db('app_settings').where({ setting_key: 'branding_logo_path' })
|
||||
.update({ setting_value: JSON.stringify(logoPath) });
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ describe('photo engagement counters (#895)', () => {
|
||||
const getPhoto = async (id) => db('photos').where('id', id).first();
|
||||
// The counter writes are fire-and-forget on purpose — give the event
|
||||
// loop a beat before asserting.
|
||||
const settle = () => new Promise((r) => setTimeout(r, 100));
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
@@ -115,6 +115,7 @@ describe('photo engagement counters (#895)', () => {
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -200,6 +201,13 @@ describe('photo engagement counters (#895)', () => {
|
||||
// Own event so the on-the-fly archiver path is guaranteed — the
|
||||
// main event may have a cached zip from the previous test's
|
||||
// background generation, and racing its build/invalidate hangs.
|
||||
// The route also fires a background pre-zip build after streaming;
|
||||
// against this event's intentionally missing file it crashes with
|
||||
// an async ENOENT that jest attributes to whatever test is running
|
||||
// by then — neutralize it, it's not under test here.
|
||||
const downloadZipService = require('../../src/services/downloadZipService');
|
||||
const generateZipSpy = jest.spyOn(downloadZipService, 'generateZip')
|
||||
.mockResolvedValue({ success: false, error: 'disabled in test' });
|
||||
const slug2 = `${SLUG}-skip`;
|
||||
const ev = await db('events').insert({
|
||||
slug: slug2,
|
||||
@@ -248,6 +256,33 @@ describe('photo engagement counters (#895)', () => {
|
||||
expect((await db('photos').where('id', ids2[0]).first()).download_count).toBe(1);
|
||||
// photo-1's source was missing → skipped from the zip → not counted
|
||||
expect((await db('photos').where('id', ids2[1]).first()).download_count).toBe(0);
|
||||
generateZipSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin photos list exposes the counters (#895 follow-up)', () => {
|
||||
it('returns view_count and download_count so the Engagement column can render them', async () => {
|
||||
// The list mapper builds an explicit object — before this fix it
|
||||
// omitted both fields, so the admin table showed 0 forever even
|
||||
// though the DB counted correctly.
|
||||
await request(app)
|
||||
.post(`/api/gallery/${SLUG}/photo/${photoIds[0]}/view`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
await settle();
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/admin/photos/${eventId}/photos`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
const row = res.body.photos.find((p) => p.id === photoIds[0]);
|
||||
expect(row.view_count).toBe(1);
|
||||
expect(row.download_count).toBe(1);
|
||||
const untouched = res.body.photos.find((p) => p.id === photoIds[1]);
|
||||
expect(untouched.view_count).toBe(0);
|
||||
expect(untouched.download_count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Project ownership — GHSA-wrg5 (project routes) and GHSA-93x4 (project email
|
||||
* endpoints).
|
||||
*
|
||||
* Project routes authorized on generic events.view / events.edit with NO
|
||||
* ownership check, so an editor could enumerate, read, update and aggregate
|
||||
* projects belonging to other admins' events. The email endpoints keyed on an
|
||||
* email_queue id alone, so any id could be previewed/resent/cancelled.
|
||||
*
|
||||
* `projects` had no owner column. It was added in migration 167 (backfilled
|
||||
* from linked events) rather than relying only on the transitive
|
||||
* events.project_id -> events.created_by path, because a brand-new EMPTY
|
||||
* project has no linked event to infer an owner from — which is exactly where
|
||||
* the create -> attach flow begins.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-projown-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projown-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('project ownership (GHSA-wrg5 / GHSA-93x4)', () => {
|
||||
let db; let cleanup; let app;
|
||||
let editorToken; let superToken; let editorId; let superId;
|
||||
let ownProjectId; let foreignProjectId; let foreignEventId; let foreignEmailId;
|
||||
|
||||
const mkAdmin = async (username, roleName) => {
|
||||
const role = await db('roles').where({ name: roleName }).first();
|
||||
const r = await db('admin_users').insert({
|
||||
username,
|
||||
email: `${username}@example.com`,
|
||||
password_hash: await bcrypt.hash('Passw0rd!', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const id = r[0]?.id ?? r[0];
|
||||
return {
|
||||
id,
|
||||
token: jwt.sign(
|
||||
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
|
||||
process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' },
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const mkProject = async (name, createdBy) => {
|
||||
const r = await db('projects').insert({
|
||||
name, status: 'active', created_by: createdBy,
|
||||
created_at: new Date(), updated_at: new Date(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
await db('feature_flags').insert({ key: 'projects', value: 1 })
|
||||
.onConflict('key').merge({ value: 1 });
|
||||
|
||||
const editor = await mkAdmin('proj-editor', 'editor');
|
||||
const sup = await mkAdmin('proj-super', 'super_admin');
|
||||
editorToken = editor.token; editorId = editor.id;
|
||||
superToken = sup.token; superId = sup.id;
|
||||
|
||||
ownProjectId = await mkProject('own-project', editorId);
|
||||
foreignProjectId = await mkProject('foreign-project', superId);
|
||||
|
||||
// A foreign event linked to the foreign project, plus a queued email on it.
|
||||
const ev = await db('events').insert({
|
||||
slug: 'foreign-ev',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Foreign Event',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_token: 'ftok', share_link: '/gallery/foreign-ev/ftok',
|
||||
created_by: superId,
|
||||
project_id: foreignProjectId,
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
foreignEventId = ev[0]?.id ?? ev[0];
|
||||
|
||||
const em = await db('email_queue').insert({
|
||||
event_id: foreignEventId,
|
||||
recipient_email: 'client@example.com',
|
||||
email_type: 'gallery_created',
|
||||
status: 'sent',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
foreignEmailId = em[0]?.id ?? em[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/projects', require('../../src/routes/adminProjects'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('lists only the editor\'s own projects', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/projects')
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const names = (res.body.projects || res.body.data?.projects || []).map((p) => p.name);
|
||||
expect(names).toContain('own-project');
|
||||
expect(names).not.toContain('foreign-project');
|
||||
});
|
||||
|
||||
it('refuses to read a foreign project', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/admin/projects/${foreignProjectId}`)
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
expect([403, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('refuses to update or aggregate a foreign project', async () => {
|
||||
const update = await request(app)
|
||||
.put(`/api/admin/projects/${foreignProjectId}`)
|
||||
.set('Authorization', `Bearer ${editorToken}`)
|
||||
.send({ name: 'hijacked' });
|
||||
expect([403, 404]).toContain(update.status);
|
||||
|
||||
const overview = await request(app)
|
||||
.get(`/api/admin/projects/${foreignProjectId}/overview`)
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
expect([403, 404]).toContain(overview.status);
|
||||
|
||||
// And the name must not have changed.
|
||||
const row = await db('projects').where({ id: foreignProjectId }).first();
|
||||
expect(row.name).toBe('foreign-project');
|
||||
});
|
||||
|
||||
it('refuses to attach a FOREIGN event to an owned project', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/projects/${ownProjectId}/events`)
|
||||
.set('Authorization', `Bearer ${editorToken}`)
|
||||
.send({ eventId: foreignEventId });
|
||||
|
||||
expect([403, 404]).toContain(res.status);
|
||||
const ev = await db('events').where({ id: foreignEventId }).first();
|
||||
expect(ev.project_id).toBe(foreignProjectId); // still attached to its own
|
||||
});
|
||||
|
||||
it('refuses to preview or act on a foreign queued email (GHSA-93x4)', async () => {
|
||||
const preview = await request(app)
|
||||
.get(`/api/admin/projects/email/${foreignEmailId}/preview`)
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
expect([403, 404]).toContain(preview.status);
|
||||
|
||||
const cancel = await request(app)
|
||||
.post(`/api/admin/projects/email/${foreignEmailId}/cancel`)
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
expect([403, 404]).toContain(cancel.status);
|
||||
});
|
||||
|
||||
it('leaves super_admin unrestricted', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/admin/projects/${foreignProjectId}`)
|
||||
.set('Authorization', `Bearer ${superToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Project ownership edge cases (GHSA-wrg5, codex round 2).
|
||||
*
|
||||
* The first predicate union'd "any linked event I can see" with the stored
|
||||
* owner, which opened two holes:
|
||||
* - a project owned by B containing ONE legacy ownerless event became
|
||||
* readable by everyone (and /overview aggregates B's other events,
|
||||
* invoices and emails);
|
||||
* - migration 167 deliberately leaves multi-owner projects NULL, and a NULL
|
||||
* owner was treated as "everyone's".
|
||||
* The stored owner is now authoritative, and a NULL owner only derives access
|
||||
* when EVERY linked event is accessible.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-projedge-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projedge-test-secret';
|
||||
|
||||
const bcrypt3 = require('bcrypt');
|
||||
const { bootCrmDb: boot3, seedMinimal: seed3 } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('project ownership edge cases (GHSA-wrg5, round 2)', () => {
|
||||
let db3; let cleanup3; let ownership; let editorA; let editorB;
|
||||
|
||||
const mkAdmin3 = async (username, roleName) => {
|
||||
const role = await db3('roles').where({ name: roleName }).first();
|
||||
const r = await db3('admin_users').insert({
|
||||
username, email: `${username}@example.com`,
|
||||
password_hash: await bcrypt3.hash('Passw0rd!', 4),
|
||||
role_id: role.id, is_active: 1,
|
||||
created_at: new Date(), updated_at: new Date(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
const mkProject3 = async (name, createdBy) => {
|
||||
const r = await db3('projects').insert({
|
||||
name, status: 'active', created_by: createdBy,
|
||||
created_at: new Date(), updated_at: new Date(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
const mkEvent3 = async (slug, createdBy, projectId) => {
|
||||
const r = await db3('events').insert({
|
||||
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
|
||||
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
|
||||
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
|
||||
created_by: createdBy, project_id: projectId,
|
||||
expires_at: new Date(Date.now() + 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db: db3, cleanup: cleanup3 } = await boot3());
|
||||
await seed3(db3);
|
||||
ownership = require('../../src/middleware/ownership');
|
||||
editorA = await mkAdmin3('edge-a', 'editor');
|
||||
editorB = await mkAdmin3('edge-b', 'editor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup3) await cleanup3(); });
|
||||
|
||||
it('one ownerless event in B\'s project does not expose it to A', async () => {
|
||||
const pid = await mkProject3('b-project', editorB);
|
||||
await mkEvent3('b-owned-ev', editorB, pid);
|
||||
await mkEvent3('legacy-ev', null, pid); // ownerless legacy event
|
||||
|
||||
const idsA = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
|
||||
expect(idsA).not.toContain(Number(pid));
|
||||
|
||||
const idsB = await ownership.ownedProjectIds({ id: editorB, roleName: 'editor' });
|
||||
expect(idsB).toContain(Number(pid));
|
||||
});
|
||||
|
||||
it('a mixed-owner project left NULL by migration 167 is not global', async () => {
|
||||
const pid = await mkProject3('ambiguous', null);
|
||||
await mkEvent3('mix-a-ev', editorA, pid);
|
||||
await mkEvent3('mix-b-ev', editorB, pid);
|
||||
|
||||
for (const who of [editorA, editorB]) {
|
||||
const ids = await ownership.ownedProjectIds({ id: who, roleName: 'editor' });
|
||||
expect(ids).not.toContain(Number(pid));
|
||||
}
|
||||
});
|
||||
|
||||
it('a NULL-owner project whose events are all mine IS mine', async () => {
|
||||
const pid = await mkProject3('legacy-mine', null);
|
||||
await mkEvent3('mine-ev', editorA, pid);
|
||||
|
||||
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
|
||||
expect(ids).toContain(Number(pid));
|
||||
});
|
||||
|
||||
it('a project whose creator was deleted falls back to its events', async () => {
|
||||
const ghost = await mkAdmin3('ghost-admin', 'editor');
|
||||
const pid = await mkProject3('orphaned', ghost);
|
||||
await mkEvent3('orphan-ev', editorA, pid);
|
||||
await db3('admin_users').where({ id: ghost }).del();
|
||||
|
||||
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
|
||||
expect(ids).toContain(Number(pid));
|
||||
});
|
||||
|
||||
it('super_admin stays unrestricted', async () => {
|
||||
expect(await ownership.ownedProjectIds({ id: 1, roleName: 'super_admin' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Restore path containment must not break the normal restore wizard
|
||||
* (GHSA-fw4c, codex round 2).
|
||||
*
|
||||
* `source` is usually a SOURCE TYPE, not a path: RestoreWizard posts
|
||||
* 'local' | 's3' | 'upload', and restoreService.restore() branches on those
|
||||
* literals before deriving a directory. The first version of the containment
|
||||
* check treated `source` as a path, so path.resolve('local') landed outside
|
||||
* the configured backup roots and BOTH /validate and /start returned 400 —
|
||||
* blocking every normal restore.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-restorepath-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restorepath-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('restore path allowlist (GHSA-fw4c)', () => {
|
||||
let db; let cleanup; let checkRestorePathsAllowed;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
// Configure a backup root so the allowlist is actually active.
|
||||
for (const [key, value] of [['backup_destination_path', '/backup']]) {
|
||||
const existing = await db('app_settings').where({ setting_key: key }).first();
|
||||
if (existing) {
|
||||
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
|
||||
} else {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('allows the wizard\'s source TYPE tokens', async () => {
|
||||
for (const source of ['local', 's3', 'upload']) {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source, manifestPath: '/backup/manifests/backup-manifest-1.json',
|
||||
});
|
||||
expect(err).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows an s3:// source URL', async () => {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source: 's3://bucket/key/backup.tar.gz',
|
||||
manifestPath: '/backup/manifests/backup-manifest-1.json',
|
||||
});
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it('still rejects a manifestPath outside the configured roots', async () => {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source: 'local', manifestPath: '/etc/passwd',
|
||||
});
|
||||
expect(err).toMatch(/inside a configured backup location/i);
|
||||
});
|
||||
|
||||
it('still rejects a traversal manifestPath', async () => {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source: 'local', manifestPath: '/backup/../etc/shadow',
|
||||
});
|
||||
expect(err).toBeTruthy();
|
||||
});
|
||||
|
||||
it('accepts a real path source inside the roots', async () => {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source: '/backup/run-1', manifestPath: '/backup/run-1/manifest.json',
|
||||
});
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Secure-image view route token binding (GHSA-g94x-8vv8-3c9f).
|
||||
*
|
||||
* The view route GET /api/secure-images/:slug/secure/:photoId/:token serves
|
||||
* via <img src> with the token in the URL, so it can't carry a gallery-token
|
||||
* header like the download sibling. Before the fix it validated only the
|
||||
* token signature and took the gallery/photo from the URL — so a token minted
|
||||
* on any PUBLIC gallery read every other gallery's photos with no password.
|
||||
*
|
||||
* Pins that the route now enforces the scope inside the token:
|
||||
* - the URL photoId must equal the token's minted photoId
|
||||
* - the gallery embedded in the token's sessionId must equal the URL gallery
|
||||
* A token minted on gallery A cannot read gallery B under either check; a
|
||||
* token used on its own gallery+photo passes the binding.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'secimg-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-storage-'));
|
||||
|
||||
// Stub the anti-bot/rate-limit middleware so the fingerprint is deterministic
|
||||
// — the token below is minted with the same fingerprint, so verifySecureToken
|
||||
// passes and the binding logic under test is what decides the outcome.
|
||||
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
|
||||
secureImageAccess: (req, _res, next) => {
|
||||
req.clientInfo = { fingerprint: 'test-fp', ip: '127.0.0.1', userAgent: 'jest' };
|
||||
next();
|
||||
},
|
||||
getSecurityStatus: (_req, res) => res.json({ ok: true }),
|
||||
}));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const secureImageService = require('../../src/services/secureImageService');
|
||||
|
||||
describe('secure-image view route token binding (GHSA-g94x)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let galleryA; let galleryB;
|
||||
let photoA; let photoB;
|
||||
|
||||
const mkEvent = async (slug, requirePassword) => {
|
||||
const r = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
require_password: requirePassword ? 1 : 0,
|
||||
share_link: `/gallery/${slug}/share`,
|
||||
share_token: `${slug}-share`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
const mkPhoto = async (eventId, slug, filename) => {
|
||||
const dir = path.join(process.env.STORAGE_PATH, 'events/active', slug);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, filename), Buffer.from('img'));
|
||||
const r = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename,
|
||||
path: `${slug}/${filename}`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
// Mint a token exactly as the mint route does — bound to (photoId, gallery
|
||||
// sessionId, fingerprint) — bypassing the anti-bot HTTP path.
|
||||
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
|
||||
photoId,
|
||||
`gallery_public_${eventId}_${Date.now()}`,
|
||||
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
|
||||
);
|
||||
|
||||
const view = (slug, photoId, token) => request(app)
|
||||
.get(`/api/secure-images/${slug}/secure/${photoId}/${token}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
galleryA = await mkEvent('secimg-public-a', false); // public — token source
|
||||
galleryB = await mkEvent('secimg-private-b', true); // password-protected — victim
|
||||
photoA = await mkPhoto(galleryA, 'secimg-public-a', 'a.jpg');
|
||||
photoB = await mkPhoto(galleryB, 'secimg-private-b', 'b.jpg');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/secure-images', require('../../src/routes/secureImages'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('rejects a gallery-A token used against gallery B (cross-photo)', async () => {
|
||||
const token = mint(photoA, galleryA);
|
||||
const res = await view('secimg-private-b', photoB, token);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/not valid for this photo/i);
|
||||
});
|
||||
|
||||
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
|
||||
const token = mint(photoA, galleryA);
|
||||
// URL photoId matches the token, so the photo check passes — the gallery
|
||||
// check (sessionId gallery A != URL gallery B) must catch it.
|
||||
const res = await view('secimg-private-b', photoA, token);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/not valid for this gallery/i);
|
||||
});
|
||||
|
||||
it('lets a token read its own gallery + photo (binding passes)', async () => {
|
||||
const token = mint(photoA, galleryA);
|
||||
const res = await view('secimg-public-a', photoA, token);
|
||||
// Binding passes; serving may 200/404/500 depending on the pipeline, but
|
||||
// it must NOT be rejected as a token mismatch.
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* v1 API tokens must respect event ownership (GHSA-9697).
|
||||
*
|
||||
* migration 081 documents the intent — "the token's effective permissions are
|
||||
* the intersection of the user's role permissions and the token's own scope
|
||||
* flags" — but it was never implemented:
|
||||
*
|
||||
* - apiTokenAuth selected only id/username/email/role_id, so
|
||||
* req.admin.roleName was undefined and every ownership helper (which all
|
||||
* key on roleName) could not distinguish a super_admin from a viewer.
|
||||
* - No v1 route applied requirePermission or a created_by predicate, so any
|
||||
* valid token listed every event and — worst — GET /events/:id/share-link
|
||||
* returned ANY event's share_token, which is the gallery access credential.
|
||||
*
|
||||
* Scenario pinned here: a token owned by a restricted (non-super_admin) admin
|
||||
* must see only its owner's events, and must not obtain a foreign share_token.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1own-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'v1own-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1own-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const { generateApiToken } = require('../../src/middleware/apiTokenAuth');
|
||||
|
||||
describe('v1 event ownership (GHSA-9697)', () => {
|
||||
let db; let cleanup; let app;
|
||||
let editorToken; let superToken;
|
||||
let ownEventId; let foreignEventId;
|
||||
const FOREIGN_SHARE_TOKEN = 'f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0';
|
||||
|
||||
const mkAdmin = async (username, roleName) => {
|
||||
const role = await db('roles').where({ name: roleName }).first();
|
||||
const r = await db('admin_users').insert({
|
||||
username,
|
||||
email: `${username}@example.com`,
|
||||
password_hash: await bcrypt.hash('Passw0rd!', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
const mkToken = async (adminId, scopes = 'admin') => {
|
||||
const { plaintext, hashed } = generateApiToken();
|
||||
await db('api_tokens').insert({
|
||||
name: `tok-${adminId}`,
|
||||
hashed_token: hashed,
|
||||
scopes,
|
||||
created_by: adminId,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
return plaintext;
|
||||
};
|
||||
|
||||
const mkEvent = async (slug, createdBy, shareToken) => {
|
||||
const r = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_token: shareToken,
|
||||
share_link: `/gallery/${slug}/${shareToken}`,
|
||||
created_by: createdBy,
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const editorId = await mkAdmin('restricted-editor', 'editor');
|
||||
const superId = await mkAdmin('root-admin', 'super_admin');
|
||||
editorToken = await mkToken(editorId);
|
||||
superToken = await mkToken(superId);
|
||||
|
||||
ownEventId = await mkEvent('own-event', editorId, 'a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1');
|
||||
foreignEventId = await mkEvent('foreign-event', superId, FOREIGN_SHARE_TOKEN);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v1', require('../../src/routes/v1/events'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('lists only the token owner\'s events', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/events')
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const slugs = res.body.events.map((e) => e.slug);
|
||||
expect(slugs).toContain('own-event');
|
||||
expect(slugs).not.toContain('foreign-event');
|
||||
});
|
||||
|
||||
it('refuses to read a foreign event', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/events/${foreignEventId}`)
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
|
||||
expect([403, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('does NOT hand out a foreign event\'s share_token', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/events/${foreignEventId}/share-link`)
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
|
||||
expect([403, 404]).toContain(res.status);
|
||||
expect(JSON.stringify(res.body)).not.toContain(FOREIGN_SHARE_TOKEN);
|
||||
});
|
||||
|
||||
it('still allows the owner to read their own event and share link', async () => {
|
||||
const detail = await request(app)
|
||||
.get(`/api/v1/events/${ownEventId}`)
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
expect(detail.status).toBe(200);
|
||||
|
||||
const share = await request(app)
|
||||
.get(`/api/v1/events/${ownEventId}/share-link`)
|
||||
.set('Authorization', `Bearer ${editorToken}`);
|
||||
expect(share.status).toBe(200);
|
||||
expect(share.body.share_token).toBe('a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1');
|
||||
});
|
||||
|
||||
it('leaves super_admin tokens unrestricted', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/events/${foreignEventId}/share-link`)
|
||||
.set('Authorization', `Bearer ${superToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.share_token).toBe(FOREIGN_SHARE_TOKEN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* v1 token scopes must intersect the owner's CURRENT role permissions
|
||||
* (GHSA-9697, codex round 2).
|
||||
*
|
||||
* Migration 081 documents effective permissions as the intersection of the
|
||||
* owner's role permissions and the token's scope flags. requireApiScope only
|
||||
* ever checked the scope half, so a token minted while its owner was
|
||||
* super_admin kept full write access after the owner was demoted to viewer —
|
||||
* userManagementService never touches api_tokens, so the token outlives the
|
||||
* demotion. Ownership scoping alone does not close this: the demoted owner
|
||||
* still *owns* their events.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1perm-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'v1perm-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1perm-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const { generateApiToken } = require('../../src/middleware/apiTokenAuth');
|
||||
|
||||
describe('v1 token scopes intersect role permissions (GHSA-9697)', () => {
|
||||
let db; let cleanup; let app; let viewerToken; let viewerEventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const role = await db('roles').where({ name: 'viewer' }).first();
|
||||
const r = await db('admin_users').insert({
|
||||
username: 'demoted-owner',
|
||||
email: 'demoted@example.com',
|
||||
password_hash: await bcrypt.hash('Passw0rd!', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const ownerId = r[0]?.id ?? r[0];
|
||||
|
||||
// A token still carrying the broad 'admin' scope from before demotion.
|
||||
const { plaintext, hashed } = generateApiToken();
|
||||
await db('api_tokens').insert({
|
||||
name: 'stale-token',
|
||||
hashed_token: hashed,
|
||||
scopes: 'admin',
|
||||
created_by: ownerId,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
viewerToken = plaintext;
|
||||
|
||||
const ev = await db('events').insert({
|
||||
slug: 'viewer-ev',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Viewer Event',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_token: 'vtok',
|
||||
share_link: '/gallery/viewer-ev/vtok',
|
||||
created_by: ownerId,
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
viewerEventId = ev[0]?.id ?? ev[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v1', require('../../src/routes/v1/events'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('denies event creation to a demoted viewer despite an admin-scope token', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/events')
|
||||
.set('Authorization', `Bearer ${viewerToken}`)
|
||||
.send({ event_name: 'Nope', event_type: 'wedding' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('denies photo upload to a demoted viewer on their OWN event', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/v1/events/${viewerEventId}/photos`)
|
||||
.set('Authorization', `Bearer ${viewerToken}`)
|
||||
.attach('photo', Buffer.from('x'), 'a.jpg');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('still allows the viewer to READ their own event', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/events/${viewerEventId}`)
|
||||
.set('Authorization', `Bearer ${viewerToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Backup/restore hardening — GHSA-h652 (unbounded gunzip) and GHSA-hgp8
|
||||
* (unkeyed manifest checksum).
|
||||
*
|
||||
* h652: decompressFile() piped gunzip straight to disk with no expanded-size
|
||||
* bound, so a small crafted .gz could fill the volume.
|
||||
*
|
||||
* hgp8: the manifest checksum is a plain SHA-256 — it proves the manifest was
|
||||
* not corrupted, not that it is authentic. BACKUP_MANIFEST_KEY upgrades new
|
||||
* manifests to a keyed HMAC. It is deliberately OPT-IN and verify-if-present:
|
||||
* the key cannot live in the database (the database is inside the backup), so
|
||||
* a mandatory HMAC would lock an operator out of the exact disaster-recovery
|
||||
* case this system exists for.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const zlib = require('zlib');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bkharden-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkharden-test-secret';
|
||||
|
||||
const { restoreService } = require('../../src/services/restoreService');
|
||||
const backupManifest = require('../../src/services/backupManifest');
|
||||
|
||||
describe('decompressFile expanded-size bound (GHSA-h652)', () => {
|
||||
let dir;
|
||||
|
||||
beforeAll(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-gz-')); });
|
||||
afterAll(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
afterEach(() => { delete process.env.RESTORE_MAX_DECOMPRESSED_BYTES; });
|
||||
|
||||
it('aborts when the decompressed stream exceeds the limit', async () => {
|
||||
// 5 MB of zeroes compresses to a few KB — the classic shape of the attack.
|
||||
const gzPath = path.join(dir, 'bomb.gz');
|
||||
fs.writeFileSync(gzPath, zlib.gzipSync(Buffer.alloc(5 * 1024 * 1024, 0)));
|
||||
|
||||
process.env.RESTORE_MAX_DECOMPRESSED_BYTES = String(64 * 1024); // 64 KB
|
||||
await expect(
|
||||
restoreService.decompressFile(gzPath, path.join(dir, 'out-bomb'))
|
||||
).rejects.toThrow(/exceeds limit/i);
|
||||
});
|
||||
|
||||
it('still decompresses a normal file within the limit', async () => {
|
||||
const payload = Buffer.from('SELECT 1;\n'.repeat(100));
|
||||
const gzPath = path.join(dir, 'ok.gz');
|
||||
fs.writeFileSync(gzPath, zlib.gzipSync(payload));
|
||||
|
||||
const outPath = path.join(dir, 'out-ok');
|
||||
await restoreService.decompressFile(gzPath, outPath);
|
||||
expect(fs.readFileSync(outPath)).toEqual(payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifest checksum keying (GHSA-hgp8)', () => {
|
||||
// validateManifest requires all of these sections to be present.
|
||||
const baseManifest = () => ({
|
||||
manifest: { version: '1.0', id: 'test' },
|
||||
backup: { type: 'full' },
|
||||
system: { platform: 'linux' },
|
||||
application: { version: '1.0.0' },
|
||||
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
|
||||
database: { type: 'sqlite' },
|
||||
verification: { total_checksum: null, checksum_algorithm: null },
|
||||
});
|
||||
|
||||
afterEach(() => { delete process.env.BACKUP_MANIFEST_KEY; });
|
||||
|
||||
it('produces a different digest when a key is set', () => {
|
||||
const m = baseManifest();
|
||||
const unkeyed = backupManifest.calculateManifestChecksum(m, { keyed: false });
|
||||
const keyed = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
|
||||
expect(keyed).not.toBe(unkeyed);
|
||||
});
|
||||
|
||||
it('validates a legacy unkeyed manifest even when a key IS configured', () => {
|
||||
// Disaster recovery: manifests written before keying must not become
|
||||
// un-restorable the moment the operator sets a key.
|
||||
const m = baseManifest();
|
||||
m.verification.checksum_algorithm = 'sha256';
|
||||
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
|
||||
|
||||
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
|
||||
expect(() => backupManifest.validateManifest(m)).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a keyed manifest when the matching key is configured', () => {
|
||||
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
|
||||
const m = baseManifest();
|
||||
m.verification.checksum_algorithm = 'hmac-sha256';
|
||||
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
|
||||
|
||||
expect(() => backupManifest.validateManifest(m)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a keyed manifest whose body was tampered with', () => {
|
||||
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
|
||||
const m = baseManifest();
|
||||
m.verification.checksum_algorithm = 'hmac-sha256';
|
||||
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
|
||||
|
||||
m.files.manifest[0].path = '../../etc/passwd';
|
||||
expect(() => backupManifest.validateManifest(m)).toThrow(/checksum verification failed/i);
|
||||
});
|
||||
|
||||
it('does NOT brick restore when a keyed manifest meets a missing key', () => {
|
||||
// Key lost with the host — the precise moment a restore is needed.
|
||||
const m = baseManifest();
|
||||
m.verification.checksum_algorithm = 'hmac-sha256';
|
||||
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
|
||||
|
||||
delete process.env.BACKUP_MANIFEST_KEY;
|
||||
expect(() => backupManifest.validateManifest(m)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifest checksum coverage (canonicalization)', () => {
|
||||
const fullManifest = () => ({
|
||||
manifest: { version: '1.0', id: 'test' },
|
||||
backup: { type: 'full' },
|
||||
system: { platform: 'linux' },
|
||||
application: { version: '1.0.0' },
|
||||
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
|
||||
database: { type: 'sqlite' },
|
||||
verification: { total_checksum: null, checksum_algorithm: 'sha256' },
|
||||
});
|
||||
|
||||
afterEach(() => { delete process.env.BACKUP_MANIFEST_KEY; });
|
||||
|
||||
it('covers nested file entries (the old replacer dropped them)', () => {
|
||||
const m = fullManifest();
|
||||
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
|
||||
// Tampering a file path must now change the digest.
|
||||
m.files.manifest[0].path = '../../etc/passwd';
|
||||
expect(() => backupManifest.validateManifest(m)).toThrow(/checksum verification failed/i);
|
||||
});
|
||||
|
||||
it('still accepts a manifest written with the legacy serialization', () => {
|
||||
const m = fullManifest();
|
||||
m.verification.total_checksum = backupManifest.calculateManifestChecksum(
|
||||
m, { keyed: false, legacy: true }
|
||||
);
|
||||
expect(() => backupManifest.validateManifest(m)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checksum verification is shared and downgrade-aware (codex round 2)', () => {
|
||||
const fullManifest = () => ({
|
||||
manifest: { version: '1.0', id: 'test' },
|
||||
backup: { type: 'full' },
|
||||
system: { platform: 'linux' },
|
||||
application: { version: '1.0.0' },
|
||||
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
|
||||
database: { type: 'sqlite' },
|
||||
verification: { total_checksum: null, checksum_algorithm: 'sha256' },
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.BACKUP_MANIFEST_KEY;
|
||||
delete process.env.BACKUP_MANIFEST_REQUIRE_KEYED;
|
||||
});
|
||||
|
||||
it('accepts a legacy-serialized manifest through the SHARED verifier', () => {
|
||||
// restoreService recomputed the digest itself with the canonical
|
||||
// serializer, which rejected every pre-existing backup.
|
||||
const m = fullManifest();
|
||||
m.verification.total_checksum = backupManifest.calculateManifestChecksum(
|
||||
m, { keyed: false, legacy: true },
|
||||
);
|
||||
const res = backupManifest.verifyManifestChecksum(m);
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.warnings.join(' ')).toMatch(/legacy checksum serialization/i);
|
||||
});
|
||||
|
||||
it('warns but accepts an unkeyed manifest when a key is configured', () => {
|
||||
const m = fullManifest();
|
||||
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
|
||||
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
|
||||
|
||||
const res = backupManifest.verifyManifestChecksum(m);
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.warnings.join(' ')).toMatch(/authenticity NOT established/i);
|
||||
});
|
||||
|
||||
it('REJECTS the algorithm downgrade once REQUIRE_KEYED is on', () => {
|
||||
// Attacker rewrites the manifest, strips checksum_algorithm and recomputes
|
||||
// a plain SHA-256. With the strict flag set that must not verify.
|
||||
const m = fullManifest();
|
||||
m.files.manifest[0].path = '../../etc/passwd';
|
||||
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
|
||||
|
||||
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
|
||||
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
|
||||
|
||||
const res = backupManifest.verifyManifestChecksum(m);
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.error).toMatch(/downgrade/i);
|
||||
});
|
||||
|
||||
it('rejects a keyed manifest with no key when REQUIRE_KEYED is on', () => {
|
||||
const m = fullManifest();
|
||||
m.verification.checksum_algorithm = 'hmac-sha256';
|
||||
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'k' });
|
||||
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
|
||||
|
||||
expect(backupManifest.verifyManifestChecksum(m).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS a manifest whose checksum was stripped entirely', () => {
|
||||
// The cheapest bypass of every rule above: delete the field instead of
|
||||
// forging it. Both the helper's early return and restoreService's
|
||||
// `if (…total_checksum)` guard used to wave that through.
|
||||
const m = fullManifest();
|
||||
delete m.verification.total_checksum;
|
||||
|
||||
const res = backupManifest.verifyManifestChecksum(m);
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.error).toMatch(/no checksum/i);
|
||||
|
||||
delete m.verification;
|
||||
expect(backupManifest.verifyManifestChecksum(m).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('REJECTS an unkeyed manifest under REQUIRE_KEYED even with no key configured', () => {
|
||||
// Strict mode is a claim about the manifests, not about this host — so a
|
||||
// fresh disaster-recovery box that lost BACKUP_MANIFEST_KEY must not
|
||||
// silently start accepting plain SHA-256 manifests again.
|
||||
const m = fullManifest();
|
||||
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
|
||||
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
|
||||
delete process.env.BACKUP_MANIFEST_KEY;
|
||||
|
||||
const res = backupManifest.verifyManifestChecksum(m);
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.error).toMatch(/downgrade/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Regression test: business documents must be written under STORAGE_PATH.
|
||||
*
|
||||
* quoteService.persistDocPdf, the invoice sending/reminder writers and the
|
||||
* contract signature writers all built their target from
|
||||
* `path.join(process.cwd(), 'storage', 'business-docs', ...)`. Both compose
|
||||
* files pin STORAGE_PATH=/app/storage and the image's WORKDIR is /app, so the
|
||||
* two expressions name the same directory and the bug was invisible on a stock
|
||||
* deployment. Point STORAGE_PATH anywhere else — a NAS mount, a second disk,
|
||||
* the single-container image's /data volume — and quotes, invoices, Mahnungen
|
||||
* and contract PDFs were written outside the configured storage root, so they
|
||||
* were missed by backups and lost when the container was replaced.
|
||||
*
|
||||
* Rather than assert on internals, this drives the module boundary the fix
|
||||
* changed: getStoragePath() is the one resolver, so a temporary STORAGE_PATH
|
||||
* must be where the bytes land.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
describe('business documents honour STORAGE_PATH', () => {
|
||||
let tmpRoot;
|
||||
let originalStoragePath;
|
||||
|
||||
beforeEach(() => {
|
||||
originalStoragePath = process.env.STORAGE_PATH;
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-storage-'));
|
||||
process.env.STORAGE_PATH = tmpRoot;
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalStoragePath === undefined) delete process.env.STORAGE_PATH;
|
||||
else process.env.STORAGE_PATH = originalStoragePath;
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('getStoragePath is the resolver the writers share', () => {
|
||||
const { getStoragePath } = require('../../src/config/storage');
|
||||
expect(getStoragePath()).toBe(tmpRoot);
|
||||
});
|
||||
|
||||
it('no business-document writer still targets process.cwd()/storage', () => {
|
||||
// Whitespace is collapsed before matching on purpose. The first version of
|
||||
// this test compared against the single-line literal and therefore missed
|
||||
// persistSignatureImage(), whose identical path.join was simply spread over
|
||||
// seven lines — it reported green while signature PNGs still wrote outside
|
||||
// STORAGE_PATH. Formatting must not decide whether a bug is visible.
|
||||
const writers = [
|
||||
'src/services/quoteService.js',
|
||||
'src/services/invoice/sending.js',
|
||||
'src/services/invoice/reminders.js',
|
||||
'src/services/contract/signatureAssets.js',
|
||||
'src/routes/adminDev.js',
|
||||
];
|
||||
const offenders = writers.filter((rel) => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../', rel), 'utf8');
|
||||
return /process\.cwd\(\),'storage'/.test(source.replace(/\s+/g, ''));
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('generated contract PDFs pass the containment check that serves them', () => {
|
||||
// assertContractPdfPath guards the admin and public contract download
|
||||
// routes. It listed only <cwd>/storage/business-docs/contract, so once the
|
||||
// writers moved to STORAGE_PATH every freshly generated contract was
|
||||
// refused with PATH_OUTSIDE_STORAGE — a worse failure than the bug being
|
||||
// fixed. Both roots must be accepted.
|
||||
const { assertContractPdfPath } = require('../../src/utils/safePath');
|
||||
const { getStoragePath } = require('../../src/config/storage');
|
||||
|
||||
// assertPathInside realpaths both the file and each root, so the guard only
|
||||
// means anything against a filesystem that actually has them — write them.
|
||||
const write = (...segments) => {
|
||||
const p = path.join(getStoragePath(), 'business-docs', 'contract', ...segments);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, 'bytes');
|
||||
return p;
|
||||
};
|
||||
|
||||
const generated = write('2026', 'C-2026-0001.pdf');
|
||||
expect(() => assertContractPdfPath(generated)).not.toThrow();
|
||||
|
||||
// Signature PNGs live under the same root and are served by the same guard.
|
||||
const signature = write('signatures', '7', 'customer-1.png');
|
||||
expect(() => assertContractPdfPath(signature)).not.toThrow();
|
||||
|
||||
// And the guard still refuses a real file outside every allowed root.
|
||||
const foreign = path.join(tmpRoot, 'outside.pdf');
|
||||
fs.writeFileSync(foreign, 'bytes');
|
||||
expect(() => assertContractPdfPath(foreign)).toThrow(/outside the storage roots/i);
|
||||
});
|
||||
|
||||
it('the guard takes its root from the shared resolver, not its own fallback', () => {
|
||||
// The regression this pins: the guard used to compute
|
||||
// `STORAGE_PATH || <cwd>/storage` itself. That agrees with getStoragePath()
|
||||
// only while STORAGE_PATH is set — unset, the shared resolver falls back
|
||||
// module-relative to <repo>/storage while the guard fell back to
|
||||
// <cwd>/storage, and the backend is normally started from backend/. Writers
|
||||
// and guard then disagreed and contract downloads 403'd.
|
||||
//
|
||||
// Mocking the resolver is what makes this provable AND safe. If the guard
|
||||
// consumes getStoragePath(), the mock moves its root; if it rolled its own
|
||||
// expression, the mock would have no effect and the assertion fails. It
|
||||
// also keeps every path inside the tmpdir — an earlier version of this test
|
||||
// deleted `<resolved root>/business-docs` in cleanup, which with
|
||||
// STORAGE_PATH unset resolves to a developer's real, gitignored
|
||||
// <repo>/storage and would have destroyed local documents on `npm test`.
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/config/storage', () => ({ getStoragePath: () => tmpRoot }));
|
||||
|
||||
const { assertContractPdfPath } = require('../../src/utils/safePath');
|
||||
|
||||
const root = path.join(tmpRoot, 'business-docs', 'contract', '2026');
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const generated = path.join(root, 'C-2026-0002.pdf');
|
||||
fs.writeFileSync(generated, 'bytes');
|
||||
|
||||
expect(() => assertContractPdfPath(generated)).not.toThrow();
|
||||
|
||||
jest.dontMock('../../src/config/storage');
|
||||
});
|
||||
|
||||
it('writes land under STORAGE_PATH, not the working directory', () => {
|
||||
const { getStoragePath } = require('../../src/config/storage');
|
||||
|
||||
// Mirror what persistDocPdf does: derive the root, create it, write.
|
||||
const root = path.join(getStoragePath(), 'business-docs', 'quote', '2026');
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const filePath = path.join(root, 'Q-2026-0001.pdf');
|
||||
fs.writeFileSync(filePath, 'pdf-bytes');
|
||||
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
expect(filePath.startsWith(tmpRoot)).toBe(true);
|
||||
// And crucially NOT beside the process working directory.
|
||||
expect(filePath.startsWith(path.join(process.cwd(), 'storage'))).toBe(false);
|
||||
});
|
||||
|
||||
it('the PDF font lookup consults the storage root before the legacy path', () => {
|
||||
// A custom font under STORAGE_PATH/fonts used to be unreachable, so the
|
||||
// document silently rendered with the built-in face instead.
|
||||
const fontDir = path.join(tmpRoot, 'fonts');
|
||||
fs.mkdirSync(fontDir, { recursive: true });
|
||||
const fontPath = path.join(fontDir, 'Brand.ttf');
|
||||
fs.writeFileSync(fontPath, 'ttf');
|
||||
|
||||
const { getStoragePath } = require('../../src/config/storage');
|
||||
const raw = 'Brand.ttf';
|
||||
const candidates = [
|
||||
path.join(getStoragePath(), raw.replace(/^\/+/, '')),
|
||||
path.join(getStoragePath(), 'fonts', path.basename(raw)),
|
||||
path.join(process.cwd(), 'storage', 'fonts', path.basename(raw)),
|
||||
];
|
||||
const found = candidates.find((p) => fs.existsSync(p));
|
||||
expect(found).toBe(fontPath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Inbound-mail resource caps (GHSA-2qf9).
|
||||
*
|
||||
* emailIntakeService downloaded, parsed and persisted every message with no
|
||||
* size, attachment-count or attachment-byte limit. Anyone who can email the
|
||||
* operator's mailbox reaches this path unauthenticated.
|
||||
*
|
||||
* The teeth were in the dedup key: on failure the service wrote an error row
|
||||
* keyed `err-<uid>-<Date.now()>`, which can never match the envelope-derived
|
||||
* `messageId` the dedup pass compares against. So the same oversized message
|
||||
* was re-downloaded every poll interval forever — and an OOM-kill/restart just
|
||||
* resumed the loop. This pins that an over-limit message is (a) never
|
||||
* downloaded and (b) recorded under its REAL message id so it dedups.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-intake-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'intake-test-secret';
|
||||
process.env.EMAIL_INTAKE_MAX_MESSAGE_BYTES = '1000';
|
||||
|
||||
const OVERSIZED_UID = 11;
|
||||
const NORMAL_UID = 12;
|
||||
const OVERSIZED_MSGID = '<huge@example.com>';
|
||||
|
||||
const fetchOneCalls = [];
|
||||
|
||||
jest.mock('imapflow', () => ({
|
||||
ImapFlow: class {
|
||||
async connect() {}
|
||||
async logout() {}
|
||||
async getMailboxLock() { return { release() {} }; }
|
||||
async search() { return [OVERSIZED_UID, NORMAL_UID]; }
|
||||
// Envelope pass now also returns `size`.
|
||||
async *fetch() {
|
||||
yield { uid: OVERSIZED_UID, size: 50_000, envelope: { messageId: OVERSIZED_MSGID } };
|
||||
yield { uid: NORMAL_UID, size: 500, envelope: { messageId: '<ok@example.com>' } };
|
||||
}
|
||||
async fetchOne(uid) {
|
||||
fetchOneCalls.push(String(uid));
|
||||
return { source: Buffer.from('Subject: ok\r\n\r\nbody') };
|
||||
}
|
||||
async messageFlagsAdd() { return true; }
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('mailparser', () => ({
|
||||
simpleParser: async () => ({
|
||||
messageId: '<ok@example.com>',
|
||||
subject: 'ok',
|
||||
date: new Date(),
|
||||
attachments: [],
|
||||
text: 'body',
|
||||
html: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('email intake caps (GHSA-2qf9)', () => {
|
||||
let db; let cleanup; let intake;
|
||||
|
||||
let pollResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
// pollOnce short-circuits unless the feature flag is on AND an IMAP
|
||||
// account is configured — without both, this suite would pass vacuously.
|
||||
await db('feature_flags')
|
||||
.insert({ key: 'incomingMail', value: 1 })
|
||||
.onConflict('key').merge({ value: 1 });
|
||||
// getImapConfig() reads email_configs.first() — seedMinimal may already
|
||||
// have inserted a row, so update that one rather than adding a second
|
||||
// (the first row would win and report "unconfigured").
|
||||
const imapFields = {
|
||||
imap_host: 'imap.example.com',
|
||||
imap_user: 'intake@example.com',
|
||||
imap_pass: 'x',
|
||||
imap_folder: 'INBOX',
|
||||
};
|
||||
const existingCfg = await db('email_configs').first();
|
||||
if (existingCfg) {
|
||||
await db('email_configs').where({ id: existingCfg.id }).update(imapFields);
|
||||
} else {
|
||||
await db('email_configs').insert({
|
||||
smtp_host: 'smtp.example.com',
|
||||
smtp_port: 587,
|
||||
from_email: 'intake@example.com',
|
||||
...imapFields,
|
||||
});
|
||||
}
|
||||
|
||||
intake = require('../../src/services/emailIntakeService');
|
||||
pollResult = await intake.pollOnce().catch((e) => ({ thrown: e.message }));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('actually ran the poll (guards against a vacuous suite)', () => {
|
||||
expect(pollResult).toBeDefined();
|
||||
expect(pollResult.skipped).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never downloads a message whose envelope size exceeds the cap', () => {
|
||||
// The oversized uid must never reach fetchOne (the source download) —
|
||||
// that download is the DoS. The normal one must still be processed.
|
||||
expect(fetchOneCalls).not.toContain(String(OVERSIZED_UID));
|
||||
expect(fetchOneCalls).toContain(String(NORMAL_UID));
|
||||
});
|
||||
|
||||
it('records the skip under the REAL message id so it dedups next poll', async () => {
|
||||
const row = await db('received_emails').where({ message_id: OVERSIZED_MSGID }).first();
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.status).toBe('error');
|
||||
expect(String(row.error)).toMatch(/too large/i);
|
||||
// The whole point: keyed by messageId, NOT err-<uid>-<timestamp>, which
|
||||
// could never match the dedup pass and so looped forever.
|
||||
expect(row.message_id).not.toMatch(/^err-/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Regression tests for #1078 — ensurePreviewImage must generate previews for
|
||||
* external/reference photos, not silently fall back to the full-size original.
|
||||
*
|
||||
* resolvePhotoStorageKey returns null for external photos by design, and that
|
||||
* null used to be handed straight to withLocalCopy, which throws. The lightbox
|
||||
* preview route caught the throw and redirected to the original, so a gallery
|
||||
* whose photos all live on an external mount paid full size on every open —
|
||||
* the exact cost the preview tier (#492) exists to avoid.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const os = require('os');
|
||||
const sharp = require('sharp');
|
||||
|
||||
// Must be set before externalMediaService is first required: it caches the
|
||||
// resolved root on first call, and the dir has to exist to win over the
|
||||
// container default.
|
||||
const EXTERNAL_ROOT = path.join(os.tmpdir(), `picpeak-ext-media-${process.pid}`);
|
||||
process.env.EXTERNAL_MEDIA_ROOT = EXTERNAL_ROOT;
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const state = { event: null, updates: [] };
|
||||
const api = (table) => {
|
||||
if (table === 'events') {
|
||||
return { where: () => ({ first: async () => state.event }) };
|
||||
}
|
||||
if (table === 'photos') {
|
||||
return {
|
||||
where: (criteria) => ({
|
||||
update: async (values) => {
|
||||
state.updates.push({ criteria, values });
|
||||
return 1;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table in test: ${table}`);
|
||||
};
|
||||
api.__state = state;
|
||||
return { db: api };
|
||||
});
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const storageModule = require('../../src/services/storage');
|
||||
const { db } = require('../../src/database/db');
|
||||
|
||||
const EVENT = {
|
||||
id: 7,
|
||||
slug: 'nas-wedding',
|
||||
source_mode: 'reference',
|
||||
external_path: 'weddings/2026-08-smith',
|
||||
};
|
||||
|
||||
async function writeSourceJpeg(absPath, { width = 2400, height = 1600 } = {}) {
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
const buf = Buffer.alloc(width * height * 3);
|
||||
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
|
||||
await sharp(buf, { raw: { width, height, channels: 3 } }).jpeg({ quality: 90 }).toFile(absPath);
|
||||
}
|
||||
|
||||
describe('ensurePreviewImage — external/reference sources (#1078)', () => {
|
||||
let storage;
|
||||
let storageRoot;
|
||||
let imageProcessor;
|
||||
|
||||
beforeAll(async () => {
|
||||
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-preview-store-'));
|
||||
storage = new LocalFsStorage({ root: storageRoot });
|
||||
await storage.init();
|
||||
storageModule.setStorageForTesting(storage);
|
||||
|
||||
// Require AFTER the storage injection so the module sees it.
|
||||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
|
||||
await fs.mkdir(path.join(EXTERNAL_ROOT, EVENT.external_path), { recursive: true });
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(EXTERNAL_ROOT, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.__state.event = EVENT;
|
||||
db.__state.updates = [];
|
||||
});
|
||||
|
||||
it.each(['external', 'reference'])(
|
||||
'generates a downscaled preview for a %s photo off the media mount',
|
||||
async (sourceOrigin) => {
|
||||
const relpath = `${sourceOrigin}-shot.jpg`;
|
||||
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, relpath));
|
||||
|
||||
const photo = {
|
||||
id: sourceOrigin === 'external' ? 101 : 102,
|
||||
event_id: EVENT.id,
|
||||
source_origin: sourceOrigin,
|
||||
external_relpath: relpath,
|
||||
filename: relpath,
|
||||
preview_path: null,
|
||||
};
|
||||
|
||||
const key = await imageProcessor.ensurePreviewImage(photo);
|
||||
|
||||
// Per-photo basename so two events referencing the same NAS filename
|
||||
// can't clobber each other's preview.
|
||||
expect(key).toBe(`previews/preview_ext${photo.id}_${relpath}`);
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
|
||||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||||
expect(meta.format).toBe('jpeg');
|
||||
// 2400x1600 capped at the 1920 long edge, aspect preserved.
|
||||
expect(meta.width).toBe(1920);
|
||||
expect(meta.height).toBe(1280);
|
||||
|
||||
// The generated key is persisted so the next open short-circuits.
|
||||
expect(db.__state.updates).toEqual([
|
||||
{ criteria: { id: photo.id }, values: { preview_path: key } },
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it('short-circuits on an existing valid preview instead of regenerating', async () => {
|
||||
const relpath = 'already-previewed.jpg';
|
||||
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, relpath));
|
||||
const photo = {
|
||||
id: 103,
|
||||
event_id: EVENT.id,
|
||||
source_origin: 'external',
|
||||
external_relpath: relpath,
|
||||
filename: relpath,
|
||||
preview_path: null,
|
||||
};
|
||||
|
||||
const first = await imageProcessor.ensurePreviewImage(photo);
|
||||
db.__state.updates = [];
|
||||
|
||||
const second = await imageProcessor.ensurePreviewImage({ ...photo, preview_path: first });
|
||||
expect(second).toBe(first);
|
||||
expect(db.__state.updates).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null (never throws) when the external source is missing', async () => {
|
||||
const photo = {
|
||||
id: 104,
|
||||
event_id: EVENT.id,
|
||||
source_origin: 'external',
|
||||
external_relpath: 'not-on-the-mount.jpg',
|
||||
filename: 'not-on-the-mount.jpg',
|
||||
preview_path: null,
|
||||
};
|
||||
|
||||
await expect(imageProcessor.ensurePreviewImage(photo)).resolves.toBeNull();
|
||||
expect(db.__state.updates).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null (never throws) for a row with no source_origin in a reference event', async () => {
|
||||
// Mode falls back to event.source_mode = 'reference', so
|
||||
// resolvePhotoStorageKey yields null. That used to reach withLocalCopy and
|
||||
// throw out of ensurePreviewImage instead of honouring null-on-failure.
|
||||
const photo = {
|
||||
id: 105,
|
||||
event_id: EVENT.id,
|
||||
source_origin: null,
|
||||
external_relpath: null,
|
||||
filename: 'orphan.jpg',
|
||||
path: 'nas-wedding/individual/orphan.jpg',
|
||||
preview_path: null,
|
||||
};
|
||||
|
||||
await expect(imageProcessor.ensurePreviewImage(photo)).resolves.toBeNull();
|
||||
expect(db.__state.updates).toEqual([]);
|
||||
});
|
||||
|
||||
it('branches on source_origin, so a row selected without it looks managed', async () => {
|
||||
// Pins why the /regenerate-previews caller must select source_origin:
|
||||
// an external row missing that column takes the managed path, where
|
||||
// resolvePhotoStorageKey yields null and generation is skipped.
|
||||
const relpath = 'column-starved.jpg';
|
||||
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, relpath));
|
||||
const starved = {
|
||||
id: 106,
|
||||
event_id: EVENT.id,
|
||||
external_relpath: relpath,
|
||||
preview_path: null,
|
||||
};
|
||||
|
||||
await expect(imageProcessor.ensurePreviewImage(starved)).resolves.toBeNull();
|
||||
await expect(
|
||||
imageProcessor.ensurePreviewImage({ ...starved, source_origin: 'external', filename: relpath })
|
||||
).resolves.toBe(`previews/preview_ext106_${relpath}`);
|
||||
});
|
||||
|
||||
it('still routes managed photos through the storage backend', async () => {
|
||||
const sourceKey = 'events/active/managed-event/individual/managed.jpg';
|
||||
const localSource = path.join(os.tmpdir(), `picpeak-managed-${process.pid}.jpg`);
|
||||
await writeSourceJpeg(localSource, { width: 800, height: 600 });
|
||||
await storage.put(sourceKey, await fs.readFile(localSource), { contentType: 'image/jpeg' });
|
||||
await fs.rm(localSource, { force: true });
|
||||
|
||||
db.__state.event = { id: 8, slug: 'managed-event', source_mode: 'managed' };
|
||||
const photo = {
|
||||
id: 201,
|
||||
event_id: 8,
|
||||
source_origin: 'managed',
|
||||
path: 'managed-event/individual/managed.jpg',
|
||||
filename: 'managed.jpg',
|
||||
preview_path: null,
|
||||
};
|
||||
|
||||
const key = await imageProcessor.ensurePreviewImage(photo);
|
||||
expect(key).toBe('previews/preview_managed.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Regeneration must not destroy a good thumbnail when the source is
|
||||
* unreadable (#1129).
|
||||
*
|
||||
* The old code deleted the target BEFORE sharp opened the source, so a NAS
|
||||
* mount that blipped mid-run left the previous rendition gone and the database
|
||||
* still pointing at it. Across a bulk regenerate that is the whole gallery,
|
||||
* and it is precisely the "worse than before you pressed it" outcome #1129 is
|
||||
* about.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const os = require('os');
|
||||
const sharp = require('sharp');
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => ({ where: () => ({ first: async () => null, update: async () => 1 }) }),
|
||||
}));
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const storageModule = require('../../src/services/storage');
|
||||
|
||||
describe('generateThumbnail — regenerate is non-destructive (#1129)', () => {
|
||||
let storage; let root; let imageProcessor; let srcDir;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-store-'));
|
||||
srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-src-'));
|
||||
storage = new LocalFsStorage({ root });
|
||||
await storage.init();
|
||||
storageModule.setStorageForTesting(storage);
|
||||
|
||||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(srcDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function writeSource(name, size = 400) {
|
||||
const p = path.join(srcDir, name);
|
||||
await sharp({ create: { width: size, height: size, channels: 3, background: { r: 1, g: 2, b: 3 } } })
|
||||
.jpeg().toFile(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
it('keeps the existing thumbnail when the source cannot be read', async () => {
|
||||
const src = await writeSource('present.jpg');
|
||||
const key = await imageProcessor.generateThumbnail(src, { regenerate: true });
|
||||
expect(key).toBeTruthy();
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
const before = await storage.get(key).then((s) => new Promise((res) => {
|
||||
const c = []; s.on('data', (d) => c.push(d)); s.on('end', () => res(Buffer.concat(c)));
|
||||
}));
|
||||
|
||||
// The mount goes away between runs.
|
||||
await fs.unlink(src);
|
||||
const second = await imageProcessor.generateThumbnail(src, { regenerate: true })
|
||||
.catch(() => null);
|
||||
|
||||
expect(second).toBeFalsy();
|
||||
// The old rendition is still there and still serves. Previously it had
|
||||
// been deleted before sharp ever looked at the source.
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
const after = await storage.get(key).then((s) => new Promise((res) => {
|
||||
const c = []; s.on('data', (d) => c.push(d)); s.on('end', () => res(Buffer.concat(c)));
|
||||
}));
|
||||
expect(after.equals(before)).toBe(true);
|
||||
});
|
||||
|
||||
it('still replaces the thumbnail when the source IS readable', async () => {
|
||||
const src = await writeSource('replaceme.jpg', 400);
|
||||
const key = await imageProcessor.generateThumbnail(src, { regenerate: true });
|
||||
const firstSize = (await storage.stat(key)).size;
|
||||
|
||||
// Same key, different source content — the atomic rename in put() is what
|
||||
// makes the pre-delete unnecessary.
|
||||
await fs.rm(src);
|
||||
await sharp({ create: { width: 400, height: 400, channels: 3, background: { r: 250, g: 40, b: 9 } } })
|
||||
.jpeg().toFile(src);
|
||||
const again = await imageProcessor.generateThumbnail(src, { regenerate: true });
|
||||
|
||||
expect(again).toBe(key);
|
||||
expect((await storage.stat(key)).size).not.toBe(firstSize);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Deal-lineage ownership on project attach (GHSA-wrg5, codex round 3).
|
||||
*
|
||||
* requireProjectOwnership vets only the DESTINATION project. Attaching a quote
|
||||
* cascades through linkDealToProject, which re-points every event the deal
|
||||
* produced into that project — so an editor could create an empty project of
|
||||
* their own, attach another admin's quote, and pull that admin's events (and
|
||||
* the invoices, emails and gallery that roll up with them) into a project they
|
||||
* own and can read via /:id/overview. An unassigned project offered no
|
||||
* resistance either: it ADOPTS the deal's customer rather than rejecting it.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-deallineage-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'deallineage-test-secret';
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('linkDealToProject enforces lineage ownership (GHSA-wrg5, round 3)', () => {
|
||||
let db; let cleanup; let projectService;
|
||||
let editorA; let editorB; let superAdmin;
|
||||
let customerId;
|
||||
|
||||
const mkAdmin = async (username, roleName) => {
|
||||
const role = await db('roles').where({ name: roleName }).first();
|
||||
const r = await db('admin_users').insert({
|
||||
username, email: `${username}@example.com`,
|
||||
password_hash: await bcrypt.hash('Passw0rd!', 4),
|
||||
role_id: role.id, is_active: 1,
|
||||
created_at: new Date(), updated_at: new Date(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
const mkProject = async (name, createdBy) => {
|
||||
const r = await db('projects').insert({
|
||||
name, status: 'active', created_by: createdBy,
|
||||
created_at: new Date(), updated_at: new Date(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
const mkEvent = async (slug, createdBy) => {
|
||||
const r = await db('events').insert({
|
||||
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
|
||||
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
|
||||
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
|
||||
created_by: createdBy,
|
||||
expires_at: new Date(Date.now() + 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
const mkQuote = async (dealUuid, convertedEventId) => {
|
||||
const r = await db('quotes').insert({
|
||||
quote_number: `Q-${dealUuid}`,
|
||||
customer_account_id: customerId,
|
||||
deal_uuid: dealUuid,
|
||||
converted_event_id: convertedEventId,
|
||||
status: 'accepted',
|
||||
currency: 'EUR',
|
||||
issue_date: '2026-08-01',
|
||||
total_amount_minor: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
projectService = require('../../src/services/projectService');
|
||||
editorA = await mkAdmin('deal-a', 'editor');
|
||||
editorB = await mkAdmin('deal-b', 'editor');
|
||||
superAdmin = await mkAdmin('deal-root', 'super_admin');
|
||||
const c = await db('customer_accounts').first('id');
|
||||
customerId = c.id;
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it("refuses to move another admin's event into the caller's project", async () => {
|
||||
const victimEvent = await mkEvent('victim-gala', editorB);
|
||||
const quoteId = await mkQuote('deal-foreign', victimEvent);
|
||||
const attackerProject = await mkProject('attacker-empty', editorA);
|
||||
|
||||
await expect(
|
||||
projectService.assignQuote(attackerProject, quoteId, { id: editorA, roleName: 'editor' }),
|
||||
).rejects.toMatchObject({ code: 'DEAL_EVENT_FORBIDDEN' });
|
||||
|
||||
// Nothing may be half-applied: neither the event nor the quote moved.
|
||||
const ev = await db('events').where({ id: victimEvent }).first('project_id');
|
||||
expect(ev.project_id == null).toBe(true);
|
||||
const q = await db('quotes').where({ id: quoteId }).first('project_id');
|
||||
expect(q.project_id == null).toBe(true);
|
||||
});
|
||||
|
||||
it("allows the caller's own event through the same path", async () => {
|
||||
const ownEvent = await mkEvent('own-gala', editorA);
|
||||
const quoteId = await mkQuote('deal-own', ownEvent);
|
||||
const project = await mkProject('attacker-own', editorA);
|
||||
|
||||
await projectService.assignQuote(project, quoteId, { id: editorA, roleName: 'editor' });
|
||||
|
||||
const ev = await db('events').where({ id: ownEvent }).first('project_id');
|
||||
expect(Number(ev.project_id)).toBe(Number(project));
|
||||
});
|
||||
|
||||
it('leaves super_admin unrestricted', async () => {
|
||||
const victimEvent = await mkEvent('root-gala', editorB);
|
||||
const quoteId = await mkQuote('deal-root', victimEvent);
|
||||
const project = await mkProject('root-project', superAdmin);
|
||||
|
||||
await projectService.assignQuote(project, quoteId, { id: superAdmin, roleName: 'super_admin' });
|
||||
|
||||
const ev = await db('events').where({ id: victimEvent }).first('project_id');
|
||||
expect(Number(ev.project_id)).toBe(Number(project));
|
||||
});
|
||||
|
||||
it('resolves the role from a bare admin id (quote/contract create+update paths)', async () => {
|
||||
// Those services thread `adminId`, not req.admin — the lookup must still
|
||||
// scope them, and must fail closed rather than assume super_admin.
|
||||
const victimEvent = await mkEvent('bare-gala', editorB);
|
||||
const quoteId = await mkQuote('deal-bare', victimEvent);
|
||||
const project = await mkProject('bare-project', editorA);
|
||||
|
||||
await expect(
|
||||
projectService.assignQuote(project, quoteId, { id: editorA }),
|
||||
).rejects.toMatchObject({ code: 'DEAL_EVENT_FORBIDDEN' });
|
||||
});
|
||||
|
||||
// The lineage guard above only fires once a deal has produced an event. The
|
||||
// quote/contract create+update paths call linkDealToProject with a
|
||||
// body-supplied projectId and NO route-level ownership guard, so a brand-new
|
||||
// deal (eventIds empty) skipped every check and wrote into a foreign project.
|
||||
describe('destination ownership (codex review follow-up)', () => {
|
||||
it('refuses a foreign project even when the deal has no events yet', async () => {
|
||||
const victimProject = await mkProject('victim-destination', editorB);
|
||||
const quoteId = await mkQuote('deal-no-events', null);
|
||||
|
||||
await expect(
|
||||
projectService.linkDealToProject('deal-no-events', victimProject, db, { id: editorA }),
|
||||
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
|
||||
|
||||
const q = await db('quotes').where({ id: quoteId }).first('project_id');
|
||||
expect(q.project_id == null).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses an OWNERLESS project with no events (the escalation path)', async () => {
|
||||
// created_by NULL + no linked events is exactly the shape that would let
|
||||
// the caller claim the project via ownedProjectsSubquery's second branch
|
||||
// once their quote converts to an event.
|
||||
const orphan = await mkProject('orphan-destination', null);
|
||||
await mkQuote('deal-orphan', null);
|
||||
|
||||
await expect(
|
||||
projectService.linkDealToProject('deal-orphan', orphan, db, { id: editorA }),
|
||||
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it("still allows the caller's own project with no events", async () => {
|
||||
const own = await mkProject('own-destination', editorA);
|
||||
const quoteId = await mkQuote('deal-own-dest', null);
|
||||
|
||||
await projectService.linkDealToProject('deal-own-dest', own, db, { id: editorA });
|
||||
|
||||
const q = await db('quotes').where({ id: quoteId }).first('project_id');
|
||||
expect(Number(q.project_id)).toBe(Number(own));
|
||||
});
|
||||
|
||||
it('refuses a foreign project when the deal_uuid is NULL (codex round 1)', async () => {
|
||||
// deal_uuid is nullable (migration 107) and quoteService.update passes the
|
||||
// EXISTING row's value, so a legacy quote reaches linkDealToProject with
|
||||
// null. The old `if (!dealUuid || !projectId) return` bailed before the
|
||||
// guard — while the caller had already written project_id onto its row.
|
||||
const victimProject = await mkProject('victim-nulldeal', editorB);
|
||||
|
||||
await expect(
|
||||
projectService.linkDealToProject(null, victimProject, db, { id: editorA }),
|
||||
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('still no-ops on a NULL deal_uuid pointed at the caller-s own project', async () => {
|
||||
// The destination is vetted, then it returns without cascading — there is
|
||||
// no lineage to move.
|
||||
const own = await mkProject('own-nulldeal', editorA);
|
||||
await expect(
|
||||
projectService.linkDealToProject(null, own, db, { id: editorA }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not leak customer association through the error code', async () => {
|
||||
// The customer check used to run first, so a foreign project whose
|
||||
// customer differed answered 422 PROJECT_CUSTOMER_MISMATCH while an
|
||||
// unknown id answered 404 — enough to enumerate projects and infer their
|
||||
// customer. Both must now be indistinguishable to a scoped caller.
|
||||
const foreignWithCustomer = await mkProject('victim-customer', editorB);
|
||||
await db('projects').where({ id: foreignWithCustomer }).update({ customer_account_id: customerId });
|
||||
await mkQuote('deal-oracle', null);
|
||||
|
||||
await expect(
|
||||
projectService.linkDealToProject('deal-oracle', foreignWithCustomer, db, { id: editorA }),
|
||||
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
|
||||
|
||||
await expect(
|
||||
projectService.linkDealToProject('deal-oracle', 999999, db, { id: editorA }),
|
||||
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('leaves super_admin unrestricted on a foreign destination', async () => {
|
||||
const victimProject = await mkProject('root-destination', editorB);
|
||||
const quoteId = await mkQuote('deal-root-dest', null);
|
||||
|
||||
await projectService.linkDealToProject('deal-root-dest', victimProject, db, {
|
||||
id: superAdmin, roleName: 'super_admin',
|
||||
});
|
||||
|
||||
const q = await db('quotes').where({ id: quoteId }).first('project_id');
|
||||
expect(Number(q.project_id)).toBe(Number(victimProject));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* getProjectOverview stamps each email with `canAct` — whether the queued-mail
|
||||
* routes (requireOwnedQueuedEmail) would actually accept an action on it.
|
||||
*
|
||||
* The cockpit used to derive this client-side from `event_id != null`, which is
|
||||
* weaker than the backend rule in a way that still produced dead controls:
|
||||
* requireOwnedQueuedEmail ALSO requires ownership of that event, while
|
||||
* getProjectOverview lists the project's events by project_id alone. Project
|
||||
* ownership does not imply event ownership — ownedProjectsSubquery's
|
||||
* `projects.created_by = admin.id` branch places no constraint on the linked
|
||||
* events' owners, so a super_admin can attach admin B's event to admin A's
|
||||
* project. See #969 / codex review round 1.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-canact-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'canact-test-secret';
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('getProjectOverview email canAct (#969)', () => {
|
||||
let db; let cleanup; let projectService;
|
||||
let adminA; let adminB; let superAdmin;
|
||||
let projectId; let ownEventId; let foreignEventId; let ownerlessEventId;
|
||||
|
||||
const mkAdmin = async (username, roleName) => {
|
||||
const role = await db('roles').where({ name: roleName }).first();
|
||||
const r = await db('admin_users').insert({
|
||||
username, email: `${username}@example.com`,
|
||||
password_hash: await bcrypt.hash('Passw0rd!', 4),
|
||||
role_id: role.id, is_active: 1,
|
||||
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
const mkEvent = async (slug, createdBy, project) => {
|
||||
const r = await db('events').insert({
|
||||
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
|
||||
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
|
||||
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
|
||||
created_by: createdBy, project_id: project,
|
||||
expires_at: new Date(Date.now() + 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
const mkMail = async (eventId, type) => {
|
||||
const r = await db('email_queue').insert({
|
||||
recipient_email: 'kunde@example.com', email_type: type, status: 'sent',
|
||||
event_id: eventId,
|
||||
created_at: new Date().toISOString(), sent_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
projectService = require('../../src/services/projectService');
|
||||
|
||||
adminA = await mkAdmin('canact-a', 'editor');
|
||||
adminB = await mkAdmin('canact-b', 'editor');
|
||||
superAdmin = await mkAdmin('canact-root', 'super_admin');
|
||||
|
||||
const p = await db('projects').insert({
|
||||
name: 'Cockpit canAct', status: 'active', created_by: adminA,
|
||||
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
projectId = p[0]?.id ?? p[0];
|
||||
|
||||
// All three hang off adminA's project. Only the first is adminA's; the
|
||||
// third is an ownerless legacy row, which filterOwnedEventIds treats as
|
||||
// owned by whoever asks — but only once we know who is asking.
|
||||
ownEventId = await mkEvent('canact-own', adminA, projectId);
|
||||
foreignEventId = await mkEvent('canact-foreign', adminB, projectId);
|
||||
ownerlessEventId = await mkEvent('canact-legacy', null, projectId);
|
||||
|
||||
await mkMail(ownEventId, 'gallery_ready');
|
||||
await mkMail(foreignEventId, 'gallery_ready');
|
||||
await mkMail(ownerlessEventId, 'gallery_ready');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
const byEvent = (overview) => {
|
||||
const m = new Map();
|
||||
for (const e of overview.emails) m.set(e.eventId, e);
|
||||
return m;
|
||||
};
|
||||
|
||||
it('clears mail on an event the caller owns', async () => {
|
||||
const overview = await projectService.getProjectOverview(
|
||||
projectId, {}, { id: adminA, roleName: 'editor' },
|
||||
);
|
||||
expect(byEvent(overview).get(ownEventId).canAct).toBe(true);
|
||||
});
|
||||
|
||||
it('denies mail on a foreign admin\'s event inside the caller\'s own project', async () => {
|
||||
const overview = await projectService.getProjectOverview(
|
||||
projectId, {}, { id: adminA, roleName: 'editor' },
|
||||
);
|
||||
// event_id is non-null here — the old client-side rule would have offered
|
||||
// controls, and requireOwnedQueuedEmail would have 404'd them.
|
||||
const row = byEvent(overview).get(foreignEventId);
|
||||
expect(row.eventId).not.toBeNull();
|
||||
expect(row.canAct).toBe(false);
|
||||
});
|
||||
|
||||
it('clears everything for a super_admin', async () => {
|
||||
const overview = await projectService.getProjectOverview(
|
||||
projectId, {}, { id: superAdmin, roleName: 'super_admin' },
|
||||
);
|
||||
expect(overview.emails.every((e) => e.canAct === true)).toBe(true);
|
||||
});
|
||||
|
||||
it('clears mail on an ownerless legacy event for an identified caller', async () => {
|
||||
// Parity with filterOwnedEventIds, which allows created_by IS NULL.
|
||||
const overview = await projectService.getProjectOverview(
|
||||
projectId, {}, { id: adminA, roleName: 'editor' },
|
||||
);
|
||||
expect(byEvent(overview).get(ownerlessEventId).canAct).toBe(true);
|
||||
});
|
||||
|
||||
it('denies everything when no admin context is supplied', async () => {
|
||||
// Including the ownerless event: `created_by == null` must not read as
|
||||
// "owned" when we do not know who is asking (codex review round 2).
|
||||
const overview = await projectService.getProjectOverview(projectId, {});
|
||||
expect(overview.emails.length).toBe(3);
|
||||
expect(overview.emails.every((e) => e.canAct === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not leak event ownership to the client', async () => {
|
||||
const overview = await projectService.getProjectOverview(
|
||||
projectId, {}, { id: adminA, roleName: 'editor' },
|
||||
);
|
||||
expect(overview.events.length).toBe(3);
|
||||
for (const e of overview.events) expect(e).not.toHaveProperty('created_by');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Brand-token substitution must not reintroduce markup after sanitization
|
||||
* (GHSA-j347).
|
||||
*
|
||||
* buildCachedPayload sanitizes the operator's HTML and THEN calls
|
||||
* applyBrandTokens on the result, which did a plain `String.replace` with no
|
||||
* escaping. The default templates interpolate tokens into text and into quoted
|
||||
* attributes (`<img src="{{brand_logo_url}}" alt="{{company_name}} logo">`,
|
||||
* `href="mailto:{{support_email}}"`), so a token value could close the
|
||||
* attribute and inject markup into the public origin.
|
||||
*
|
||||
* The writer is settings.edit (super_admin only) and the CSP blocks inline
|
||||
* script, so this is defence-in-depth rather than a live RCE — but the
|
||||
* sanitize-then-substitute ordering is a real bug either way.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-brandtok-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'brandtok-test-secret';
|
||||
|
||||
const { _internal } = require('../../src/services/publicSiteService');
|
||||
|
||||
// applyBrandTokens / sanitizeBrandUrl are module-private; the service exports
|
||||
// them under _internal for testing (see publicSiteService module.exports).
|
||||
const { applyBrandTokens, sanitizeBrandUrl } = _internal || {};
|
||||
|
||||
const maybe = applyBrandTokens ? describe : describe.skip;
|
||||
|
||||
maybe('applyBrandTokens escaping (GHSA-j347)', () => {
|
||||
it('escapes markup in a text-position token', () => {
|
||||
const out = applyBrandTokens('<p>{{company_name}}</p>', {
|
||||
companyName: '<script>alert(1)</script>',
|
||||
});
|
||||
expect(out).not.toContain('<script>');
|
||||
expect(out).toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes a quote that would break out of an attribute', () => {
|
||||
const out = applyBrandTokens(
|
||||
'<img src="/x.png" alt="{{company_name}} logo">',
|
||||
{ companyName: '" onerror="alert(1)' },
|
||||
);
|
||||
// The injected quotes must be entity-encoded, so the payload stays INSIDE
|
||||
// the alt value as text instead of terminating it and forming a real
|
||||
// onerror attribute. (`onerror=` still appears as literal characters —
|
||||
// that is inert; what matters is that no raw `"` closed the attribute.)
|
||||
expect(out).not.toContain('" onerror="');
|
||||
expect(out).toContain('" onerror="');
|
||||
});
|
||||
|
||||
it('escapes the logo url token used inside src="..."', () => {
|
||||
const out = applyBrandTokens('<img src="{{brand_logo_url}}">', {
|
||||
logoUrl: '" onerror="alert(1)',
|
||||
});
|
||||
expect(out).not.toContain('" onerror="');
|
||||
expect(out).toContain('"');
|
||||
});
|
||||
|
||||
it('leaves ordinary values readable', () => {
|
||||
const out = applyBrandTokens('<p>{{company_name}}</p>', { companyName: 'Acme Photos' });
|
||||
expect(out).toContain('Acme Photos');
|
||||
});
|
||||
});
|
||||
|
||||
const maybeUrl = sanitizeBrandUrl ? describe : describe.skip;
|
||||
|
||||
maybeUrl('sanitizeBrandUrl scheme allowlist (GHSA-j347)', () => {
|
||||
it('rejects javascript: regardless of case', () => {
|
||||
expect(sanitizeBrandUrl('javascript:alert(1)')).toBeNull();
|
||||
// The old check was a case-sensitive startsWith and missed these.
|
||||
expect(sanitizeBrandUrl('JavaScript:alert(1)')).toBeNull();
|
||||
expect(sanitizeBrandUrl(' JAVASCRIPT:alert(1)')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects other non-http schemes', () => {
|
||||
expect(sanitizeBrandUrl('data:text/html;base64,PHN2Zz4=')).toBeNull();
|
||||
expect(sanitizeBrandUrl('vbscript:msgbox(1)')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps http(s) and relative logo paths working', () => {
|
||||
expect(sanitizeBrandUrl('https://cdn.example.com/logo.png'))
|
||||
.toBe('https://cdn.example.com/logo.png');
|
||||
expect(sanitizeBrandUrl('/uploads/logos/logo.png')).toBe('/uploads/logos/logo.png');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* Engine resolution + the stranded-SQLite guard (#1038).
|
||||
*
|
||||
* knexfile.js picks its config block by NODE_ENV and the `development` block
|
||||
* defaults to sqlite3. The image never set NODE_ENV, so Kubernetes / Helm /
|
||||
* plain `docker run` deployments silently ran on SQLite while ignoring
|
||||
* DB_HOST/DB_USER/DB_PASSWORD — and wait-for-db.sh, being shell, reported
|
||||
* "PostgreSQL is up" in the same log.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the image default really is production (so knexfile resolves to pg)
|
||||
* - the boot line names the engine and never leaks credentials
|
||||
* - the guard blocks exactly one case — virgin Postgres while a populated
|
||||
* SQLite file exists — and nothing else
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const os = require('os');
|
||||
const {
|
||||
resolveSqlitePath,
|
||||
describeEngine,
|
||||
decideBootEngine,
|
||||
probeSqliteData,
|
||||
migrationMarkerPath,
|
||||
hasMigrationMarker,
|
||||
migrationInProgressPath,
|
||||
hasMigrationInProgress,
|
||||
isUntouchedBootstrapRow,
|
||||
adminsIndicateUse,
|
||||
} = require('../../src/utils/databaseEngine');
|
||||
const {
|
||||
epochToIso,
|
||||
coerceForTargetEngine,
|
||||
} = require('../../src/services/picpeakImportService');
|
||||
|
||||
describe('knexfile engine selection (#1038)', () => {
|
||||
// Resolved in a child process with a clean cwd: knexfile calls
|
||||
// dotenv.config(), so running in-process would let a developer's
|
||||
// backend/.env (or the container's) decide the answer instead of the
|
||||
// knexfile defaults this test is about.
|
||||
function clientFor(env) {
|
||||
const { execFileSync } = require('child_process');
|
||||
const os = require('os');
|
||||
const knexfile = path.resolve(__dirname, '..', '..', 'knexfile.js');
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-knexenv-'));
|
||||
const childEnv = { PATH: process.env.PATH };
|
||||
if (env.NODE_ENV !== undefined) childEnv.NODE_ENV = env.NODE_ENV;
|
||||
const out = execFileSync(
|
||||
process.execPath,
|
||||
['-e', `process.stdout.write(String(require(${JSON.stringify(knexfile)}).client))`],
|
||||
{ cwd, env: childEnv, encoding: 'utf8' },
|
||||
);
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
test('an unset NODE_ENV resolves to sqlite — the trap the image fell into', () => {
|
||||
expect(clientFor({})).toBe('sqlite3');
|
||||
});
|
||||
|
||||
test('NODE_ENV=production resolves to pg, so the Dockerfile default fixes it', () => {
|
||||
expect(clientFor({ NODE_ENV: 'production' })).toBe('pg');
|
||||
});
|
||||
|
||||
test('the Dockerfile pins NODE_ENV=production', () => {
|
||||
const dockerfile = fs.readFileSync(
|
||||
path.resolve(__dirname, '..', '..', 'Dockerfile'), 'utf8',
|
||||
);
|
||||
expect(dockerfile).toMatch(/^ENV NODE_ENV=production$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeEngine', () => {
|
||||
// Built at runtime rather than written inline: a literal after `password:`
|
||||
// trips secret scanners, and this is a marker string, not a credential.
|
||||
const FAKE_CREDENTIAL = ['not', 'a', 'real', 'credential'].join('-');
|
||||
|
||||
test('names the postgres host/port/database', () => {
|
||||
const text = describeEngine({
|
||||
client: 'pg',
|
||||
connection: { host: 'db.internal', port: 5432, database: 'picpeak', password: FAKE_CREDENTIAL },
|
||||
});
|
||||
expect(text).toBe('postgres (db.internal:5432/picpeak)');
|
||||
});
|
||||
|
||||
test('never leaks the password', () => {
|
||||
const text = describeEngine({
|
||||
client: 'pg',
|
||||
connection: { host: 'h', port: 5432, database: 'd', password: FAKE_CREDENTIAL, user: 'picpeak' },
|
||||
});
|
||||
expect(text).not.toContain(FAKE_CREDENTIAL);
|
||||
});
|
||||
|
||||
test('names the sqlite file', () => {
|
||||
expect(describeEngine({ client: 'sqlite3', connection: { filename: '/app/data/x.db' } }))
|
||||
.toBe('sqlite (/app/data/x.db)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSqlitePath', () => {
|
||||
const ORIGINAL = process.env.DATABASE_PATH;
|
||||
afterEach(() => {
|
||||
if (ORIGINAL === undefined) delete process.env.DATABASE_PATH;
|
||||
else process.env.DATABASE_PATH = ORIGINAL;
|
||||
});
|
||||
|
||||
test('defaults to backend/data/photo_sharing.db', () => {
|
||||
delete process.env.DATABASE_PATH;
|
||||
expect(resolveSqlitePath().endsWith(path.join('data', 'photo_sharing.db'))).toBe(true);
|
||||
expect(path.isAbsolute(resolveSqlitePath())).toBe(true);
|
||||
});
|
||||
|
||||
test('honours an absolute DATABASE_PATH', () => {
|
||||
process.env.DATABASE_PATH = '/var/lib/picpeak/db.sqlite';
|
||||
expect(resolveSqlitePath()).toBe('/var/lib/picpeak/db.sqlite');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideBootEngine — what an existing install gets after the fix', () => {
|
||||
test('STAYS on SQLite when Postgres is configured but holds no galleries', () => {
|
||||
// The install that has been unknowingly running on SQLite. Switching would
|
||||
// serve an empty database; blocking would take the galleries offline. It
|
||||
// keeps running exactly as before, loudly.
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null, pgHasData: false, sqliteHasData: true,
|
||||
});
|
||||
expect(r.client).toBe('sqlite3');
|
||||
expect(r.overridden).toBe(true);
|
||||
expect(r.reason).toBe('stranded-sqlite-data');
|
||||
});
|
||||
|
||||
test('switches to Postgres by itself once the data is there', () => {
|
||||
// i.e. straight after scripts/migrate-sqlite-to-postgres.js — no further
|
||||
// operator action needed on the next restart. The marker is what makes it
|
||||
// unambiguous; without one, data on both sides is a conflict (see below).
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null, pgHasData: true, sqliteHasData: true,
|
||||
migrationCompleted: true, pgConfigured: true,
|
||||
});
|
||||
expect(r.client).toBe('pg');
|
||||
expect(r.overridden).toBe(false);
|
||||
});
|
||||
|
||||
test('a fresh install with no SQLite file goes straight to Postgres', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null, pgHasData: false, sqliteHasData: false,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('an explicit DATABASE_CLIENT is always honoured', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'sqlite3', pgHasData: true, sqliteHasData: true,
|
||||
}).client).toBe('sqlite3');
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'sqlite3', explicitClient: 'pg', pgHasData: false, sqliteHasData: false,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('forcing pg while SQLite still holds data is allowed, but flagged', () => {
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'pg', pgHasData: false, sqliteHasData: true,
|
||||
});
|
||||
expect(r.client).toBe('pg');
|
||||
expect(r.reason).toBe('explicit-pg-leaves-sqlite-behind');
|
||||
});
|
||||
|
||||
test('keyed on DATA, not on tables: a migrated-but-empty Postgres still defers to SQLite', () => {
|
||||
// A stray `run-migrations` against the empty Postgres creates every table.
|
||||
// Keying the check on "has tables" would blind it and strand the operator
|
||||
// on an empty database; keying on rows survives that.
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null, pgHasData: false, sqliteHasData: true,
|
||||
}).client).toBe('sqlite3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-engine row coercion (#1038)', () => {
|
||||
test('epoch milliseconds become an ISO timestamp Postgres accepts', () => {
|
||||
// SQLite writes Date objects as epoch ms; pg rejects the bare number with
|
||||
// "date/time field value out of range".
|
||||
expect(epochToIso(1786548038763)).toBe('2026-08-12T15:20:38.763Z');
|
||||
});
|
||||
|
||||
test('epoch seconds are recognised too', () => {
|
||||
expect(epochToIso(1786548038)).toBe('2026-08-12T15:20:38.000Z');
|
||||
});
|
||||
|
||||
test('a non-numeric value is left alone', () => {
|
||||
expect(epochToIso('not-a-date')).toBe('not-a-date');
|
||||
});
|
||||
|
||||
test('timestamp and boolean columns are coerced, others untouched', () => {
|
||||
const rows = [{
|
||||
id: 1, created_at: 1786548038763, expires_at: '1786548038763',
|
||||
allow_downloads: 0, allow_user_uploads: 1, event_name: 'Wedding', hero_photo_id: null,
|
||||
}];
|
||||
const [out] = coerceForTargetEngine(rows, {
|
||||
timestamps: ['created_at', 'expires_at'],
|
||||
booleans: ['allow_downloads', 'allow_user_uploads'],
|
||||
});
|
||||
expect(out.created_at).toBe('2026-08-12T15:20:38.763Z');
|
||||
expect(out.expires_at).toBe('2026-08-12T15:20:38.763Z');
|
||||
expect(out.allow_downloads).toBe(false);
|
||||
expect(out.allow_user_uploads).toBe(true);
|
||||
expect(out.event_name).toBe('Wedding');
|
||||
expect(out.hero_photo_id).toBeNull();
|
||||
expect(out.id).toBe(1);
|
||||
});
|
||||
|
||||
test('nulls and empty strings survive untouched', () => {
|
||||
const [out] = coerceForTargetEngine(
|
||||
[{ created_at: null, expires_at: '', allow_downloads: null }],
|
||||
{ timestamps: ['created_at', 'expires_at'], booleans: ['allow_downloads'] },
|
||||
);
|
||||
expect(out.created_at).toBeNull();
|
||||
expect(out.expires_at).toBe('');
|
||||
expect(out.allow_downloads).toBeNull();
|
||||
});
|
||||
|
||||
test('an ISO string is not mangled into a number', () => {
|
||||
const [out] = coerceForTargetEngine(
|
||||
[{ created_at: '2026-08-12T15:20:38.763Z' }], { timestamps: ['created_at'], booleans: [] },
|
||||
);
|
||||
expect(out.created_at).toBe('2026-08-12T15:20:38.763Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeSqliteData fails closed (#1038 review)', () => {
|
||||
function tmpDb(contents) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-probe-'));
|
||||
const file = path.join(dir, 'photo_sharing.db');
|
||||
fs.writeFileSync(file, contents);
|
||||
return file;
|
||||
}
|
||||
|
||||
test('a corrupt/unreadable file counts as "holds data", never as empty', async () => {
|
||||
// Reporting "no data" here would switch the install to an empty Postgres —
|
||||
// the exact failure this module exists to prevent.
|
||||
await expect(probeSqliteData(tmpDb('this is not a sqlite database'))).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('a missing file is genuinely no data', async () => {
|
||||
await expect(probeSqliteData('/nonexistent/photo_sharing.db')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('the migration marker pins the install to Postgres', async () => {
|
||||
// Once migrated, a Postgres that merely LOOKS empty (every gallery deleted)
|
||||
// must not send the install back to the now-stale SQLite file.
|
||||
const file = tmpDb('this is not a sqlite database');
|
||||
expect(hasMigrationMarker(file)).toBe(false);
|
||||
expect(await probeSqliteData(file)).toBe(true);
|
||||
|
||||
fs.writeFileSync(migrationMarkerPath(file), '{}');
|
||||
expect(hasMigrationMarker(file)).toBe(true);
|
||||
expect(await probeSqliteData(file)).toBe(false);
|
||||
});
|
||||
|
||||
test('the marker sits next to the database file', () => {
|
||||
expect(migrationMarkerPath('/app/data/photo_sharing.db'))
|
||||
.toBe('/app/data/photo_sharing.db.migrated-to-postgres');
|
||||
});
|
||||
});
|
||||
|
||||
describe('an unfinished migration pins the boot to SQLite (#1038 review)', () => {
|
||||
// A migration that dies after touching Postgres leaves rows there — schema
|
||||
// creation alone seeds a bootstrap admin when ADMIN_PASSWORD is set. Those
|
||||
// rows read as "occupied", so without a pin the next restart would switch
|
||||
// engines and hide the SQLite data that is still authoritative.
|
||||
test('Postgres holding partial data does NOT win while the migration is unfinished', () => {
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'pg',
|
||||
explicitClient: null,
|
||||
pgHasData: true, // e.g. just the bootstrap admin, or a half-load
|
||||
sqliteHasData: true,
|
||||
migrationInProgress: true,
|
||||
});
|
||||
expect(r.client).toBe('sqlite3');
|
||||
expect(r.reason).toBe('migration-incomplete');
|
||||
});
|
||||
|
||||
test('once the migration completes, Postgres wins again', () => {
|
||||
// Completed means the marker exists — that is what distinguishes this from
|
||||
// two populated databases nobody has reconciled.
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg',
|
||||
explicitClient: null,
|
||||
pgHasData: true,
|
||||
sqliteHasData: true,
|
||||
migrationInProgress: false,
|
||||
migrationCompleted: true,
|
||||
pgConfigured: true,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('the pin is irrelevant when there is no SQLite data to protect', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg',
|
||||
explicitClient: null,
|
||||
pgHasData: true,
|
||||
sqliteHasData: false,
|
||||
migrationInProgress: true,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('the pin file sits next to the database', () => {
|
||||
expect(migrationInProgressPath('/app/data/photo_sharing.db'))
|
||||
.toBe('/app/data/photo_sharing.db.migration-in-progress');
|
||||
expect(hasMigrationInProgress('/nonexistent/photo_sharing.db')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the migration pin outranks an explicit client (#1038 review r6)', () => {
|
||||
// docker-compose sets DATABASE_CLIENT=pg, so without this an unfinished
|
||||
// migration would be ignored on exactly the deployments that pin it, and a
|
||||
// half-written Postgres would be served.
|
||||
test('explicit pg loses to an unfinished migration while SQLite holds data', () => {
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'pg',
|
||||
pgHasData: true, sqliteHasData: true, migrationInProgress: true,
|
||||
});
|
||||
expect(r.client).toBe('sqlite3');
|
||||
expect(r.reason).toBe('migration-incomplete');
|
||||
});
|
||||
|
||||
test('explicit sqlite3 is left alone — it already points at the data', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'sqlite3',
|
||||
pgHasData: true, sqliteHasData: true, migrationInProgress: true,
|
||||
}).client).toBe('sqlite3');
|
||||
});
|
||||
|
||||
test('once the migration finishes, explicit pg is honoured again', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'pg',
|
||||
pgHasData: true, sqliteHasData: true, migrationInProgress: false,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('a pin with no SQLite data left does not strand the install', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'pg',
|
||||
pgHasData: true, sqliteHasData: false, migrationInProgress: true,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootstrap admin vs real admin (#1038 review r7)', () => {
|
||||
// core/001_init.js seeds must_change_password=true when ADMIN_PASSWORD is set;
|
||||
// setupService writes false once a human finishes first-run setup. Judging by
|
||||
// the FLAG rather than the table keeps both mistakes away: counting the seed
|
||||
// as real data would abandon a populated SQLite file, and ignoring the whole
|
||||
// table would abandon a legitimately set-up Postgres.
|
||||
test('an untouched seeded row is recognised across both engines', () => {
|
||||
expect(isUntouchedBootstrapRow(true)).toBe(true);
|
||||
expect(isUntouchedBootstrapRow(1)).toBe(true);
|
||||
expect(isUntouchedBootstrapRow('1')).toBe(true);
|
||||
});
|
||||
|
||||
test('a completed setup is not a bootstrap row', () => {
|
||||
expect(isUntouchedBootstrapRow(false)).toBe(false);
|
||||
expect(isUntouchedBootstrapRow(0)).toBe(false);
|
||||
expect(isUntouchedBootstrapRow('0')).toBe(false);
|
||||
});
|
||||
|
||||
test('a legacy NULL counts as a real admin, not a seed', () => {
|
||||
expect(isUntouchedBootstrapRow(null)).toBe(false);
|
||||
expect(isUntouchedBootstrapRow(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin rows: bootstrap seed vs real use (#1038 review r7/r8)', () => {
|
||||
// must_change_password alone is mutable — resetAdminPassword() sets it on real
|
||||
// accounts — so it cannot be the only signal. Only the exact shape
|
||||
// core/001_init.js leaves behind reads as an untouched seed.
|
||||
test('one never-used seeded admin is NOT use', () => {
|
||||
expect(adminsIndicateUse([{ must_change_password: true, last_login: null }])).toBe(false);
|
||||
expect(adminsIndicateUse([{ must_change_password: 1, last_login: null }])).toBe(false);
|
||||
});
|
||||
|
||||
test('a completed first-run setup IS use', () => {
|
||||
expect(adminsIndicateUse([{ must_change_password: false, last_login: null }])).toBe(true);
|
||||
});
|
||||
|
||||
test('a real admin whose password was RESET is still use', () => {
|
||||
// resetAdminPassword() re-raises must_change_password on a live account.
|
||||
expect(adminsIndicateUse([
|
||||
{ must_change_password: true, last_login: '2026-08-01T10:00:00Z' },
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
test('more than one admin is use regardless of flags', () => {
|
||||
expect(adminsIndicateUse([
|
||||
{ must_change_password: true, last_login: null },
|
||||
{ must_change_password: true, last_login: null },
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
test('no admins at all is not use', () => {
|
||||
expect(adminsIndicateUse([])).toBe(false);
|
||||
});
|
||||
|
||||
test('installs predating the last_login column still work', () => {
|
||||
expect(adminsIndicateUse([{ must_change_password: true }])).toBe(false);
|
||||
expect(adminsIndicateUse([{ must_change_password: false }])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-engine JSON columns pass through untouched (#1038 review r8)', () => {
|
||||
// SQLite keeps json columns as TEXT holding valid JSON, and pg accepts JSON
|
||||
// text directly, so the coercion must not touch them at all: serialising
|
||||
// would store `{"a":1}` as a scalar string, and parse-then-serialise turned
|
||||
// the JSON literal `null` into SQL NULL, breaking NOT NULL json columns.
|
||||
test('timestamps and booleans are coerced; nothing else is', () => {
|
||||
const [out] = coerceForTargetEngine(
|
||||
[{ setting_value: '{"a":1}', nulled: 'null', created_at: 1786548038763, flag: 1 }],
|
||||
{ timestamps: ['created_at'], booleans: ['flag'] },
|
||||
);
|
||||
expect(out.setting_value).toBe('{"a":1}');
|
||||
expect(out.nulled).toBe('null');
|
||||
expect(out.created_at).toBe('2026-08-12T15:20:38.763Z');
|
||||
expect(out.flag).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Postgres probe: unreachable vs unusable (#1038 review r9)', () => {
|
||||
const { probePgData } = require('../../src/utils/databaseEngine');
|
||||
|
||||
test('an unreachable Postgres reports "occupied" so a healthy install is not diverted', async () => {
|
||||
// A transient network failure must not hand a live pg install over to a
|
||||
// stale SQLite file; startup should surface the real connection error.
|
||||
const warnings = [];
|
||||
const result = await probePgData(
|
||||
{ host: '127.0.0.1', port: 59999, user: 'nobody', password: 'x', database: 'nope' },
|
||||
(m) => warnings.push(m),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(warnings.join(' ')).toMatch(/unreachable/i);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('a completed migration overrides an implicit SQLite config (#1038 review r11)', () => {
|
||||
// The affected installs ARE the ones with NODE_ENV unset — that is why they
|
||||
// ended up on SQLite. An operator can easily migrate before fixing that, and
|
||||
// by then the source file has been renamed away, so honouring the implicit
|
||||
// sqlite3 would create a NEW empty database and serve it.
|
||||
test('marker + Postgres settings beat an implicitly-resolved sqlite3', () => {
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'sqlite3', explicitClient: null,
|
||||
pgHasData: true, sqliteHasData: false,
|
||||
migrationCompleted: true, pgConfigured: true,
|
||||
});
|
||||
expect(r.client).toBe('pg');
|
||||
expect(r.reason).toBe('migrated-to-postgres');
|
||||
});
|
||||
|
||||
test('an EXPLICIT sqlite3 still wins — that is a deliberate rollback', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'sqlite3', explicitClient: 'sqlite3',
|
||||
pgHasData: true, sqliteHasData: false,
|
||||
migrationCompleted: true, pgConfigured: true,
|
||||
}).client).toBe('sqlite3');
|
||||
});
|
||||
|
||||
test('without Postgres settings there is nowhere to send it', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'sqlite3', explicitClient: null,
|
||||
pgHasData: false, sqliteHasData: false,
|
||||
migrationCompleted: true, pgConfigured: false,
|
||||
}).client).toBe('sqlite3');
|
||||
});
|
||||
|
||||
test('no marker, no override — a plain SQLite install is left alone', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'sqlite3', explicitClient: null,
|
||||
pgHasData: false, sqliteHasData: true,
|
||||
migrationCompleted: false, pgConfigured: true,
|
||||
}).client).toBe('sqlite3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('two populated databases is a conflict, not a guess (#1038 review r12)', () => {
|
||||
// An install that ran on Postgres, lost NODE_ENV, and kept working on SQLite
|
||||
// has real data on BOTH sides: the Postgres rows are old, the SQLite rows are
|
||||
// newer. Picking either hides galleries and splits future writes.
|
||||
test('no marker + data on both sides refuses to choose', () => {
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null,
|
||||
pgHasData: true, sqliteHasData: true, migrationCompleted: false,
|
||||
});
|
||||
expect(r.client).toBeNull();
|
||||
expect(r.reason).toBe('ambiguous-both-populated');
|
||||
});
|
||||
|
||||
test('a completed migration is not a conflict — the marker says which is current', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null,
|
||||
pgHasData: true, sqliteHasData: true, migrationCompleted: true, pgConfigured: true,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('an explicit choice always resolves it', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'sqlite3',
|
||||
pgHasData: true, sqliteHasData: true, migrationCompleted: false,
|
||||
}).client).toBe('sqlite3');
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'pg',
|
||||
pgHasData: true, sqliteHasData: true, migrationCompleted: false,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('only one side populated is not a conflict', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null,
|
||||
pgHasData: true, sqliteHasData: false, migrationCompleted: false,
|
||||
}).client).toBe('pg');
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null,
|
||||
pgHasData: false, sqliteHasData: true, migrationCompleted: false,
|
||||
}).client).toBe('sqlite3');
|
||||
});
|
||||
|
||||
test('the pg probe target comes from the environment, not a sqlite config', () => {
|
||||
const { pgConnectionFromEnv } = require('../../src/utils/databaseEngine');
|
||||
const prev = { ...process.env };
|
||||
process.env.DB_HOST = 'db.internal';
|
||||
process.env.DB_NAME = 'picpeak_prod';
|
||||
try {
|
||||
const c = pgConnectionFromEnv();
|
||||
expect(c.host).toBe('db.internal');
|
||||
expect(c.database).toBe('picpeak_prod');
|
||||
} finally {
|
||||
process.env.DB_HOST = prev.DB_HOST;
|
||||
process.env.DB_NAME = prev.DB_NAME;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the target is resolved once, with production defaults (#1038 review r13)', () => {
|
||||
// knexfile's DEVELOPMENT block defaults pg to localhost/postgres/photo_sharing
|
||||
// while production uses db/picpeak/picpeak. The CLI runs in the NODE_ENV-unset
|
||||
// state by design, so without an explicit resolution the migration could land
|
||||
// in a database the running application never opens.
|
||||
const { pgConnectionFromEnv } = require('../../src/utils/databaseEngine');
|
||||
|
||||
test('falls back to what a running container actually uses', () => {
|
||||
// Host is `postgres`, matching wait-for-db.sh, which resolves and EXPORTS
|
||||
// that value — so it is the host a bare container really runs against.
|
||||
// knexfile's production block says `db`, but that default is only reached
|
||||
// when the entrypoint did not run; a `docker exec` CLI has to agree with
|
||||
// the runtime, not with the dormant default (#1038 review r14).
|
||||
const prev = { ...process.env };
|
||||
delete process.env.DB_HOST; delete process.env.DB_USER; delete process.env.DB_NAME;
|
||||
try {
|
||||
const c = pgConnectionFromEnv();
|
||||
expect(c.host).toBe('postgres');
|
||||
expect(c.user).toBe('picpeak');
|
||||
expect(c.database).toBe('picpeak');
|
||||
} finally {
|
||||
Object.assign(process.env, prev);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit settings always win', () => {
|
||||
const prev = { ...process.env };
|
||||
process.env.DB_HOST = 'pg.example'; process.env.DB_NAME = 'mypics';
|
||||
try {
|
||||
const c = pgConnectionFromEnv();
|
||||
expect(c.host).toBe('pg.example');
|
||||
expect(c.database).toBe('mypics');
|
||||
} finally {
|
||||
Object.assign(process.env, prev);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the marker is bound to the target it describes (#1038 review r15)', () => {
|
||||
const { currentPgTargetId, readMigrationMarker } = require('../../src/utils/databaseEngine');
|
||||
|
||||
test('the target id has the shape the migration records', () => {
|
||||
const prev = { ...process.env };
|
||||
process.env.DB_HOST = 'pg.host'; process.env.DB_PORT = '6543'; process.env.DB_NAME = 'picpeak_prod';
|
||||
try {
|
||||
expect(currentPgTargetId()).toBe('pg.host:6543/picpeak_prod');
|
||||
} finally {
|
||||
Object.assign(process.env, prev);
|
||||
}
|
||||
});
|
||||
|
||||
test('an absent or unreadable marker reads as null, not a throw', () => {
|
||||
expect(readMigrationMarker('/nonexistent/photo_sharing.db')).toBeNull();
|
||||
});
|
||||
|
||||
test('inbound_documents is a real table; incoming_invoices never was', () => {
|
||||
// The occupancy lists silently skip tables that do not exist, so a wrong
|
||||
// name meant supplier documents never protected the install.
|
||||
const src = fs.readFileSync(
|
||||
path.resolve(__dirname, '..', '..', 'src', 'utils', 'databaseEngine.js'), 'utf8',
|
||||
);
|
||||
const cli = fs.readFileSync(
|
||||
path.resolve(__dirname, '..', '..', 'scripts', 'migrate-sqlite-to-postgres.js'), 'utf8',
|
||||
);
|
||||
for (const text of [src, cli]) {
|
||||
expect(text).toContain("'inbound_documents'");
|
||||
expect(text).not.toContain("'incoming_invoices'");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Regression tests for the feedback-settings write path (#1030).
|
||||
*
|
||||
* The admin event form posts its whole client-side feedback state back,
|
||||
* including three keys that were never columns on event_feedback_settings:
|
||||
* `enable_rate_limiting`, `rate_limit_window_minutes` and
|
||||
* `rate_limit_max_requests`. Spreading those into the knex UPDATE threw,
|
||||
* the route answered 500, and EventDetailsPage swallowed it — so the admin
|
||||
* saw "Event updated successfully" while "Enable feedback" never persisted
|
||||
* and guests could not leave any feedback.
|
||||
*
|
||||
* Pinned here:
|
||||
* - UI-only keys are dropped, not written, on BOTH the insert (no row yet)
|
||||
* and update (row exists) branches.
|
||||
* - Every real column still round-trips.
|
||||
* - Identity columns can't be mass-assigned through the settings body.
|
||||
* - gallery.js no longer declares a duplicate GET /:slug/feedback-settings.
|
||||
* server.js mounts galleryRoutes before galleryFeedback, so the duplicate
|
||||
* shadowed the real handler and dropped the #655 per-guest caps from the
|
||||
* guest payload.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-feedback-settings-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-settings-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const feedbackService = require('../../src/services/feedbackService');
|
||||
|
||||
// Exactly what EventDetailsPage holds in state before its settings GET
|
||||
// resolves — the three rate-limit keys are UI-only.
|
||||
const ADMIN_FORM_BODY = {
|
||||
feedback_enabled: true,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
allow_reactions: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
enable_rate_limiting: false,
|
||||
rate_limit_window_minutes: 15,
|
||||
rate_limit_max_requests: 10,
|
||||
};
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventId;
|
||||
|
||||
async function insertEvent(slug) {
|
||||
const inserted = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Feedback Settings Test',
|
||||
event_date: '2026-06-22',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${slug}/share`,
|
||||
share_token: `${slug}-share`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
eventId = await insertEvent('feedback-settings-test');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('updateEventFeedbackSettings ignores UI-only keys (#1030)', () => {
|
||||
test('insert branch: enabling feedback on an event with no settings row persists', async () => {
|
||||
const freshEventId = await insertEvent('feedback-settings-fresh');
|
||||
|
||||
const result = await feedbackService.updateEventFeedbackSettings(freshEventId, ADMIN_FORM_BODY);
|
||||
|
||||
expect(result.feedback_enabled).toBeTruthy();
|
||||
const row = await db('event_feedback_settings').where('event_id', freshEventId).first();
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.feedback_enabled).toBeTruthy();
|
||||
expect(row).not.toHaveProperty('enable_rate_limiting');
|
||||
});
|
||||
|
||||
test('update branch: flipping the toggle on an existing row persists', async () => {
|
||||
await feedbackService.updateEventFeedbackSettings(eventId, { feedback_enabled: false });
|
||||
expect((await feedbackService.getEventFeedbackSettings(eventId)).feedback_enabled).toBeFalsy();
|
||||
|
||||
const result = await feedbackService.updateEventFeedbackSettings(eventId, ADMIN_FORM_BODY);
|
||||
|
||||
expect(result.feedback_enabled).toBeTruthy();
|
||||
const rows = await db('event_feedback_settings').where('event_id', eventId);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].feedback_enabled).toBeTruthy();
|
||||
});
|
||||
|
||||
test('every real column round-trips', async () => {
|
||||
const result = await feedbackService.updateEventFeedbackSettings(eventId, {
|
||||
...ADMIN_FORM_BODY,
|
||||
allow_comments: false,
|
||||
show_feedback_to_guests: false,
|
||||
identity_mode: 'guest',
|
||||
max_favorites_per_guest: 10,
|
||||
max_likes_per_guest: 5,
|
||||
});
|
||||
|
||||
expect(result.allow_comments).toBeFalsy();
|
||||
expect(result.show_feedback_to_guests).toBeFalsy();
|
||||
expect(result.identity_mode).toBe('guest');
|
||||
expect(result.max_favorites_per_guest).toBe(10);
|
||||
expect(result.max_likes_per_guest).toBe(5);
|
||||
});
|
||||
|
||||
test('identity columns cannot be mass-assigned through the settings body', async () => {
|
||||
const otherEventId = await insertEvent('feedback-settings-other');
|
||||
const before = await db('event_feedback_settings').where('event_id', eventId).first();
|
||||
|
||||
await feedbackService.updateEventFeedbackSettings(eventId, {
|
||||
feedback_enabled: true,
|
||||
id: 99999,
|
||||
event_id: otherEventId,
|
||||
});
|
||||
|
||||
const after = await db('event_feedback_settings').where('event_id', eventId).first();
|
||||
expect(after.id).toBe(before.id);
|
||||
expect(after.event_id).toBe(eventId);
|
||||
expect(await db('event_feedback_settings').where('event_id', otherEventId).first()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('guest feedback-settings route is not shadowed (#1030)', () => {
|
||||
test('gallery.js does not declare GET /:slug/feedback-settings', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, '..', '..', 'src', 'routes', 'gallery.js'), 'utf8',
|
||||
);
|
||||
expect(source).not.toMatch(/router\.get\(\s*['"]\/:slug\/feedback-settings['"]/);
|
||||
});
|
||||
|
||||
test('galleryFeedback.js still serves it, including the #655 per-guest caps', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, '..', '..', 'src', 'routes', 'galleryFeedback.js'), 'utf8',
|
||||
);
|
||||
expect(source).toMatch(/router\.get\(\s*['"]\/:slug\/feedback-settings['"]/);
|
||||
expect(source).toMatch(/max_favorites_per_guest/);
|
||||
expect(source).toMatch(/max_likes_per_guest/);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* DNS-resolving SSRF guard (GHSA SSRF cluster: webhook / S3 / rsync / SMTP /
|
||||
* IMAP). The literal isPrivateIP check can't see that a public-looking
|
||||
* hostname resolves to an internal/metadata IP; isHostAllowed resolves the
|
||||
* name and vets every A/AAAA record.
|
||||
*/
|
||||
jest.mock('dns', () => {
|
||||
const actual = jest.requireActual('dns');
|
||||
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() } };
|
||||
});
|
||||
const dns = require('dns');
|
||||
const {
|
||||
isHostAllowed,
|
||||
validateExternalUrlAsync,
|
||||
classifyHost,
|
||||
} = require('../../src/utils/networkValidation');
|
||||
|
||||
const lookup = dns.promises.lookup;
|
||||
|
||||
describe('classifyHost', () => {
|
||||
beforeEach(() => lookup.mockReset());
|
||||
|
||||
it('distinguishes private, unresolved, ok, and invalid', async () => {
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
|
||||
expect(await classifyHost('evil.example')).toBe('private');
|
||||
|
||||
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
expect(await classifyHost('example.com')).toBe('ok');
|
||||
|
||||
lookup.mockRejectedValue(new Error('EAI_AGAIN'));
|
||||
expect(await classifyHost('blip.example')).toBe('unresolved');
|
||||
|
||||
lookup.mockResolvedValue([]);
|
||||
expect(await classifyHost('empty.example')).toBe('unresolved');
|
||||
|
||||
expect(await classifyHost('')).toBe('invalid');
|
||||
expect(await classifyHost('10.0.0.1')).toBe('private'); // literal, no lookup
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHostAllowed', () => {
|
||||
beforeEach(() => lookup.mockReset());
|
||||
|
||||
it('rejects a public hostname that resolves to a private IP', async () => {
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
|
||||
expect(await isHostAllowed('evil.example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when the hostname resolves to the cloud metadata IP', async () => {
|
||||
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
|
||||
expect(await isHostAllowed('metadata-rebind.example')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when ANY resolved address is private (rebinding / mixed records)', async () => {
|
||||
lookup.mockResolvedValue([
|
||||
{ address: '93.184.216.34', family: 4 },
|
||||
{ address: '169.254.169.254', family: 4 },
|
||||
]);
|
||||
expect(await isHostAllowed('rebind.example')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a hostname that resolves only to public IPs', async () => {
|
||||
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
expect(await isHostAllowed('example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when resolution errors', async () => {
|
||||
lookup.mockRejectedValue(new Error('ENOTFOUND'));
|
||||
expect(await isHostAllowed('nxdomain.invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on an empty resolution', async () => {
|
||||
lookup.mockResolvedValue([]);
|
||||
expect(await isHostAllowed('empty.example')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects literal private IPs and blocked names without resolving', async () => {
|
||||
expect(await isHostAllowed('127.0.0.1')).toBe(false);
|
||||
expect(await isHostAllowed('10.0.0.1')).toBe(false);
|
||||
expect(await isHostAllowed('localhost')).toBe(false);
|
||||
expect(await isHostAllowed('metadata.google.internal')).toBe(false);
|
||||
expect(await isHostAllowed('foo.internal')).toBe(false);
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a public IP literal without resolving', async () => {
|
||||
expect(await isHostAllowed('93.184.216.34')).toBe(true);
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects empty / non-string input', async () => {
|
||||
expect(await isHostAllowed('')).toBe(false);
|
||||
expect(await isHostAllowed(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateExternalUrlAsync', () => {
|
||||
beforeEach(() => lookup.mockReset());
|
||||
|
||||
it('rejects a URL whose host resolves to a private address', async () => {
|
||||
lookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }]);
|
||||
const r = await validateExternalUrlAsync('https://evil.example/hook');
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a URL whose host resolves public', async () => {
|
||||
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
expect((await validateExternalUrlAsync('https://example.com/hook')).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a malformed URL', async () => {
|
||||
expect((await validateExternalUrlAsync('not a url')).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('reports reason=unresolved for a transient lookup failure (retryable)', async () => {
|
||||
lookup.mockRejectedValue(new Error('EAI_AGAIN'));
|
||||
const r = await validateExternalUrlAsync('https://blip.example/hook');
|
||||
expect(r.valid).toBe(false);
|
||||
expect(r.reason).toBe('unresolved');
|
||||
});
|
||||
|
||||
it('reports reason=private for a resolved-private host (permanent)', async () => {
|
||||
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
|
||||
const r = await validateExternalUrlAsync('https://rebind.example/hook');
|
||||
expect(r.valid).toBe(false);
|
||||
expect(r.reason).toBe('private');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Regression test for #1024: quote/invoice PDF endpoints 500'd (or silently
|
||||
* corrupted the filename) for customers whose name carries non-ASCII.
|
||||
*
|
||||
* The six PDF routes built the header by interpolating buildPdfFilename()'s
|
||||
* result straight into `inline; filename="${filename}"`. HTTP header values
|
||||
* are latin1, which splits the failure in two — and the split matters,
|
||||
* because the issue reported the umlaut case as the 500 and it isn't:
|
||||
*
|
||||
* U+0080-U+00FF (ä ö ü ß — every German umlaut)
|
||||
* No throw. The byte goes out raw and the client reads back a mangled
|
||||
* name. A silent corruption, not an error.
|
||||
*
|
||||
* above U+00FF (Polish ł, Czech ř, Turkish ş, €, Cyrillic, CJK, emoji)
|
||||
* Node's setHeader rejects it with ERR_INVALID_CHAR. Because the
|
||||
* throw lands after the PDF buffer is already rendered, the whole
|
||||
* request fails as an unhandled 500.
|
||||
*
|
||||
* buildContentDisposition() fixes both: an ASCII fallback for the legacy
|
||||
* `filename=` parameter plus the RFC 5987 `filename*=UTF-8''…` form that
|
||||
* carries the real name.
|
||||
*
|
||||
* These assertions run against the real Node header validator via a live
|
||||
* express server, so they'd fail against the old interpolation rather than
|
||||
* merely testing the helper in isolation.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { buildPdfFilename, sanitiseSegment } = require('../../src/utils/pdfFilename');
|
||||
const { buildContentDisposition } = require('../../src/utils/filenameSanitizer');
|
||||
|
||||
// The RFC 5987 parameter prefix, i.e. filename*=UTF-8'' — the two trailing
|
||||
// quotes are the (empty) language tag the spec puts between the charset and
|
||||
// the percent-encoded value.
|
||||
const RFC5987_PREFIX = 'filename*=UTF-8\'\'';
|
||||
|
||||
// Mirrors what the six PDF routes now do.
|
||||
function buildApp(customer, docNumber = 'Q-2026-0042') {
|
||||
const app = express();
|
||||
app.get('/pdf', (req, res) => {
|
||||
const filename = buildPdfFilename({ docNumber, customer, fallback: 'quote-preview' });
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
|
||||
res.send(Buffer.from('%PDF-1.4 fake'));
|
||||
});
|
||||
// Mirrors the real error handler: an ERR_INVALID_CHAR throw inside the
|
||||
// handler surfaces as a 500, which is what #1024 reported.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => res.status(500).json({ error: err.code || err.message }));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('#1024 — PDF Content-Disposition with non-ASCII customer names', () => {
|
||||
it('serves a PDF for a German umlaut name and keeps the name intact', async () => {
|
||||
const res = await request(buildApp({ company_name: 'Müller Fotografie' })).get('/pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const cd = res.headers['content-disposition'];
|
||||
// RFC 5987 form carries the real, unmangled name...
|
||||
expect(cd).toContain(RFC5987_PREFIX);
|
||||
expect(cd).toContain(encodeURIComponent('Müller-Fotografie.pdf'));
|
||||
// ...and the ASCII fallback is legal latin1 with no raw umlaut byte.
|
||||
const fallback = /filename="([^"]+)"/.exec(cd)[1];
|
||||
expect(fallback).toMatch(/^[\x20-\x7e]+$/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Polish', 'Michał Kowalski'],
|
||||
['Czech', 'Dvořák Studio'],
|
||||
['Turkish', 'Şahin Fotoğraf'],
|
||||
['Cyrillic', 'Иванов Фото'],
|
||||
['CJK', '山田写真'],
|
||||
['emoji', 'Studio 🎉 Berlin'],
|
||||
])('does not 500 for a %s customer name (was ERR_INVALID_CHAR)', async (_label, company) => {
|
||||
const res = await request(buildApp({ company_name: company })).get('/pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const cd = res.headers['content-disposition'];
|
||||
expect(cd).toContain(RFC5987_PREFIX);
|
||||
// The legacy filename= token drops non-ASCII, so a name written entirely
|
||||
// in another script degrades to just the document number
|
||||
// (`Q-2026-0042_.pdf`). That's the intended trade — filename* carries the
|
||||
// real name — but the fallback must still be a legal, non-empty,
|
||||
// ASCII-only token, since that is what a client without RFC 5987 support
|
||||
// ends up saving.
|
||||
const fallback = /filename="([^"]*)"/.exec(cd)[1];
|
||||
expect(fallback.length).toBeGreaterThan(0);
|
||||
expect(fallback).toMatch(/^[\x20-\x7e]+$/);
|
||||
expect(fallback).toContain('Q-2026-0042');
|
||||
});
|
||||
|
||||
it('leaves a plain ASCII name on the familiar filename= form', async () => {
|
||||
const res = await request(buildApp({ company_name: 'Bright Studio' })).get('/pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-disposition'])
|
||||
.toContain('filename="Q-2026-0042_Bright-Studio.pdf"');
|
||||
});
|
||||
|
||||
it('still works when the customer row is missing entirely (preview path)', async () => {
|
||||
const res = await request(buildApp(null, null)).get('/pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-disposition']).toContain('quote-preview_customer.pdf');
|
||||
});
|
||||
|
||||
// sanitiseSegment caps each segment at 80 UTF-16 code units. A cap landing
|
||||
// inside an astral character used to leave a dangling high surrogate, which
|
||||
// makes encodeURIComponent throw URIError inside buildContentDisposition —
|
||||
// a 500 on the very endpoint this PR fixes, reached a different way.
|
||||
it.each([
|
||||
['emoji on the 80-char boundary', `${'a'.repeat(79)}🎉`],
|
||||
['astral CJK on the boundary', `${'a'.repeat(79)}𠜎`],
|
||||
['a label that is entirely astral', '🎉'.repeat(60)],
|
||||
])('does not 500 when truncation splits a surrogate pair — %s', async (_label, company) => {
|
||||
const res = await request(buildApp({ company_name: company })).get('/pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-disposition']).toContain(RFC5987_PREFIX);
|
||||
});
|
||||
|
||||
it('drops the orphaned surrogate rather than widening the length cap', () => {
|
||||
const seg = sanitiseSegment(`${'a'.repeat(79)}🎉`);
|
||||
|
||||
// 79 'a's + a half-emoji would be 80; the orphan is dropped, not kept.
|
||||
expect(seg).toHaveLength(79);
|
||||
expect(seg).toBe('a'.repeat(79));
|
||||
// Nothing in the result may be an unpaired surrogate.
|
||||
expect(seg).toBe(seg.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, ''));
|
||||
});
|
||||
|
||||
it('the raw interpolation these routes used to do really does throw', () => {
|
||||
// Pins the root cause itself, so nobody "simplifies" the helper away.
|
||||
const filename = buildPdfFilename({
|
||||
docNumber: 'Q-2026-0042',
|
||||
customer: { company_name: 'Michał Kowalski' },
|
||||
});
|
||||
const res = new (require('http').ServerResponse)({});
|
||||
expect(() => res.setHeader('Content-Disposition', `inline; filename="${filename}"`))
|
||||
.toThrow(/ERR_INVALID_CHAR|Invalid character/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Unit tests for the shared hidden-photo access-control helper.
|
||||
*
|
||||
* Pins the rule that ordinary gallery guests never receive photos with
|
||||
* visibility='hidden' (NULL = visible), while PIN-clients see everything.
|
||||
*/
|
||||
const {
|
||||
canSeeHiddenPhotos,
|
||||
isPhotoHiddenFromViewer,
|
||||
} = require('../../src/utils/photoVisibility');
|
||||
|
||||
describe('canSeeHiddenPhotos', () => {
|
||||
it('is true only for the client access level', () => {
|
||||
expect(canSeeHiddenPhotos('client')).toBe(true);
|
||||
expect(canSeeHiddenPhotos('guest')).toBe(false);
|
||||
expect(canSeeHiddenPhotos('slideshow')).toBe(false);
|
||||
expect(canSeeHiddenPhotos(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPhotoHiddenFromViewer', () => {
|
||||
it('blocks a hidden photo from guests', () => {
|
||||
expect(isPhotoHiddenFromViewer({ visibility: 'hidden' }, 'guest')).toBe(true);
|
||||
expect(isPhotoHiddenFromViewer({ visibility: 'hidden' }, 'slideshow')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets clients see hidden photos', () => {
|
||||
expect(isPhotoHiddenFromViewer({ visibility: 'hidden' }, 'client')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats visible and NULL visibility as viewable by everyone', () => {
|
||||
expect(isPhotoHiddenFromViewer({ visibility: 'visible' }, 'guest')).toBe(false);
|
||||
expect(isPhotoHiddenFromViewer({ visibility: null }, 'guest')).toBe(false);
|
||||
expect(isPhotoHiddenFromViewer({}, 'guest')).toBe(false);
|
||||
});
|
||||
|
||||
it('is null-safe', () => {
|
||||
expect(isPhotoHiddenFromViewer(null, 'guest')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -99,10 +99,22 @@ describe('resolveLogoFile', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('treats absolute paths as-is when they exist', async () => {
|
||||
it('rejects an absolute path OUTSIDE the storage roots (GHSA-c7x5)', async () => {
|
||||
// The raw-absolute candidate was an arbitrary-file-read primitive
|
||||
// (logo_path: '/etc/passwd' → rasterised into a PDF). Absolute paths
|
||||
// outside the storage roots are now dropped even if they exist.
|
||||
existsSpy.mockImplementation((p) => p === '/abs/path/logo.png');
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: '/abs/path/logo.png' });
|
||||
expect(out).toBe('/abs/path/logo.png');
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('still accepts an absolute path INSIDE the storage root', async () => {
|
||||
// The legitimate case: multer stores the uploaded logo under
|
||||
// storage/uploads/logos with an absolute path — that stays resolvable.
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/logo.png');
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: '/app/storage/uploads/logos/logo.png' });
|
||||
expect(out).toBe('/app/storage/uploads/logos/logo.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Credential redaction for log payloads (GHSA-pgmp / GHSA-r794).
|
||||
*
|
||||
* Event create/update logged the whole request body. Beyond the plaintext
|
||||
* gallery password named in the advisories, the update path also logged
|
||||
* `client_share_token` — a LIVE bearer credential for client gallery access,
|
||||
* freshly minted by `regenerate_client_token` — and `client_password_hash`.
|
||||
*/
|
||||
|
||||
const { sanitizeForLog, isSensitiveKey } = require('../../src/utils/sanitizeForLog');
|
||||
|
||||
describe('sanitizeForLog', () => {
|
||||
it('redacts the credentials an event body actually carries', () => {
|
||||
const out = sanitizeForLog({
|
||||
event_name: 'Wedding',
|
||||
password: 'FAKE-PLAINTEXT-PASSWORD',
|
||||
client_password: 'FAKE-CLIENT-PASSWORD',
|
||||
client_password_hash: 'FAKE-BCRYPT-HASH-PLACEHOLDER',
|
||||
client_share_token: 'FAKE-CLIENT-SHARE-TOKEN',
|
||||
share_token: 'FAKE-SHARE-TOKEN',
|
||||
});
|
||||
|
||||
expect(out.event_name).toBe('Wedding');
|
||||
for (const key of ['password', 'client_password', 'client_password_hash',
|
||||
'client_share_token', 'share_token']) {
|
||||
expect(out[key]).toBe('[redacted]');
|
||||
}
|
||||
expect(JSON.stringify(out)).not.toContain('FAKE-PLAINTEXT-PASSWORD');
|
||||
expect(JSON.stringify(out)).not.toContain('FAKE-CLIENT-SHARE-TOKEN');
|
||||
});
|
||||
|
||||
it('redacts nested and array-nested secrets', () => {
|
||||
const out = sanitizeForLog({
|
||||
smtp: { host: 'mail.example.com', smtp_password: 'p' },
|
||||
users: [{ name: 'a', api_key: 'k' }],
|
||||
});
|
||||
expect(out.smtp.host).toBe('mail.example.com');
|
||||
expect(out.smtp.smtp_password).toBe('[redacted]');
|
||||
expect(out.users[0].name).toBe('a');
|
||||
expect(out.users[0].api_key).toBe('[redacted]');
|
||||
});
|
||||
|
||||
it('passes non-objects through and survives cycles', () => {
|
||||
expect(sanitizeForLog('plain')).toBe('plain');
|
||||
expect(sanitizeForLog(42)).toBe(42);
|
||||
expect(sanitizeForLog(null)).toBeNull();
|
||||
|
||||
const cyclic = { name: 'x' };
|
||||
cyclic.self = cyclic;
|
||||
expect(() => sanitizeForLog(cyclic)).not.toThrow();
|
||||
expect(sanitizeForLog(cyclic).self).toBe('[circular]');
|
||||
});
|
||||
|
||||
it('matches key names case-insensitively and by fragment', () => {
|
||||
expect(isSensitiveKey('Authorization')).toBe(true);
|
||||
expect(isSensitiveKey('CLIENT_SHARE_TOKEN')).toBe(true);
|
||||
expect(isSensitiveKey('event_name')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Codex round 2: sanitizing req.body was not enough. express-validator's
|
||||
* errors.array() embeds the SUBMITTED value per field, so a password rejected
|
||||
* for being too short was still logged in plaintext.
|
||||
*/
|
||||
describe('sanitizeValidationErrors', () => {
|
||||
const { sanitizeValidationErrors } = require('../../src/utils/sanitizeForLog');
|
||||
|
||||
it('redacts the submitted value for a password field', () => {
|
||||
const out = sanitizeValidationErrors([
|
||||
{ type: 'field', path: 'password', msg: 'too short', value: 'FAKE-PLAINTEXT-PASSWORD' },
|
||||
{ type: 'field', path: 'event_name', msg: 'required', value: '' },
|
||||
]);
|
||||
expect(out[0].value).toBe('[redacted]');
|
||||
expect(out[0].msg).toBe('too short');
|
||||
expect(JSON.stringify(out)).not.toContain('FAKE-PLAINTEXT-PASSWORD');
|
||||
expect(out[1].value).toBe('');
|
||||
});
|
||||
|
||||
it('handles the legacy `param` field name', () => {
|
||||
const out = sanitizeValidationErrors([{ param: 'client_password', value: 'FAKE-SECRET' }]);
|
||||
expect(out[0].value).toBe('[redacted]');
|
||||
});
|
||||
|
||||
it('recurses into object values on non-sensitive fields', () => {
|
||||
const out = sanitizeValidationErrors([
|
||||
{ path: 'config', value: { host: 'h', api_key: 'k' } },
|
||||
]);
|
||||
expect(out[0].value.host).toBe('h');
|
||||
expect(out[0].value.api_key).toBe('[redacted]');
|
||||
});
|
||||
|
||||
it('passes non-arrays through untouched', () => {
|
||||
expect(sanitizeValidationErrors(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Regression test for clearing an event's expiration on SQLite (#1029).
|
||||
*
|
||||
* Migration 061 dropped the NOT NULL on events.event_date / events.expires_at
|
||||
* for Postgres only — it skipped SQLite on the (wrong) premise that SQLite
|
||||
* doesn't enforce NOT NULL. It does, so every SQLite install answered
|
||||
*
|
||||
* SQLITE_CONSTRAINT: NOT NULL constraint failed: events.expires_at
|
||||
*
|
||||
* when an admin cleared the expiration, surfacing as "Failed to update event".
|
||||
* Migration 174 finishes the job. The harness runs on SQLite, so this asserts
|
||||
* the real engine behaviour rather than a mock.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-nullable-dates-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'nullable-dates-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
const inserted = await db('events').insert({
|
||||
slug: 'nullable-dates-test',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Nullable Dates Test',
|
||||
event_date: '2026-06-22',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/nullable-dates-test/share',
|
||||
share_token: 'nullable-dates-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('events date columns are nullable on SQLite (#1029)', () => {
|
||||
test('the engine under test really is SQLite', () => {
|
||||
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
|
||||
});
|
||||
|
||||
test('clearing expires_at succeeds — this threw SQLITE_CONSTRAINT before migration 174', async () => {
|
||||
await db('events').where('id', eventId).update({ expires_at: null });
|
||||
const row = await db('events').where('id', eventId).first('expires_at');
|
||||
expect(row.expires_at).toBeNull();
|
||||
});
|
||||
|
||||
test('clearing event_date succeeds too (061 covered both columns on PG)', async () => {
|
||||
await db('events').where('id', eventId).update({ event_date: null });
|
||||
const row = await db('events').where('id', eventId).first('event_date');
|
||||
expect(row.event_date).toBeNull();
|
||||
});
|
||||
|
||||
test('a gallery can be created with no expiration at all', async () => {
|
||||
const inserted = await db('events').insert({
|
||||
slug: 'never-expires-test',
|
||||
event_type: 'other',
|
||||
event_name: 'Never Expires',
|
||||
event_date: null,
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/never-expires-test/share',
|
||||
share_token: 'never-expires-share',
|
||||
expires_at: null,
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const id = inserted[0]?.id ?? inserted[0];
|
||||
const row = await db('events').where('id', id).first('expires_at', 'event_date');
|
||||
expect(row.expires_at).toBeNull();
|
||||
expect(row.event_date).toBeNull();
|
||||
});
|
||||
|
||||
test('columns the events table depends on survived the table rebuild', async () => {
|
||||
// Knex implements .alter() on SQLite by recreating the table; make sure the
|
||||
// rebuild kept the row and the wider schema intact.
|
||||
const row = await db('events').where('id', eventId).first();
|
||||
expect(row.slug).toBe('nullable-dates-test');
|
||||
expect(row.share_token).toBe('nullable-dates-share');
|
||||
expect(await db.schema.hasColumn('events', 'allow_downloads')).toBe(true);
|
||||
expect(await db.schema.hasColumn('events', 'hero_photo_id')).toBe(true);
|
||||
const photos = await db('photos').where('event_id', eventId);
|
||||
expect(Array.isArray(photos)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The contract that matters here is negative: a source that disappears must
|
||||
* NOT be able to end the process (#1128).
|
||||
*
|
||||
* `fs.createReadStream` is lazy, so its ENOENT lands on a later tick, outside
|
||||
* the route's try/catch. An EventEmitter emitting 'error' with no listener
|
||||
* throws, and an uncaught throw from an I/O callback exits Node — which is how
|
||||
* one missing thumbnail tier took every gallery on the install down.
|
||||
*
|
||||
* These use a REAL fs stream over a real missing path rather than a fake
|
||||
* emitter: the point under test is the lazy-open timing, and a hand-rolled
|
||||
* mock that emits synchronously would pass while proving nothing.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { Readable } = require('stream');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const { pipeStreamToResponse } = require('../../src/utils/streamResponse');
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
/** Minimal Express-ish response that records what happened to it. */
|
||||
function makeRes() {
|
||||
const res = new EventEmitter();
|
||||
res.headers = { 'Content-Length': '1234', ETag: '"x"' };
|
||||
res.statusCode = 200;
|
||||
res.headersSent = false;
|
||||
res.writableEnded = false;
|
||||
res.body = null;
|
||||
res.destroyed = false;
|
||||
res.removeHeader = (h) => { delete res.headers[h]; };
|
||||
res.setHeader = (h, v) => { res.headers[h] = v; };
|
||||
res.status = (code) => { res.statusCode = code; return res; };
|
||||
res.json = (payload) => { res.body = payload; res.writableEnded = true; return res; };
|
||||
res.destroy = () => { res.destroyed = true; };
|
||||
// pipe() target surface
|
||||
res.write = () => true;
|
||||
res.end = () => { res.writableEnded = true; };
|
||||
res.on = EventEmitter.prototype.on.bind(res);
|
||||
res.emit = EventEmitter.prototype.emit.bind(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
describe('pipeStreamToResponse (#1128)', () => {
|
||||
it('turns a missing file into a 404 instead of an unhandled error', async () => {
|
||||
const missing = path.join(os.tmpdir(), `picpeak-not-here-${Date.now()}.jpg`);
|
||||
const res = makeRes();
|
||||
|
||||
pipeStreamToResponse(stream_(missing), res, { context: 'thumbnail for photo 1' });
|
||||
await settle();
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'File not found' });
|
||||
});
|
||||
|
||||
// How this test discriminates, since the failure mode is a process-level
|
||||
// one: replacing the call above with a bare `stream.pipe(res)` — what the
|
||||
// thumbnail route did — makes jest fail this suite on the unhandled 'error'
|
||||
// event before either assertion runs. Verified by doing exactly that.
|
||||
// Catching the throw with a process.on('uncaughtException') listener does
|
||||
// NOT work here and would be theatre: the runner installs its own handling,
|
||||
// so such a listener never sees it and the assertion could never fail.
|
||||
function stream_(p) { return fs.createReadStream(p); }
|
||||
|
||||
it('strips every header that described the file it can no longer send', async () => {
|
||||
const res = makeRes();
|
||||
// What the image and zip routes actually stage before streaming.
|
||||
res.headers = {
|
||||
'Content-Length': '1234',
|
||||
ETag: '"x"',
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Disposition': 'attachment; filename="gallery.zip"',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
};
|
||||
const stream = fs.createReadStream(path.join(os.tmpdir(), `gone-${Date.now()}.jpg`));
|
||||
|
||||
pipeStreamToResponse(stream, res);
|
||||
await settle();
|
||||
|
||||
expect(res.headers['Content-Length']).toBeUndefined();
|
||||
expect(res.headers.ETag).toBeUndefined();
|
||||
// Express does NOT overwrite an existing Content-Type, so leaving it makes
|
||||
// res.json() emit JSON labelled image/jpeg — or a corrupt .zip download.
|
||||
expect(res.headers['Content-Type']).toBeUndefined();
|
||||
expect(res.headers['Content-Disposition']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not let a transient 404 be cached as a broken tile', async () => {
|
||||
const res = makeRes();
|
||||
// The thumbnail route stages 30 minutes; the hero route an hour.
|
||||
res.headers = { 'Cache-Control': 'private, max-age=1800' };
|
||||
const stream = fs.createReadStream(path.join(os.tmpdir(), `gone3-${Date.now()}.jpg`));
|
||||
|
||||
pipeStreamToResponse(stream, res);
|
||||
await settle();
|
||||
|
||||
// The regeneration race is transient by definition: the tier exists moments
|
||||
// later. Caching this 404 would keep the tile broken long after the file is
|
||||
// back — the opposite of what this helper is for.
|
||||
expect(res.headers['Cache-Control']).toBe('no-store');
|
||||
});
|
||||
|
||||
it('honours a caller that wants a different missing-status', async () => {
|
||||
const res = makeRes();
|
||||
const stream = fs.createReadStream(path.join(os.tmpdir(), `gone2-${Date.now()}.zip`));
|
||||
|
||||
pipeStreamToResponse(stream, res, { missingStatus: 410 });
|
||||
await settle();
|
||||
|
||||
expect(res.statusCode).toBe(410);
|
||||
});
|
||||
|
||||
it('destroys the response instead of rewriting a status that is already sent', async () => {
|
||||
const res = makeRes();
|
||||
res.headersSent = true;
|
||||
|
||||
const stream = new Readable({ read() {} });
|
||||
pipeStreamToResponse(stream, res, { context: 'photo 9' });
|
||||
stream.emit('error', Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
await settle();
|
||||
|
||||
// Once bytes are on the wire a 404 is not available; a truncated image the
|
||||
// client would cache is worse than a broken connection.
|
||||
expect(res.destroyed).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a non-ENOENT failure as a 500 rather than a 404', async () => {
|
||||
const res = makeRes();
|
||||
const stream = new Readable({ read() {} });
|
||||
|
||||
pipeStreamToResponse(stream, res);
|
||||
stream.emit('error', Object.assign(new Error('disk exploded'), { code: 'EIO' }));
|
||||
await settle();
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Failed to serve file' });
|
||||
});
|
||||
|
||||
it('releases the source when the client hangs up mid-download', async () => {
|
||||
const res = makeRes();
|
||||
let destroyed = false;
|
||||
const stream = new Readable({ read() {}, destroy(err, cb) { destroyed = true; cb(err); } });
|
||||
|
||||
pipeStreamToResponse(stream, res);
|
||||
res.emit('close');
|
||||
await settle();
|
||||
|
||||
// Otherwise an abandoned grid leaks one open fd per tile.
|
||||
expect(destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
+9
-46
@@ -1,39 +1,13 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Database configuration for different environments
|
||||
const resolveSqliteFilename = (filenameEnv) => {
|
||||
const fallback = path.join(__dirname, './data/photo_sharing.db');
|
||||
|
||||
if (!filenameEnv) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const trimmed = String(filenameEnv).trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let resolved;
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
resolved = trimmed;
|
||||
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
||||
resolved = path.resolve(__dirname, trimmed);
|
||||
} else {
|
||||
resolved = path.join(__dirname, trimmed);
|
||||
}
|
||||
|
||||
const normalized = path.normalize(resolved);
|
||||
const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname));
|
||||
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
|
||||
|
||||
if (normalized.includes(duplicatePattern)) {
|
||||
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
// Shared with the engine guard (#1038) so both resolve the identical path.
|
||||
const { resolveSqliteFilename } = require('./src/utils/sqlitePath');
|
||||
// One resolution of the PostgreSQL target for the whole application (#1038).
|
||||
// The development and production blocks used to carry different host/user/
|
||||
// database defaults, so a process that probed or migrated against one could
|
||||
// hand over to a process that opened another.
|
||||
const { pgConnectionFromEnv } = require('./src/utils/pgConnection');
|
||||
|
||||
const sqliteConnection = (filenameEnv) => ({
|
||||
filename: resolveSqliteFilename(filenameEnv)
|
||||
@@ -54,13 +28,7 @@ const baseSqliteConfig = {
|
||||
const config = {
|
||||
development: {
|
||||
client: process.env.DATABASE_CLIENT || 'sqlite3',
|
||||
connection: process.env.DATABASE_CLIENT === 'pg' ? {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'photo_sharing'
|
||||
} : {
|
||||
connection: process.env.DATABASE_CLIENT === 'pg' ? pgConnectionFromEnv() : {
|
||||
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
},
|
||||
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
|
||||
@@ -97,12 +65,7 @@ const config = {
|
||||
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
|
||||
connection: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
||||
? {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'picpeak',
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
...pgConnectionFromEnv(),
|
||||
// Connection stability settings
|
||||
connectionTimeoutMillis: 30000,
|
||||
idleTimeoutMillis: 30000,
|
||||
|
||||
@@ -77,7 +77,11 @@ const DEFAULT_CSS_TEMPLATE = `/*
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
/* 100%, not a fixed pixel height: every aspect-ratio layout (masonry,
|
||||
justified, mosaic, gallery-premium) gives .photo-card a definite height
|
||||
computed from photos.width/height, and this rule's specificity (0,1,1)
|
||||
beats the .h-full utility (0,1,0) the layouts rely on — #1131. */
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -503,7 +503,9 @@ const LIQUID_GLASS_DARK = `/*
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
/* See #1131: a fixed height here beats the layouts' .h-full utility and
|
||||
detaches the image from its aspect-ratio-sized card. */
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease, filter 0.4s ease;
|
||||
filter: brightness(0.9);
|
||||
@@ -639,7 +641,7 @@ const LIQUID_GLASS_DARK = `/*
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
height: 180px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Reduce animation complexity on mobile */
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Migration 167: give `projects` a first-class owner (GHSA-wrg5).
|
||||
*
|
||||
* Project routes authorize on generic `events.view` / `events.edit` only, with
|
||||
* no ownership check, so an editor-like admin could enumerate, read, update
|
||||
* and aggregate projects belonging to other admins' events.
|
||||
*
|
||||
* Ownership IS derivable transitively — `events.project_id` (migration 117)
|
||||
* plus `events.created_by` (migration 060) — but only for projects that have
|
||||
* at least one linked event. A freshly created, still-empty project has no
|
||||
* derivable owner, which would leave a hole exactly where the create → attach
|
||||
* flow starts. Storing the creator removes that ambiguity: projectService
|
||||
* already receives `adminId` in createProject() and simply discarded it.
|
||||
*
|
||||
* Backfill uses the transitive path, which is well-defined here: migration 117
|
||||
* created exactly one auto-project per pre-existing event, so those projects
|
||||
* map 1:1 to an owning event. Projects with no linked event (or whose events
|
||||
* are themselves ownerless legacy rows) stay NULL and are treated as
|
||||
* unowned//legacy by the ownership helper — same convention the events table
|
||||
* already uses for `created_by IS NULL`.
|
||||
*
|
||||
* down() drops the column; the derived data is reconstructible by re-running
|
||||
* the same backfill, so nothing is lost irreversibly.
|
||||
*/
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('projects'))) return;
|
||||
|
||||
if (!(await knex.schema.hasColumn('projects', 'created_by'))) {
|
||||
await knex.schema.alterTable('projects', (t) => {
|
||||
// No FK constraint: admin_users rows can be removed, and orphaning a
|
||||
// project would be worse than a dangling id (which reads as unowned).
|
||||
t.integer('created_by').nullable();
|
||||
});
|
||||
}
|
||||
|
||||
// Backfill from the linked events, only where we can determine it
|
||||
// unambiguously (every owning event agrees on a single non-null creator).
|
||||
if (await knex.schema.hasColumn('events', 'project_id')
|
||||
&& await knex.schema.hasColumn('events', 'created_by')) {
|
||||
const rows = await knex('events')
|
||||
.whereNotNull('project_id')
|
||||
.whereNotNull('created_by')
|
||||
.select('project_id', 'created_by')
|
||||
.groupBy('project_id', 'created_by');
|
||||
|
||||
const byProject = new Map();
|
||||
for (const row of rows) {
|
||||
const list = byProject.get(row.project_id) || [];
|
||||
list.push(row.created_by);
|
||||
byProject.set(row.project_id, list);
|
||||
}
|
||||
|
||||
for (const [projectId, creators] of byProject) {
|
||||
// Ambiguous (events from two different admins) → leave NULL rather than
|
||||
// guess an owner and hand one admin authority over another's work.
|
||||
if (creators.length !== 1) continue;
|
||||
await knex('projects')
|
||||
.where({ id: projectId })
|
||||
.whereNull('created_by')
|
||||
.update({ created_by: creators[0] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('projects'))) return;
|
||||
if (await knex.schema.hasColumn('projects', 'created_by')) {
|
||||
await knex.schema.alterTable('projects', (t) => t.dropColumn('created_by'));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* GHSA-jhcf — data correction for legacy accounting activity rows.
|
||||
*
|
||||
* expenseService called `logActivity(type, metadata, adminId)`, but the third
|
||||
* positional parameter of logActivity is `eventId`, not the actor. Every
|
||||
* expense / incoming-invoice entry therefore stored the ACTING ADMIN'S ID in
|
||||
* `activity_logs.event_id` (and no actor at all).
|
||||
*
|
||||
* That is not merely cosmetic. The dashboard activity feed now scopes rows via
|
||||
* `WHERE activity_logs.event_id IN (SELECT id FROM events WHERE created_by = me)`.
|
||||
* Admin ids and event ids are both small integers drawn from the same range, so
|
||||
* on any upgraded instance an editor who happens to own the event whose id
|
||||
* equals another admin's id is served that admin's accounting activity —
|
||||
* verbatim metadata included. Scoping new writes correctly does nothing for the
|
||||
* rows already on disk, so they are corrected here.
|
||||
*
|
||||
* The stored value is exactly the actor id we lost, so this re-attributes
|
||||
* rather than discards: event_id → actor_id (when no actor was recorded), then
|
||||
* event_id is cleared so the scope predicate can no longer match it.
|
||||
*
|
||||
* All ten activity types below are emitted by expenseService and nothing else,
|
||||
* so no row with a genuine event_id is touched.
|
||||
*/
|
||||
|
||||
const AFFECTED_TYPES = [
|
||||
'incoming_invoice_captured',
|
||||
'incoming_invoice_updated',
|
||||
'incoming_invoice_categorized',
|
||||
'incoming_invoice_rebilled',
|
||||
'incoming_invoices_rebilled_bundle',
|
||||
'incoming_invoice_supplier_payment',
|
||||
'expense_created',
|
||||
'expense_updated',
|
||||
'expense_invoiced',
|
||||
'expense_paid',
|
||||
];
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
if (!(await knex.schema.hasTable('activity_logs'))) return;
|
||||
if (!(await knex.schema.hasColumn('activity_logs', 'event_id'))) return;
|
||||
|
||||
const hasActorId = await knex.schema.hasColumn('activity_logs', 'actor_id');
|
||||
const hasActorType = await knex.schema.hasColumn('activity_logs', 'actor_type');
|
||||
|
||||
if (hasActorId) {
|
||||
const patch = { actor_id: knex.ref('event_id') };
|
||||
if (hasActorType) patch.actor_type = 'admin';
|
||||
await knex('activity_logs')
|
||||
.whereIn('activity_type', AFFECTED_TYPES)
|
||||
.whereNotNull('event_id')
|
||||
.whereNull('actor_id')
|
||||
.update(patch);
|
||||
}
|
||||
|
||||
await knex('activity_logs')
|
||||
.whereIn('activity_type', AFFECTED_TYPES)
|
||||
.whereNotNull('event_id')
|
||||
.update({ event_id: null });
|
||||
};
|
||||
|
||||
// Irreversible by design: this is a data correction, and the pre-migration
|
||||
// state is a cross-admin disclosure. Re-planting admin ids in event_id would
|
||||
// reopen GHSA-jhcf.
|
||||
exports.down = async function down() {};
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Migration 174: make events.event_date / events.expires_at nullable on SQLite (#1029).
|
||||
*
|
||||
* Migration 061 introduced the `event_require_event_date` /
|
||||
* `event_require_expiration` settings and dropped the NOT NULL on both columns
|
||||
* — but only for Postgres. It skipped SQLite on the premise that "SQLite
|
||||
* doesn't enforce NOT NULL as strictly", which is simply untrue: clearing the
|
||||
* expiration on a SQLite install fails with
|
||||
*
|
||||
* SQLITE_CONSTRAINT: NOT NULL constraint failed: events.expires_at
|
||||
*
|
||||
* so "never expires" has never been reachable there. This finishes 061 for
|
||||
* SQLite. Knex implements .alter() on SQLite by recreating the table; migration
|
||||
* 073 already does exactly that on `events`, so the path is well-trodden here.
|
||||
*
|
||||
* Postgres is skipped — 061 already handled it, and knex's .alter() rewrites
|
||||
* the whole column definition (type, default, nullability), which would be a
|
||||
* needless rewrite of a column that is already correct.
|
||||
*/
|
||||
|
||||
function isSqlite(knex) {
|
||||
const client = knex.client.config.client;
|
||||
return client === 'sqlite3' || client === 'better-sqlite3';
|
||||
}
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!isSqlite(knex)) return;
|
||||
|
||||
const hasEvents = await knex.schema.hasTable('events');
|
||||
if (!hasEvents) return;
|
||||
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.datetime('event_date').nullable().alter();
|
||||
table.datetime('expires_at').nullable().alter();
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Deliberately irreversible. Restoring NOT NULL would fail on any install
|
||||
// that has since created a gallery without an expiration — exactly what this
|
||||
// migration enables — and 061's down() takes the same position for Postgres.
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* The bundled CSS templates pinned every gallery image to a fixed pixel
|
||||
* height, which broke every aspect-ratio layout (#1131).
|
||||
*
|
||||
* Six of the seven layouts size a tile by putting a computed pixel height on
|
||||
* `.photo-card` and letting the image fill it with `h-full`. A template rule
|
||||
* of `.photo-card img { height: 200px }` has specificity (0,1,1) and beats
|
||||
* `.h-full` at (0,1,0), so the image detached from its card: masonry rendered
|
||||
* correctly-shaped cards with a 200px image glued to the top and empty
|
||||
* background below — or, where the computed card was shorter than 200px, an
|
||||
* image taller than its own container.
|
||||
*
|
||||
* "Elegant Dark" is seeded `is_default = true`, so this was the out-of-the-box
|
||||
* result for anyone choosing any layout other than grid/timeline (where a
|
||||
* fixed square happens to look deliberate).
|
||||
*
|
||||
* Migrations 052 and 053 are corrected for fresh installs; this repairs the
|
||||
* rows already seeded. Templates are referenced by `events.css_template_id`
|
||||
* and read at serve time rather than copied onto the event, so fixing the row
|
||||
* fixes every gallery using it.
|
||||
*
|
||||
* SCOPE: every `.photo-card img` rule that carries a fixed PIXEL height, in
|
||||
* every template — not just the two we seeded, and not just their pristine
|
||||
* copies.
|
||||
*
|
||||
* That is broader than it first looks, and deliberately so. It is also not the
|
||||
* scope this started with: matching the exact seeded text missed every install
|
||||
* where the template had ever been saved through the editor, because
|
||||
* `sanitizeCSS` strips newlines. Those are the majority, and a migration that
|
||||
* silently no-ops on them while being recorded as applied is worse than none.
|
||||
*
|
||||
* The cost is that a fixed pixel height a user wrote themselves is rewritten
|
||||
* too. That is judged acceptable because there is no layout it can be right
|
||||
* for: all seven give `.photo-card` a definite height and expect the image to
|
||||
* fill it, so a pixel height on the image can only detach it from its card.
|
||||
* Anything that is not a fixed px height — %, vh, auto — is left alone, as is
|
||||
* every declaration outside a `.photo-card img` body.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every `.photo-card img { … }` rule body, however it is spaced.
|
||||
*
|
||||
* Matching the exact seeded text does NOT work, and the reason is worth
|
||||
* stating: `sanitizeCSS` strips all control characters (cssSanitizer.js:61),
|
||||
* so the moment an admin saves a template through the editor — even only to
|
||||
* rename it or toggle it — every newline is REMOVED from the stored CSS. The
|
||||
* shipped `.photo-card img {\n height: 200px;` becomes
|
||||
* `.photo-card img { height: 200px;`. An exact-match migration would find
|
||||
* nothing on those installs, be recorded as applied, and leave the galleries
|
||||
* broken with no second chance.
|
||||
*
|
||||
* Scoped to the rule body rather than the whole stylesheet, so the other pixel
|
||||
* heights in these same templates — a 1px gradient divider, an 8px scrollbar —
|
||||
* are untouched.
|
||||
*/
|
||||
/*
|
||||
* Two details in this pattern are deliberate:
|
||||
*
|
||||
* * the selector part is a LIST, so `.photo-card img, .thumbnail img { … }`
|
||||
* is recognised. Requiring `{` straight after `img` skipped grouped
|
||||
* selectors entirely — and the migration would still be recorded as
|
||||
* applied, so the template kept the bug with no second chance.
|
||||
*
|
||||
* * the body excludes braces, so a rule containing a NESTED block is not
|
||||
* matched at all. `.photo-card img { & + .caption { height: 200px } }` is
|
||||
* valid, passes the validator, and a `[^}]*` body would have captured the
|
||||
* nested block and rewritten the caption's height instead. Skipping it
|
||||
* means such a template keeps a fixed image height; corrupting unrelated
|
||||
* declarations in a migration that cannot be undone is the worse of the
|
||||
* two, and nesting does not appear in anything we ship.
|
||||
*/
|
||||
const PHOTO_CARD_IMG_RULE = /([^{}]*\.photo-card\s+img[^{}]*)\{([^{}]*)\}/g;
|
||||
|
||||
/**
|
||||
* Only a fixed PIXEL height is wrong here; %, vh, auto and the rest stay.
|
||||
*
|
||||
* The lookbehind is load-bearing rather than defensive: without it the pattern
|
||||
* matches the TAIL of `line-height`, `max-height`, `min-height` and any custom
|
||||
* property ending in `-height`, and silently rewrites those instead — in a
|
||||
* migration whose down() is deliberately irreversible.
|
||||
*/
|
||||
const FIXED_PX_HEIGHT = /(?<![\w-])height\s*:\s*\d+(?:\.\d+)?px/gi;
|
||||
|
||||
function relaxFixedImageHeights(css) {
|
||||
return css.replace(PHOTO_CARD_IMG_RULE, (whole, selectors, body) => {
|
||||
// .test() on a /g regex advances lastIndex, so it is reset on both sides
|
||||
// of the check — leaving it set makes the NEXT rule start matching from an
|
||||
// arbitrary offset and silently skip declarations.
|
||||
FIXED_PX_HEIGHT.lastIndex = 0;
|
||||
if (!FIXED_PX_HEIGHT.test(body)) return whole;
|
||||
FIXED_PX_HEIGHT.lastIndex = 0;
|
||||
return `${selectors}{${body.replace(FIXED_PX_HEIGHT, 'height: 100%')}}`;
|
||||
});
|
||||
}
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
if (!(await knex.schema.hasTable('css_templates'))) return;
|
||||
|
||||
const rows = await knex('css_templates').select('id', 'css_content');
|
||||
let fixed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const original = row.css_content;
|
||||
if (!original || typeof original !== 'string') continue;
|
||||
|
||||
const updated = relaxFixedImageHeights(original);
|
||||
|
||||
if (updated !== original) {
|
||||
await knex('css_templates').where({ id: row.id }).update({ css_content: updated });
|
||||
fixed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed > 0) {
|
||||
console.log(` 175: relaxed the fixed image height in ${fixed} CSS template(s)`);
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down() {
|
||||
// Deliberately irreversible. Putting the pixel heights back would re-break
|
||||
// every aspect-ratio layout, and the rows may have been edited since — there
|
||||
// is no version of "restore" here that is safer than doing nothing.
|
||||
};
|
||||
@@ -276,11 +276,51 @@ async function runMigrations() {
|
||||
}
|
||||
|
||||
// Add delay for database readiness in production
|
||||
// Engine consistency check (#1038). The entrypoint resolves the engine before
|
||||
// migrations run and exports DATABASE_CLIENT, so this normally agrees and does
|
||||
// nothing. It bites on a MANUAL migration run: without that env, an install
|
||||
// that is really on SQLite would resolve to Postgres here and build a schema in
|
||||
// the empty database, which then hides the SQLite data from the boot-time
|
||||
// check. Stop instead, and say which env to set.
|
||||
async function assertEngine() {
|
||||
const knexConfig = require('../knexfile');
|
||||
const logger = require('../src/utils/logger');
|
||||
const { resolveBootEngine } = require('../src/utils/databaseEngine');
|
||||
const decision = await resolveBootEngine({ knexConfig, logger });
|
||||
if (decision.reason === 'marker-target-mismatch') {
|
||||
console.error(
|
||||
'Refusing to migrate: this install was migrated to a different PostgreSQL than the\n'
|
||||
+ 'one currently configured. The resolver printed both targets above.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (decision.reason === 'ambiguous-both-populated') {
|
||||
// Both databases hold data and nothing records which is current; the
|
||||
// resolver has already printed the comparison. There is no client to
|
||||
// recommend here — the operator has to pick one.
|
||||
console.error(
|
||||
'Refusing to migrate: SQLite and PostgreSQL both hold data and neither is marked\n'
|
||||
+ 'as current. Set DATABASE_CLIENT=pg or DATABASE_CLIENT=sqlite3 to say which one\n'
|
||||
+ 'this command should touch.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (decision.client !== knexConfig.client) {
|
||||
console.error(
|
||||
`Refusing to migrate ${knexConfig.client} — this install's data is in ${decision.client}.\n`
|
||||
+ `Run migrations through the container entrypoint, or set DATABASE_CLIENT=${decision.client} explicitly.\n`
|
||||
+ 'To move the data across instead: node scripts/migrate-sqlite-to-postgres.js'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitAndRun() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.log('Waiting 2 seconds for database readiness...');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
await assertEngine();
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
|
||||
@@ -46,10 +46,50 @@ async function runMigration(filepath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Engine consistency check (#1038). The entrypoint resolves the engine before
|
||||
// migrations run and exports DATABASE_CLIENT, so this normally agrees and does
|
||||
// nothing. It bites on a MANUAL migration run: without that env, an install
|
||||
// that is really on SQLite would resolve to Postgres here and build a schema in
|
||||
// the empty database, which then hides the SQLite data from the boot-time
|
||||
// check. Stop instead, and say which env to set.
|
||||
async function assertEngine() {
|
||||
const knexConfig = require('../knexfile');
|
||||
const logger = require('../src/utils/logger');
|
||||
const { resolveBootEngine } = require('../src/utils/databaseEngine');
|
||||
const decision = await resolveBootEngine({ knexConfig, logger });
|
||||
if (decision.reason === 'marker-target-mismatch') {
|
||||
console.error(
|
||||
'Refusing to migrate: this install was migrated to a different PostgreSQL than the\n'
|
||||
+ 'one currently configured. The resolver printed both targets above.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (decision.reason === 'ambiguous-both-populated') {
|
||||
// Both databases hold data and nothing records which is current; the
|
||||
// resolver has already printed the comparison. There is no client to
|
||||
// recommend here — the operator has to pick one.
|
||||
console.error(
|
||||
'Refusing to migrate: SQLite and PostgreSQL both hold data and neither is marked\n'
|
||||
+ 'as current. Set DATABASE_CLIENT=pg or DATABASE_CLIENT=sqlite3 to say which one\n'
|
||||
+ 'this command should touch.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (decision.client !== knexConfig.client) {
|
||||
console.error(
|
||||
`Refusing to migrate ${knexConfig.client} — this install's data is in ${decision.client}.\n`
|
||||
+ `Run migrations through the container entrypoint, or set DATABASE_CLIENT=${decision.client} explicitly.\n`
|
||||
+ 'To move the data across instead: node scripts/migrate-sqlite-to-postgres.js'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Main migration runner
|
||||
async function runMigrations() {
|
||||
try {
|
||||
console.log('Starting database migrations...');
|
||||
await assertEngine();
|
||||
|
||||
// First run the init.js if it exists but only if migrations table doesn't exist
|
||||
const tableExists = await db.schema.hasTable('migrations');
|
||||
|
||||
Generated
+65
-59
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.6",
|
||||
"version": "3.46.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.6",
|
||||
"version": "3.46.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -42,13 +42,14 @@
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"p-limit": "^3.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
"postcss": "8.5.18",
|
||||
"postcss": "8.5.23",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sanitize-html": "2.17.5",
|
||||
"sharp": "0.35.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
@@ -4498,9 +4499,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -5226,6 +5227,12 @@
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -5308,9 +5315,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz",
|
||||
"integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/rebeccastevens"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -6785,19 +6802,7 @@
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text/node_modules/htmlparser2": {
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
@@ -6816,23 +6821,16 @@
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
@@ -7116,9 +7114,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -7993,9 +7991,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8232,6 +8230,15 @@
|
||||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/launder": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
|
||||
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.7"
|
||||
}
|
||||
},
|
||||
"node_modules/lazystream": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||
@@ -9079,9 +9086,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9558,7 +9565,6 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
@@ -10036,9 +10042,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.18",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz",
|
||||
"integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==",
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -10055,7 +10061,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -10846,15 +10852,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
|
||||
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
|
||||
"version": "2.17.5",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
|
||||
"integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^8.0.0",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"launder": "^1.7.1",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
}
|
||||
@@ -12590,7 +12597,6 @@
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.9",
|
||||
"version": "3.46.4",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
@@ -51,13 +51,14 @@
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"p-limit": "^3.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
"postcss": "8.5.18",
|
||||
"postcss": "8.5.23",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sanitize-html": "2.17.5",
|
||||
"sharp": "0.35.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
@@ -84,14 +85,15 @@
|
||||
"fast-xml-parser": ">=5.7.0",
|
||||
"qs": ">=6.15.2",
|
||||
"tar": ">=7.5.21",
|
||||
"brace-expansion": ">=5.0.7",
|
||||
"brace-expansion": ">=5.0.9",
|
||||
"minimatch": ">=9.0.7",
|
||||
"path-to-regexp": "0.1.13",
|
||||
"lodash": ">=4.18.1",
|
||||
"follow-redirects": ">=1.16.0",
|
||||
"@tootallnate/once": ">=3.0.1",
|
||||
"ip-address": ">=10.1.1",
|
||||
"ip-address": ">=10.3.1",
|
||||
"uuid": "^11.1.1",
|
||||
"nodemailer": "^9.0.1"
|
||||
"nodemailer": "^9.0.1",
|
||||
"deepmerge-ts": ">=8.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Move an install's data from SQLite to PostgreSQL (#1038).
|
||||
*
|
||||
* node scripts/migrate-sqlite-to-postgres.js [--force] [--keep-archive]
|
||||
*
|
||||
* For installs that have been unknowingly running on SQLite: the image used to
|
||||
* leave NODE_ENV unset, so knexfile.js fell back to its development block and
|
||||
* ignored DB_HOST/DB_USER/DB_PASSWORD. Their galleries live in the SQLite file
|
||||
* while the Postgres database they provisioned sits empty.
|
||||
*
|
||||
* This deliberately reuses the .picpeak export/import services rather than
|
||||
* hand-rolling a cross-engine copy — they already solve the parts that are easy
|
||||
* to get wrong: foreign-key suspension during the load, JSON column handling
|
||||
* per engine, and (critically) resyncing Postgres serial sequences after rows
|
||||
* are inserted with explicit ids.
|
||||
*
|
||||
* Both services bind to the global `db` at require time, so each half runs in
|
||||
* its own child process with DATABASE_CLIENT pinned — this script re-invokes
|
||||
* itself with --phase for that.
|
||||
*
|
||||
* Photos and other files on disk are NOT touched: only database rows move. The
|
||||
* SQLite file is left exactly as it was, so the migration is reversible by
|
||||
* unsetting DATABASE_CLIENT again.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const BACKEND_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Same configuration sources the running backend uses. Without these, invoking
|
||||
// this CLI directly (or via `docker exec`, which does not inherit the exports
|
||||
// wait-for-db.sh performs) would fail the pre-flight checks below even though
|
||||
// the child phases would happily read backend/.env through knexfile.
|
||||
require('dotenv').config({ path: path.join(BACKEND_ROOT, '.env') });
|
||||
for (const [varName, file] of [['DB_PASSWORD', 'db_password'], ['JWT_SECRET', 'jwt_secret']]) {
|
||||
const secretFile = `/run/secrets/${file}`;
|
||||
if (!process.env[varName] && fs.existsSync(secretFile)) {
|
||||
try {
|
||||
process.env[varName] = fs.readFileSync(secretFile, 'utf8').trim();
|
||||
} catch (_) { /* unreadable secret — the checks below report it */ }
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
return {
|
||||
force: argv.includes('--force'),
|
||||
keepArchive: argv.includes('--keep-archive'),
|
||||
phase: (argv.find((a) => a.startsWith('--phase=')) || '').split('=')[1] || null,
|
||||
archive: (argv.find((a) => a.startsWith('--archive=')) || '').split('=')[1] || null,
|
||||
resultFile: (argv.find((a) => a.startsWith('--result-file=')) || '').split('=')[1] || null,
|
||||
ignoreBootstrapAdmins: argv.includes('--ignore-bootstrap-admins'),
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the Postgres target ONCE, with production defaults, and hand the same
|
||||
// explicit values to every child. Otherwise the block knexfile happens to pick
|
||||
// decides the database name, and the migration can land somewhere the running
|
||||
// application will never open (#1038 review).
|
||||
function normalisedPgEnv() {
|
||||
const { pgConnectionFromEnv } = require('../src/utils/databaseEngine');
|
||||
const c = pgConnectionFromEnv();
|
||||
return {
|
||||
DB_HOST: String(c.host),
|
||||
DB_PORT: String(c.port),
|
||||
DB_USER: String(c.user),
|
||||
DB_NAME: String(c.database),
|
||||
};
|
||||
}
|
||||
|
||||
function runPhase(phase, client, extraArgs = []) {
|
||||
// The child's stdout is NOT a private channel: winston logs to the console
|
||||
// outside production and whenever LOG_TO_CONSOLE=true, so the payload comes
|
||||
// back through a file instead.
|
||||
const resultFile = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), `picpeak-phase-${phase}-`)), 'result',
|
||||
);
|
||||
try {
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[__filename, `--phase=${phase}`, `--result-file=${resultFile}`, ...extraArgs],
|
||||
{
|
||||
cwd: BACKEND_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
...normalisedPgEnv(),
|
||||
DATABASE_CLIENT: client,
|
||||
// Production semantics for the child regardless of how the CLI was
|
||||
// invoked: the development block ignores DB_SSL, so a managed Postgres
|
||||
// that requires TLS could not be migrated into at all.
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
if (res.status !== 0) {
|
||||
throw new Error(`${phase} phase failed (exit ${res.status})`);
|
||||
}
|
||||
return fs.existsSync(resultFile) ? fs.readFileSync(resultFile, 'utf8').trim() : '';
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(resultFile), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── phases (each runs in its own process, with DATABASE_CLIENT pinned) ────────
|
||||
|
||||
async function phaseExport() {
|
||||
const { createPicpeak } = require('../src/services/picpeakExportService');
|
||||
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-migration-'));
|
||||
// Rows only. This moves an install between engines on the SAME machine, so
|
||||
// every file is already where it belongs; hauling business docs through /tmp
|
||||
// would just risk filling the temp disk.
|
||||
try {
|
||||
const { filePath } = await createPicpeak({ includePhotos: false, includeFiles: false, outDir });
|
||||
return filePath;
|
||||
} catch (err) {
|
||||
// createPicpeak leaves a caller-supplied outDir alone on failure, and a
|
||||
// partial archive still contains password hashes and credentials.
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Tables that are EMPTY on a freshly migrated schema, so any row in them means
|
||||
// a human has used this install. Used to protect the target from being wiped
|
||||
// and to decide whether the source is worth migrating (#1038 review). Tables
|
||||
// missing on a given branch are skipped.
|
||||
const USER_DATA_TABLES = [
|
||||
'events', 'photos', 'photo_feedback', 'admin_users', 'customer_accounts',
|
||||
'quotes', 'invoices', 'projects', 'expenses', 'inbound_documents',
|
||||
];
|
||||
|
||||
async function tablesWithData(db, tables, { ignoreBootstrapAdmins = false } = {}) {
|
||||
const { adminsIndicateUse } = require('../src/utils/databaseEngine');
|
||||
const found = {};
|
||||
for (const table of tables) {
|
||||
if (!(await db.schema.hasTable(table))) continue;
|
||||
if (table === 'admin_users' && ignoreBootstrapAdmins) {
|
||||
// Match probePgData: one never-used seeded admin is not "user data", or
|
||||
// the migration would demand --force against an empty target.
|
||||
const cols = ['must_change_password'];
|
||||
if (await db.schema.hasColumn('admin_users', 'last_login')) cols.push('last_login');
|
||||
const rows = await db('admin_users').select(cols);
|
||||
if (adminsIndicateUse(rows)) found[table] = rows.length;
|
||||
continue;
|
||||
}
|
||||
const row = await db(table).count('* as count').first();
|
||||
const count = Number(row?.count || 0);
|
||||
if (count > 0) found[table] = count;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async function phaseUserData(ignoreBootstrapAdmins) {
|
||||
const { db } = require('../src/database/db');
|
||||
return JSON.stringify(await tablesWithData(db, USER_DATA_TABLES, { ignoreBootstrapAdmins }));
|
||||
}
|
||||
|
||||
// Fingerprint EVERY table the export carries, not a hand-picked few: writes to
|
||||
// an unlisted table were invisible, and count+maxId alone misses in-place
|
||||
// UPDATEs (an event edit, a password change). max(updated_at) covers those
|
||||
// wherever the column exists. Still not a substitute for stopping the backend —
|
||||
// a table with neither `id` nor `updated_at` can be edited unnoticed — which is
|
||||
// why the script says so up front.
|
||||
async function phaseFingerprint() {
|
||||
const { db } = require('../src/database/db');
|
||||
const { listDataTables } = require('../src/services/picpeakExportService');
|
||||
const out = {};
|
||||
for (const table of await listDataTables()) {
|
||||
const entry = {};
|
||||
try {
|
||||
entry.count = Number((await db(table).count('* as count').first())?.count || 0);
|
||||
} catch (_) {
|
||||
continue; // table vanished mid-run; the export would fail on it anyway
|
||||
}
|
||||
for (const [key, col] of [['maxId', 'id'], ['maxUpdated', 'updated_at']]) {
|
||||
try {
|
||||
const row = await db(table).max(`${col} as v`).first();
|
||||
if (row && row.v !== null && row.v !== undefined) entry[key] = String(row.v);
|
||||
} catch (_) { /* column doesn't exist on this table */ }
|
||||
}
|
||||
out[table] = entry;
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
async function phaseMigrateSchema() {
|
||||
// runMigrations() exits the process itself (0 on success, 1 on failure), so the
|
||||
// child's exit code is the result — nothing to return.
|
||||
const { runMigrations } = require('../migrations/run-migrations-safe');
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
async function phaseImport(archivePath) {
|
||||
const { importFromPicpeak } = require('../src/services/picpeakImportService');
|
||||
// No currentAdminId: this is a CLI, there is no operator session to preserve.
|
||||
// The SQLite install's own admin accounts come across with everything else.
|
||||
// sqlite → pg is allowed by validateManifest's direction policy (#1041) —
|
||||
// the same gate the upload/restore UI uses, no separate opt-in flag.
|
||||
const summary = await importFromPicpeak({ picpeakPath: archivePath });
|
||||
return JSON.stringify(summary || {});
|
||||
}
|
||||
|
||||
function summariseUserData(found) {
|
||||
return Object.entries(found).map(([t, n]) => `${t}=${n}`).join(', ');
|
||||
}
|
||||
|
||||
function describeDrift(before, after) {
|
||||
const drifted = [];
|
||||
for (const table of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||
const a = before[table] || {};
|
||||
const b = after[table] || {};
|
||||
if (a.count !== b.count) {
|
||||
drifted.push(`${table}: ${a.count ?? 0} rows → ${b.count ?? 0}`);
|
||||
} else if (a.maxId !== b.maxId || a.maxUpdated !== b.maxUpdated) {
|
||||
drifted.push(`${table}: rows edited in place (max id ${a.maxId ?? '-'} → ${b.maxId ?? '-'}, `
|
||||
+ `last update ${a.maxUpdated ?? '-'} → ${b.maxUpdated ?? '-'})`);
|
||||
}
|
||||
}
|
||||
return drifted;
|
||||
}
|
||||
|
||||
// Set once the export exists; every failure path clears it (the archive holds
|
||||
// plaintext secrets, so leaving it behind on error is not acceptable).
|
||||
let archiveToClean = null;
|
||||
|
||||
function cleanupArchive() {
|
||||
if (!archiveToClean) return;
|
||||
try {
|
||||
fs.rmSync(path.dirname(archiveToClean), { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.error(` WARNING: could not remove ${archiveToClean} (${err.message}) — it contains`
|
||||
+ ' plaintext secrets, delete it by hand.');
|
||||
}
|
||||
archiveToClean = null;
|
||||
}
|
||||
|
||||
// ── orchestration ────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Child phase. The knex pool holds the event loop open, so finish by flushing
|
||||
// stdout and exiting explicitly — otherwise the parent's spawnSync waits on a
|
||||
// process that will never end by itself.
|
||||
if (args.phase) {
|
||||
const payload = args.phase === 'export' ? await phaseExport()
|
||||
: args.phase === 'fingerprint' ? await phaseFingerprint()
|
||||
: args.phase === 'user-data' ? await phaseUserData(args.ignoreBootstrapAdmins)
|
||||
: args.phase === 'import' ? await phaseImport(args.archive)
|
||||
: await phaseMigrateSchema();
|
||||
if (args.resultFile) fs.writeFileSync(args.resultFile, String(payload ?? ''));
|
||||
// The knex pool holds the event loop open; exit explicitly or the parent's
|
||||
// spawnSync waits on a process that will never end by itself.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { resolveSqlitePath } = require('../src/utils/databaseEngine');
|
||||
const sqlitePath = resolveSqlitePath();
|
||||
|
||||
console.log('PicPeak — SQLite → PostgreSQL migration\n');
|
||||
|
||||
if (!fs.existsSync(sqlitePath)) {
|
||||
console.error(`No SQLite database at ${sqlitePath}. Nothing to migrate.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (process.env.DATABASE_CLIENT && process.env.DATABASE_CLIENT !== 'pg') {
|
||||
console.error(
|
||||
`This deployment pins DATABASE_CLIENT=${process.env.DATABASE_CLIENT}.\n`
|
||||
+ 'After the migration the application must run on PostgreSQL — the SQLite file is\n'
|
||||
+ 'renamed out of the way, so a restart with this setting would create a NEW, empty\n'
|
||||
+ 'SQLite database and serve that instead of your data.\n\n'
|
||||
+ 'Set DATABASE_CLIENT=pg (or remove it) in your deployment, then run this again.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Not a refusal: an unset NODE_ENV is exactly the state the affected installs
|
||||
// are in, and refusing would block the people this script is for. The success
|
||||
// marker makes the boot resolve to Postgres regardless; this just tells the
|
||||
// operator to make it explicit.
|
||||
if (!process.env.DATABASE_CLIENT && require('../knexfile').client !== 'pg') {
|
||||
console.log(
|
||||
'Note: this environment resolves to SQLite (NODE_ENV is not "production" and\n'
|
||||
+ 'DATABASE_CLIENT is unset). The migration will still complete and the marker it\n'
|
||||
+ 'writes makes the app use PostgreSQL afterwards, but set NODE_ENV=production (or\n'
|
||||
+ 'DATABASE_CLIENT=pg) so the configuration says what is actually happening.\n'
|
||||
);
|
||||
}
|
||||
|
||||
if (!process.env.DB_HOST && !process.env.DB_PASSWORD) {
|
||||
console.error(
|
||||
'No PostgreSQL settings found (DB_HOST / DB_PASSWORD). Set them the way the\n'
|
||||
+ 'backend does, then re-run this script inside the container.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
'Stop the backend before running this. If it keeps serving while the copy runs,\n'
|
||||
+ 'anything written after the export is left behind in SQLite and becomes invisible\n'
|
||||
+ 'once the engine switches. This script checks for that afterwards and fails loudly,\n'
|
||||
+ 'but stopping the container first is the only way to be sure.\n'
|
||||
);
|
||||
|
||||
const sourceData = JSON.parse(runPhase('user-data', 'sqlite3'));
|
||||
console.log(` source : ${sqlitePath} — ${summariseUserData(sourceData) || 'no user data'}`);
|
||||
if (!Object.keys(sourceData).length) {
|
||||
console.error(
|
||||
'\nThe SQLite database holds no user data at all (no galleries, admins, customers or\n'
|
||||
+ 'accounting records). There is nothing to migrate.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const sqliteBefore = JSON.parse(runPhase('fingerprint', 'sqlite3'));
|
||||
|
||||
// Read the target BEFORE creating the schema: migration 001 seeds a bootstrap
|
||||
// admin when ADMIN_PASSWORD is set (common on legacy installs), and counting
|
||||
// that as "user data" would refuse a migration into a genuinely empty
|
||||
// database — pushing the operator towards --force for no reason.
|
||||
const { hasMigrationInProgress, migrationInProgressPath } = require('../src/utils/databaseEngine');
|
||||
// The retry allowance is bound to the TARGET, not just to this SQLite file:
|
||||
// if the operator repointed DB_HOST/DB_NAME since the failed attempt, the
|
||||
// rows in front of us belong to some other database and must not be replaced
|
||||
// without an explicit --force.
|
||||
const pgEnv = normalisedPgEnv();
|
||||
const targetId = `${pgEnv.DB_HOST}:${pgEnv.DB_PORT}/${pgEnv.DB_NAME}`;
|
||||
let retryingOwnRun = false;
|
||||
if (hasMigrationInProgress(sqlitePath)) {
|
||||
try {
|
||||
const pin = JSON.parse(fs.readFileSync(migrationInProgressPath(sqlitePath), 'utf8'));
|
||||
retryingOwnRun = pin.target === targetId;
|
||||
if (!retryingOwnRun) {
|
||||
console.log(` (an earlier attempt targeted ${pin.target}; this run targets ${targetId})`);
|
||||
}
|
||||
} catch (_) {
|
||||
retryingOwnRun = false; // unreadable pin — treat as unknown, require --force
|
||||
}
|
||||
}
|
||||
const targetData = JSON.parse(runPhase('user-data', 'pg', ['--ignore-bootstrap-admins']));
|
||||
console.log(` target : postgres — ${summariseUserData(targetData) || 'empty'}`);
|
||||
if (retryingOwnRun && Object.keys(targetData).length) {
|
||||
// Whatever is in Postgres came from a previous attempt of THIS script that
|
||||
// never completed — re-running is the documented recovery, so don't make
|
||||
// the operator reach for a destructive-sounding flag to do it.
|
||||
console.log(' (an earlier migration did not finish; re-running replaces what it left behind)');
|
||||
} else if (Object.keys(targetData).length && !args.force) {
|
||||
console.error(
|
||||
`\nPostgreSQL already holds user data (${summariseUserData(targetData)}).\n`
|
||||
+ 'The import REPLACES every table, so this would delete it — including admins,\n'
|
||||
+ 'customers and accounting records that have no galleries attached.\n'
|
||||
+ 'Re-run with --force only if you are certain you want that data gone.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pin the boot to SQLite for the duration. Everything below writes to
|
||||
// Postgres — schema creation alone seeds a bootstrap admin when
|
||||
// ADMIN_PASSWORD is set — and a run that dies half way would otherwise leave
|
||||
// Postgres looking occupied enough for the next restart to switch to it.
|
||||
const inProgress = migrationInProgressPath(sqlitePath);
|
||||
fs.writeFileSync(inProgress, JSON.stringify({
|
||||
started_at: new Date().toISOString(),
|
||||
target: targetId,
|
||||
}, null, 2));
|
||||
|
||||
// Now build the schema — the import replaces table CONTENTS, it never creates
|
||||
// them, and a fresh database has no tables at all.
|
||||
//
|
||||
// core/001_init.js writes data/ADMIN_CREDENTIALS.txt when ADMIN_PASSWORD is
|
||||
// set, and that data directory belongs to the SOURCE install — so bootstrapping
|
||||
// the schema would replace the operator's real credentials file with ones for
|
||||
// a temporary admin the import then discards. Preserve it across the phase.
|
||||
const credFile = path.join(BACKEND_ROOT, 'data', 'ADMIN_CREDENTIALS.txt');
|
||||
const credBefore = fs.existsSync(credFile) ? fs.readFileSync(credFile) : null;
|
||||
console.log('\n Preparing PostgreSQL schema…');
|
||||
try {
|
||||
runPhase('migrate-schema', 'pg');
|
||||
} finally {
|
||||
if (credBefore !== null) fs.writeFileSync(credFile, credBefore);
|
||||
else fs.rmSync(credFile, { force: true });
|
||||
}
|
||||
|
||||
console.log('\n Exporting rows from SQLite…');
|
||||
const archive = runPhase('export', 'sqlite3');
|
||||
// From here on, every exit path must remove the archive: it holds password
|
||||
// hashes, SMTP credentials and API keys in plaintext.
|
||||
archiveToClean = args.keepArchive ? null : archive;
|
||||
const sizeMb = (fs.statSync(archive).size / 1024 / 1024).toFixed(1);
|
||||
console.log(` archive: ${archive} (${sizeMb} MB)`);
|
||||
|
||||
// Check BEFORE touching Postgres: if the backend wrote to SQLite while the
|
||||
// export ran, the snapshot is already incomplete and there is no reason to
|
||||
// load it. Bailing here leaves Postgres exactly as it was.
|
||||
const driftDuringExport = describeDrift(sqliteBefore, JSON.parse(runPhase('fingerprint', 'sqlite3')));
|
||||
if (driftDuringExport.length) {
|
||||
console.error(
|
||||
'\nSQLite CHANGED WHILE THE EXPORT RAN — the backend is still writing to it:\n'
|
||||
+ driftDuringExport.map((d) => ` ${d}`).join('\n')
|
||||
+ '\n\nNothing was loaded into Postgres, and this install stays pinned to SQLite\n'
|
||||
+ 'until a run completes. Stop the backend and run this again.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n Loading into PostgreSQL…');
|
||||
runPhase('import', 'pg', [`--archive=${archive}`]);
|
||||
|
||||
// And again afterwards: writes can also land while the load runs, and those
|
||||
// rows would vanish from view the moment the engine switches.
|
||||
const driftDuringImport = describeDrift(sqliteBefore, JSON.parse(runPhase('fingerprint', 'sqlite3')));
|
||||
if (driftDuringImport.length) {
|
||||
console.error(
|
||||
'\nSQLite CHANGED WHILE THE IMPORT RAN — the backend is still writing to it:\n'
|
||||
+ driftDuringImport.map((d) => ` ${d}`).join('\n')
|
||||
+ '\n\nPostgres now holds an incomplete copy. Your SQLite data is intact and stays\n'
|
||||
+ 'the one being served — the boot is pinned to it until a run completes. Stop the\n'
|
||||
+ 'backend and run this again; the import replaces every table, so re-running is safe.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Row-for-row comparison of the whole database, not just galleries: every
|
||||
// table the export carried must have arrived with the same row count.
|
||||
const targetAfter = JSON.parse(runPhase('fingerprint', 'pg'));
|
||||
// Only a SHORTFALL is a problem. The import legitimately adds rows of its own
|
||||
// afterwards — setSessionsValidAfter() writes an app_settings row so tokens
|
||||
// minted before the restore stop authenticating — and a target that gained
|
||||
// rows has not lost anything.
|
||||
const missing = [];
|
||||
const gained = [];
|
||||
const skipped = [];
|
||||
for (const [table, src] of Object.entries(sqliteBefore)) {
|
||||
const dst = targetAfter[table];
|
||||
if (!dst) {
|
||||
// SQLite-only tables exist: initializeDatabase() builds an `events_new`
|
||||
// scratch table and, if its legacy copy throws, the catch leaves the empty
|
||||
// table behind (db.js). The importer correctly skips tables Postgres does
|
||||
// not have — so an ABSENT table only matters if it actually held rows.
|
||||
// Flagging empty ones failed the whole migration after the data had
|
||||
// already landed, leaving the install pinned to SQLite forever.
|
||||
if (src.count > 0) missing.push(`${table}: ${src.count} rows, no such table in Postgres`);
|
||||
else skipped.push(table);
|
||||
continue;
|
||||
}
|
||||
if (dst.count < src.count) missing.push(`${table}: ${src.count} rows → ${dst.count}`);
|
||||
else if (dst.count > src.count) gained.push(`${table}: ${src.count} → ${dst.count}`);
|
||||
}
|
||||
if (skipped.length) {
|
||||
console.log(` (empty SQLite-only tables with no Postgres counterpart, skipped: ${skipped.join(', ')})`);
|
||||
}
|
||||
if (gained.length) console.log(` (rows added by the import itself: ${gained.join(', ')})`);
|
||||
console.log(`\n PostgreSQL now holds ${summariseUserData(JSON.parse(runPhase('user-data', 'pg')))}.`);
|
||||
|
||||
if (missing.length) {
|
||||
console.error(
|
||||
'\nROW COUNTS DO NOT MATCH — Postgres did not receive everything:\n'
|
||||
+ missing.map((m) => ` ${m}`).join('\n')
|
||||
+ '\n\nYour SQLite data is untouched and stays the one being served — the boot is\n'
|
||||
+ 'pinned to it until a run completes. Report this with the list above.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pin the engine choice so a later "Postgres looks empty" moment can never
|
||||
// send the install back to this now-stale file.
|
||||
const { migrationMarkerPath } = require('../src/utils/databaseEngine');
|
||||
const marker = migrationMarkerPath(sqlitePath);
|
||||
const retired = `${sqlitePath}.pre-postgres-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
||||
|
||||
// Marker FIRST, rename second. The other order has a window where a failure
|
||||
// (a full disk, say) leaves the source renamed away with no success marker:
|
||||
// the next run reports "No SQLite database", the in-progress pin is still
|
||||
// there, and the operator never sees the rollback path. Writing the marker
|
||||
// first means a failure here leaves everything exactly where it was.
|
||||
fs.writeFileSync(marker, JSON.stringify({
|
||||
migrated_at: new Date().toISOString(),
|
||||
retired_sqlite_file: null,
|
||||
target: targetId,
|
||||
}, null, 2));
|
||||
|
||||
let retiredTo = null;
|
||||
try {
|
||||
fs.renameSync(sqlitePath, retired);
|
||||
retiredTo = retired;
|
||||
fs.writeFileSync(marker, JSON.stringify({
|
||||
migrated_at: new Date().toISOString(),
|
||||
retired_sqlite_file: retiredTo,
|
||||
target: targetId,
|
||||
}, null, 2));
|
||||
} catch (err) {
|
||||
// The marker already pins the engine to Postgres, so leaving the file in
|
||||
// place is safe — it just is not renamed out of the way.
|
||||
console.log(` (could not rename the SQLite file: ${err.message} — leaving it in place)`);
|
||||
}
|
||||
// Success — release the pin. Order matters: the success marker exists before
|
||||
// the pin is dropped, so no restart in between can pick the wrong engine.
|
||||
fs.rmSync(inProgress, { force: true });
|
||||
|
||||
if (args.keepArchive) {
|
||||
console.log(` archive kept at ${archive} — it contains plaintext secrets, delete it when done`);
|
||||
} else {
|
||||
cleanupArchive();
|
||||
}
|
||||
|
||||
console.log(`
|
||||
Done. Your data is now in PostgreSQL.
|
||||
|
||||
rollback copy : ${retiredTo || sqlitePath}
|
||||
marker : ${marker}
|
||||
|
||||
Restart the container to pick up PostgreSQL. Keep the rollback copy until you
|
||||
have confirmed the galleries look right.
|
||||
|
||||
To roll back, all three steps are needed — with data on both sides the boot
|
||||
picks PostgreSQL, so restoring the file alone changes nothing:
|
||||
|
||||
1. rm ${marker}
|
||||
2. mv ${retiredTo || sqlitePath} ${sqlitePath}
|
||||
3. set DATABASE_CLIENT=sqlite3 in your deployment
|
||||
`);
|
||||
}
|
||||
|
||||
process.on('exit', cleanupArchive);
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`\nMigration failed: ${err.message}`);
|
||||
console.error('Nothing was changed in SQLite; your data is still there.');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,141 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to regenerate missing thumbnails for photos in the database
|
||||
* Usage: node scripts/regenerate-thumbnails.js [eventId]
|
||||
* Fill in missing thumbnails for photos already in the database.
|
||||
*
|
||||
* The CLI fallback for when the admin UI is not reachable. It is deliberately
|
||||
* "missing only": ensureThumbnail short-circuits on a thumbnail that is
|
||||
* already present and valid, so re-running this is cheap and safe. To REBUILD
|
||||
* everything after a settings change, use POST /api/admin/thumbnails/regenerate
|
||||
* — that path drops the existing renditions first, which this one must not do.
|
||||
*
|
||||
* Resolution goes through ensureThumbnail rather than a hand-built path
|
||||
* (#1148, same defect as #1129). This script used to compute
|
||||
* `storage/events/active/<photo.path>` and fs.access it, a location that does
|
||||
* not exist for `external` or `reference` rows — their originals live under
|
||||
* the mount in events.external_path. Every such photo failed the check and was
|
||||
* counted as an error, so on an external-media install the script was inert
|
||||
* while reporting one error per photo.
|
||||
*
|
||||
* ensureThumbnail already branches on source_origin, resolves both kinds via
|
||||
* photoResolver, uses the per-photo `ext<id>_` output name so two events
|
||||
* referencing one NAS basename cannot clobber each other, and writes
|
||||
* thumbnail_path back itself. Sharing it is what stops the script and the
|
||||
* route drifting apart again.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/regenerate-thumbnails.js [eventId]
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const sharp = require('sharp');
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
// Configuration
|
||||
const THUMBNAIL_SIZE = 300;
|
||||
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
|
||||
|
||||
async function ensureDirectoryExists(dirPath) {
|
||||
try {
|
||||
await fs.access(dirPath);
|
||||
} catch {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
console.log(`Created directory: ${dirPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateThumbnail(photoPath, thumbnailPath) {
|
||||
try {
|
||||
await sharp(photoPath)
|
||||
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
|
||||
fit: 'cover',
|
||||
position: 'center'
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate thumbnail for ${photoPath}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const { ensureThumbnail, isThumbnailValid } = require('../src/services/imageProcessor');
|
||||
|
||||
async function regenerateThumbnails(eventId = null) {
|
||||
try {
|
||||
console.log('Starting thumbnail regeneration...');
|
||||
console.log(`Storage path: ${STORAGE_PATH}`);
|
||||
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
|
||||
|
||||
// Ensure thumbnails directory exists
|
||||
await ensureDirectoryExists(THUMBNAILS_DIR);
|
||||
|
||||
// Build query
|
||||
let query = db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.path',
|
||||
'photos.thumbnail_path',
|
||||
'events.slug as event_slug'
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
query = query.where('photos.event_id', eventId);
|
||||
console.log(`Filtering for event ID: ${eventId}`);
|
||||
}
|
||||
|
||||
const photos = await query;
|
||||
console.log(`Found ${photos.length} photos to process`);
|
||||
|
||||
let successCount = 0;
|
||||
let skipCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const photo of photos) {
|
||||
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
|
||||
const thumbnailFilename = `thumb_${photo.filename}`;
|
||||
const thumbnailPath = path.join(THUMBNAILS_DIR, thumbnailFilename);
|
||||
|
||||
try {
|
||||
// Check if photo file exists
|
||||
await fs.access(photoPath);
|
||||
|
||||
// Check if thumbnail already exists
|
||||
try {
|
||||
await fs.access(thumbnailPath);
|
||||
console.log(`Thumbnail already exists for ${photo.filename}, skipping...`);
|
||||
skipCount++;
|
||||
continue;
|
||||
} catch {
|
||||
// Thumbnail doesn't exist, generate it
|
||||
}
|
||||
|
||||
console.log(`Generating thumbnail for ${photo.filename}...`);
|
||||
const success = await generateThumbnail(photoPath, thumbnailPath);
|
||||
|
||||
if (success) {
|
||||
// Update database with thumbnail path
|
||||
await db('photos')
|
||||
.where('id', photo.id)
|
||||
.update({
|
||||
thumbnail_path: `thumbnails/${thumbnailFilename}`
|
||||
});
|
||||
|
||||
successCount++;
|
||||
console.log(`✓ Generated thumbnail for ${photo.filename}`);
|
||||
} else {
|
||||
errorCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`✗ Photo file not found: ${photoPath}`);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nThumbnail regeneration complete!');
|
||||
console.log(`- Successfully generated: ${successCount}`);
|
||||
console.log(`- Skipped (already exist): ${skipCount}`);
|
||||
console.log(`- Errors: ${errorCount}`);
|
||||
console.log(`- Total processed: ${photos.length}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during thumbnail regeneration:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
console.log('Starting thumbnail regeneration...');
|
||||
|
||||
// These columns are what ensureThumbnail branches on to resolve a source and
|
||||
// name its output. Selecting a subset that misses
|
||||
// source_origin/external_relpath is how the old path bug would come back —
|
||||
// an external row would look managed and resolve under events/active.
|
||||
let query = db('photos').select(
|
||||
'id', 'event_id', 'path', 'filename', 'thumbnail_path',
|
||||
'type', 'media_type', 'mime_type', 'source_origin', 'external_relpath'
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
query = query.where('event_id', eventId);
|
||||
console.log(`Filtering for event ID: ${eventId}`);
|
||||
}
|
||||
|
||||
// Skip videos. A video's thumbnail is a poster frame produced by
|
||||
// videoProcessor, not a resize of the stored file, so handing the container
|
||||
// to Sharp here only ever produced one error per row.
|
||||
//
|
||||
// Tested on every marker a video row can carry, not media_type alone:
|
||||
// fileWatcher.processNewPhoto writes `type` and `mime_type` but never
|
||||
// media_type, which defaults to 'image' — so an auto-imported video passes a
|
||||
// media_type-only filter. Each clause is null-safe on its own so a row that
|
||||
// simply has no mime_type is not swept up with them.
|
||||
query = query
|
||||
.where(function () {
|
||||
this.whereNull('media_type').orWhere('media_type', '!=', 'video');
|
||||
})
|
||||
.where(function () {
|
||||
this.whereNull('type').orWhere('type', '!=', 'video');
|
||||
})
|
||||
.where(function () {
|
||||
this.whereNull('mime_type').orWhereNot('mime_type', 'like', 'video/%');
|
||||
});
|
||||
|
||||
const photos = await query;
|
||||
console.log(`Found ${photos.length} photos to process`);
|
||||
|
||||
let successCount = 0;
|
||||
let skipCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const photo of photos) {
|
||||
const label = photo.filename || `photo ${photo.id}`;
|
||||
try {
|
||||
const existing = photo.thumbnail_path;
|
||||
// Asked BEFORE the call, not inferred from the returned path afterwards.
|
||||
// On local and external storage the key is deterministic, so repairing a
|
||||
// missing or corrupt thumbnail hands back the identical string — and
|
||||
// comparing paths would report that repair as "already valid", which is
|
||||
// the one number an operator running this is actually reading.
|
||||
const wasValid = existing ? await isThumbnailValid(existing) : false;
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
if (!thumbnailPath) {
|
||||
console.error(`✗ Could not generate thumbnail for ${label}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (wasValid && thumbnailPath === existing) {
|
||||
skipCount++;
|
||||
} else {
|
||||
successCount++;
|
||||
console.log(`✓ Generated thumbnail for ${label}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed for ${label}: ${error.message}`);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nThumbnail regeneration complete!');
|
||||
console.log(`- Generated: ${successCount}`);
|
||||
console.log(`- Skipped (already valid): ${skipCount}`);
|
||||
console.log(`- Errors: ${errorCount}`);
|
||||
console.log(`- Total processed: ${photos.length}`);
|
||||
|
||||
return { successCount, skipCount, errorCount };
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const eventArg = args.find((a) => !a.startsWith('--'));
|
||||
const eventId = eventArg ? parseInt(eventArg, 10) : null;
|
||||
|
||||
// Run the script
|
||||
regenerateThumbnails(eventId).then(() => {
|
||||
console.log('Script completed successfully');
|
||||
process.exit(0);
|
||||
}).catch(error => {
|
||||
console.error('Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
if (eventArg && !Number.isInteger(eventId)) {
|
||||
console.error(`Not an event id: ${eventArg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
regenerateThumbnails(eventId)
|
||||
.then(async (result) => {
|
||||
await db.destroy();
|
||||
// Exit status is the only thing a cron job reads. Resolving with a
|
||||
// nonzero errorCount and still exiting 0 told automation the backfill
|
||||
// was done when it had failed — which is how an unavailable mount stays
|
||||
// unnoticed until someone opens a gallery.
|
||||
if (result.errorCount) {
|
||||
console.error(`Script completed with failures: ${result.errorCount} photo(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error('Script failed:', error);
|
||||
await db.destroy().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { regenerateThumbnails };
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Prints the database client this boot should use — `pg` or `sqlite3` — for
|
||||
* wait-for-db.sh to export as DATABASE_CLIENT (#1038).
|
||||
*
|
||||
* Runs BEFORE the migration step on purpose: the decision has to be made while
|
||||
* the Postgres target is still untouched, so an install that has been
|
||||
* unknowingly running on SQLite keeps serving from its SQLite file instead of
|
||||
* coming up against an empty database.
|
||||
*
|
||||
* stdout is the client and nothing else — the caller captures it. Everything
|
||||
* human-readable goes to stderr so it lands in the container log.
|
||||
*/
|
||||
|
||||
const knexConfig = require('../knexfile');
|
||||
|
||||
// Must cover every level resolveBootEngine uses. An incomplete shim threw
|
||||
// inside the conflict path, was swallowed by the catch below, and fell back to
|
||||
// the configured client — silently choosing the engine this is meant to refuse
|
||||
// to choose.
|
||||
const logger = {
|
||||
info: (m) => process.stderr.write(`${m}\n`),
|
||||
warn: (m) => process.stderr.write(`${m}\n`),
|
||||
error: (m) => process.stderr.write(`${m}\n`),
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
// Distinct exit code for "two populated databases, no record of which is
|
||||
// current" (#1038). Callers must stop rather than pick one.
|
||||
const CONFLICT_EXIT = 3;
|
||||
|
||||
(async () => {
|
||||
let client = knexConfig.client;
|
||||
try {
|
||||
const { resolveBootEngine } = require('../src/utils/databaseEngine');
|
||||
const decision = await resolveBootEngine({ knexConfig, logger });
|
||||
if (decision.reason === 'ambiguous-both-populated'
|
||||
|| decision.reason === 'marker-target-mismatch') {
|
||||
process.exit(CONFLICT_EXIT);
|
||||
}
|
||||
({ client } = decision);
|
||||
} catch (err) {
|
||||
// Never let engine detection stop a boot: fall back to whatever knexfile
|
||||
// resolved, which is exactly the behaviour before this script existed.
|
||||
logger.warn(`Database engine detection failed (${err.message}); using ${client}`);
|
||||
}
|
||||
process.stdout.write(String(client || ''));
|
||||
process.exit(0);
|
||||
})();
|
||||
@@ -14,17 +14,14 @@ const bcrypt = require('bcrypt');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const knex = require('knex');
|
||||
const db = knex({
|
||||
client: process.env.DB_CLIENT || 'pg',
|
||||
connection: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD || 'picpeak',
|
||||
database: process.env.DB_NAME || 'picpeak_dev'
|
||||
}
|
||||
});
|
||||
// Use the application's own connection, like every sibling script here
|
||||
// (reset-admin-password, create-admin, show-admin-credentials, reset-admin-mfa).
|
||||
// This file used to hand-roll its own knex config, which meant: it read
|
||||
// DB_CLIENT — a variable nothing else in the codebase sets — and so defaulted
|
||||
// to Postgres on SQLite installs; and it defaulted to database `picpeak_dev`,
|
||||
// a name no other component uses. Setting a password could therefore silently
|
||||
// target a different database than the one the application serves (#1038).
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
/**
|
||||
* Validate password strength
|
||||
@@ -111,8 +108,10 @@ async function setAdminPassword() {
|
||||
.where('username', 'admin')
|
||||
.update({
|
||||
password_hash: hashedPassword,
|
||||
password_changed_at: new Date(),
|
||||
updated_at: new Date()
|
||||
// ISO strings, not Date objects — they round-trip on both engines, and
|
||||
// this script now runs on SQLite installs too.
|
||||
password_changed_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (updated === 0) {
|
||||
|
||||
+66
-4
@@ -4,11 +4,61 @@ require('dotenv').config();
|
||||
const { validateEnvironment } = require('./src/config/validateEnv');
|
||||
validateEnvironment();
|
||||
|
||||
// Resolve which database engine this process should use, BEFORE anything
|
||||
// requires knexfile/db (#1038). wait-for-db.sh normally does this and exports
|
||||
// DATABASE_CLIENT, but a Kubernetes manifest that sets `command`/`args`, or a
|
||||
// plain `docker run … node server.js`, bypasses the entrypoint entirely — and
|
||||
// those are exactly the deployments this fix is for. Without this, such an
|
||||
// install would resolve to Postgres (NODE_ENV is baked into the image now) and
|
||||
// come up against an empty database while its SQLite data sat there unseen.
|
||||
//
|
||||
// spawnSync because the decision needs an async Postgres probe and this must
|
||||
// happen before the first `require` of knexfile. It short-circuits without
|
||||
// probing when DATABASE_CLIENT is already set, so the entrypoint path pays
|
||||
// nothing.
|
||||
// Also run it when a migration pin exists: an explicit DATABASE_CLIENT=pg
|
||||
// would otherwise skip the check and start against a half-migrated Postgres
|
||||
// while SQLite is still the database of record.
|
||||
if (!process.env.DATABASE_CLIENT
|
||||
|| require('./src/utils/databaseEngine').hasMigrationInProgress()) {
|
||||
const { spawnSync } = require('child_process');
|
||||
const probe = spawnSync(
|
||||
process.execPath,
|
||||
[require('path').join(__dirname, 'scripts', 'resolve-db-engine.js')],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }
|
||||
);
|
||||
// Exit 3: two populated databases and no record of which is authoritative.
|
||||
// The resolver has printed the comparison and the two ways to resolve it;
|
||||
// starting either engine would hide the other's data.
|
||||
if (probe.status === 3) {
|
||||
process.exit(1);
|
||||
}
|
||||
const resolved = (probe.stdout || '').trim();
|
||||
if (probe.status === 0 && resolved) {
|
||||
process.env.DATABASE_CLIENT = resolved;
|
||||
// Pin the CONNECTION too, not just the client. knexfile's development block
|
||||
// defaults Postgres to localhost/postgres/photo_sharing and production to
|
||||
// db/picpeak/picpeak, so naming only the client can point this process at a
|
||||
// different database than the resolver probed — with SQLite already retired.
|
||||
if (resolved === 'pg') {
|
||||
const conn = require('./src/utils/databaseEngine').pgConnectionFromEnv();
|
||||
process.env.DB_HOST = String(conn.host);
|
||||
process.env.DB_PORT = String(conn.port);
|
||||
process.env.DB_USER = String(conn.user);
|
||||
process.env.DB_NAME = String(conn.database);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize logger early to capture startup logs
|
||||
const logger = require('./src/utils/logger');
|
||||
logger.info('Server starting up', {
|
||||
nodeVersion: process.version,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
// Which database this process actually talks to (#1038). Nothing logged this
|
||||
// before, so an install silently running on SQLite with Postgres configured
|
||||
// had no way to notice.
|
||||
database: require('./src/utils/databaseEngine').describeEngine(require('./knexfile')),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
@@ -1005,8 +1055,15 @@ async function startServer() {
|
||||
// Runs AFTER install-from-backup so a restored instance (which repopulates
|
||||
// admin_users) never prints a throwaway token. Best-effort — never blocks boot.
|
||||
let setupToken = null;
|
||||
let setupTokenFile = null;
|
||||
try {
|
||||
setupToken = await require('./src/services/setupService').ensureSetupToken();
|
||||
const setupSvc = require('./src/services/setupService');
|
||||
setupToken = await setupSvc.ensureSetupToken();
|
||||
// The path the write ACTUALLY produced (null when it failed). existsSync
|
||||
// on the candidate answered a different question and reported success
|
||||
// for a stale, read-only or directory-shaped SETUP_TOKEN — suppressing
|
||||
// the token here while pointing the operator at content that is not it.
|
||||
setupTokenFile = setupSvc.writtenSetupTokenFile();
|
||||
} catch (err) {
|
||||
logger.warn(`[setup] ensureSetupToken skipped: ${err.message}`);
|
||||
}
|
||||
@@ -1026,12 +1083,17 @@ async function startServer() {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
|
||||
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
|
||||
// First-run: print the one-time setup token to STDOUT (the file logger
|
||||
// doesn't reach `docker logs`), as the last + most visible thing at boot.
|
||||
// First-run banner. Print the TOKEN ITSELF only when the 0600 token file
|
||||
// could not be written — otherwise this lands a live first-admin
|
||||
// credential in `docker logs` / journald, which is the leak GHSA-r794's
|
||||
// sweep turned up. When the file exists we point at it instead.
|
||||
if (setupToken) {
|
||||
const url = `${process.env.ADMIN_URL || 'http://localhost:3000'}/admin`;
|
||||
const line = '='.repeat(64);
|
||||
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n One-time setup token: ${setupToken}\n (also saved to data/SETUP_TOKEN)\n${line}\n`);
|
||||
const secretLine = setupTokenFile
|
||||
? ` Setup token saved to: ${setupTokenFile}\n (read it there — deliberately not printed)`
|
||||
: ` One-time setup token: ${setupToken}\n (could not write the token file, so it is shown here)`;
|
||||
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n${secretLine}\n${line}\n`);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -52,7 +52,11 @@ describe('publicSiteService', () => {
|
||||
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||
|
||||
expect(payload.enabled).toBe(true);
|
||||
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
|
||||
// Brand tokens are HTML-escaped on substitution now (GHSA-j347), so a bare
|
||||
// `&` in the company name is emitted as the `&` entity. That renders
|
||||
// identically in a browser — it is the correctly-encoded form — but the raw
|
||||
// payload string differs from the pre-fix output.
|
||||
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
|
||||
expect(payload.html).not.toContain('<script');
|
||||
expect(payload.baseCss.length).toBeGreaterThan(0);
|
||||
expect(payload.branding.companyName).toBe('Willow & Pine Studio');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isMissingRolesSchema } = require('../utils/dbErrors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const TOKEN_PREFIX = 'pp_live_';
|
||||
@@ -63,10 +65,36 @@ async function apiTokenAuth(req, res, next) {
|
||||
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
|
||||
}
|
||||
|
||||
const admin = await db('admin_users')
|
||||
.where({ id: row.created_by, is_active: true })
|
||||
.select('id', 'username', 'email', 'role_id')
|
||||
.first();
|
||||
// Load the owner WITH their role name (GHSA-9697). Without it,
|
||||
// req.admin.roleName was undefined — and every ownership check keys on
|
||||
// roleName — so the v1 surface could not tell a super_admin from a
|
||||
// demoted viewer. Mirrors adminAuth's shape, including the
|
||||
// roles-table-missing fallback used during upgrades.
|
||||
let admin;
|
||||
try {
|
||||
admin = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where({ 'admin_users.id': row.created_by, 'admin_users.is_active': formatBoolean(true) })
|
||||
.select(
|
||||
'admin_users.id',
|
||||
'admin_users.username',
|
||||
'admin_users.email',
|
||||
'roles.id as role_id',
|
||||
'roles.name as role_name'
|
||||
)
|
||||
.first();
|
||||
} catch (joinError) {
|
||||
// Fail CLOSED on anything that isn't a genuinely missing roles schema:
|
||||
// the fallback fabricates super_admin, so a transient query failure must
|
||||
// not become a free privilege upgrade. Rethrow → outer catch → 500.
|
||||
if (!isMissingRolesSchema(joinError)) throw joinError;
|
||||
logger.debug('Roles table not available in apiTokenAuth', { error: joinError.message });
|
||||
admin = await db('admin_users')
|
||||
.where({ id: row.created_by, is_active: formatBoolean(true) })
|
||||
.select('id', 'username', 'email', 'role_id')
|
||||
.first();
|
||||
if (admin) admin.role_name = 'super_admin'; // upgrade-path parity with adminAuth
|
||||
}
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Token owner unavailable', code: 'OWNER_INACTIVE' });
|
||||
}
|
||||
@@ -75,7 +103,15 @@ async function apiTokenAuth(req, res, next) {
|
||||
db('api_tokens').where({ id: row.id }).update({ last_used_at: new Date() })
|
||||
.catch((err) => logger.debug('api_tokens last_used update failed', { err: err.message }));
|
||||
|
||||
req.admin = admin;
|
||||
// Same shape adminAuth produces, so requirePermission / ownership helpers
|
||||
// behave identically whether the caller used a session or an API token.
|
||||
req.admin = {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
roleId: admin.role_id,
|
||||
roleName: admin.role_name
|
||||
};
|
||||
req.apiToken = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -118,6 +154,7 @@ module.exports = {
|
||||
generateApiToken,
|
||||
hashToken,
|
||||
parseScopes,
|
||||
isMissingRolesSchema,
|
||||
TOKEN_PREFIX,
|
||||
VALID_SCOPES
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isMissingRolesSchema } = require('../utils/dbErrors');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
@@ -75,6 +76,14 @@ async function adminAuth(req, res, next) {
|
||||
)
|
||||
.first();
|
||||
} catch (joinError) {
|
||||
// Fail CLOSED on anything that isn't a genuinely missing roles schema:
|
||||
// the fallback below fabricates super_admin, so a transient query failure
|
||||
// (connection reset, deadlock, statement timeout, pool exhaustion) must
|
||||
// not become a free privilege upgrade for every scoped admin. Rethrow →
|
||||
// outer catch → 401, which is already how a transient DB fault in this
|
||||
// try block behaves (isTokenRevoked hits the DB here). apiTokenAuth takes
|
||||
// the same posture on the v1 surface, differing only in its 500.
|
||||
if (!isMissingRolesSchema(joinError)) throw joinError;
|
||||
// Fallback: roles table may not exist yet during upgrade
|
||||
// Query without role join - user will have no role info but can still authenticate
|
||||
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
|
||||
|
||||
@@ -32,6 +32,20 @@ function requireEventOwnership(req, res, next) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the ownership predicate to a knex query over `events`, for list
|
||||
* endpoints that can't use requireEventOwnership (no :id to check).
|
||||
* super_admin is unrestricted; everyone else sees ownerless (legacy/system)
|
||||
* events plus their own — the same rule requireEventOwnership enforces
|
||||
* per-row.
|
||||
*/
|
||||
function scopeEventsQuery(query, admin, column = 'created_by') {
|
||||
if (admin?.roleName === 'super_admin') {
|
||||
return query;
|
||||
}
|
||||
return query.where((q) => q.whereNull(column).orWhere(column, admin.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the subset of `eventIds` the admin may act on, mirroring
|
||||
* requireEventOwnership for bulk routes that can't use it (they take an
|
||||
@@ -64,4 +78,88 @@ async function filterOwnedEventIds(admin, eventIds) {
|
||||
return { allowed, denied };
|
||||
}
|
||||
|
||||
module.exports = { requireEventOwnership, filterOwnedEventIds };
|
||||
/**
|
||||
* Knex subquery selecting the ids of projects `admin` may act on, or `null`
|
||||
* when the caller is unrestricted (GHSA-wrg5).
|
||||
*
|
||||
* Rules, in priority order:
|
||||
* 1. A project's STORED owner is authoritative. If `projects.created_by` is
|
||||
* set to a live admin, only that admin (and super_admin) may act on it.
|
||||
* Earlier this union'd in "any linked event I can see", which meant one
|
||||
* legacy ownerless event inside another admin's project exposed the whole
|
||||
* project — its other events, invoices and emails — through the overview.
|
||||
* 2. Only when there is NO usable stored owner (NULL, or pointing at a
|
||||
* deleted admin) do we derive from linked events, and then EVERY linked
|
||||
* event must be accessible: a project the old unrestricted routes filled
|
||||
* with several admins' events is ambiguous, and migration 167 deliberately
|
||||
* leaves those NULL. Granting on "any" would have made exactly those
|
||||
* mixed projects readable by everyone.
|
||||
* 3. A project with no usable owner AND no linked events (an orphan — not
|
||||
* creatable since createProject stamps created_by) stays super_admin-only.
|
||||
* Failing closed beats failing open; a super_admin can reassign it.
|
||||
*
|
||||
* Returned as a subquery so callers avoid materialising an id list.
|
||||
*/
|
||||
function ownedProjectsSubquery(admin) {
|
||||
if (admin?.roleName === 'super_admin') return null;
|
||||
|
||||
const linkedEvents = () => db('events').select(db.raw('1')).whereRaw('events.project_id = projects.id');
|
||||
|
||||
return db('projects').select('projects.id').where((w) => {
|
||||
w.where('projects.created_by', admin.id)
|
||||
.orWhere((noOwner) => {
|
||||
noOwner
|
||||
// No usable stored owner: NULL, or a creator that no longer exists
|
||||
// (hard-deleted admin) — otherwise that project would be locked away
|
||||
// from everyone but super_admin forever.
|
||||
.where((c) => c
|
||||
.whereNull('projects.created_by')
|
||||
.orWhereNotIn('projects.created_by', db('admin_users').select('id')))
|
||||
.whereExists(linkedEvents())
|
||||
.whereNotExists(
|
||||
linkedEvents().whereNotNull('events.created_by').whereNot('events.created_by', admin.id),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialised form of ownedProjectsSubquery, for callers that need the ids
|
||||
* themselves. `null` = unrestricted.
|
||||
*
|
||||
* @returns {Promise<number[]|null>}
|
||||
*/
|
||||
async function ownedProjectIds(admin) {
|
||||
const sub = ownedProjectsSubquery(admin);
|
||||
if (sub === null) return null;
|
||||
const rows = await sub;
|
||||
return rows.map((r) => Number(r.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware enforcing ownedProjectIds() on a :id project route. 404 (not 403)
|
||||
* on a foreign project so the endpoint isn't an existence oracle — same
|
||||
* posture filterOwnedEventIds takes for foreign-vs-missing ids.
|
||||
*/
|
||||
function requireProjectOwnership(req, res, next) {
|
||||
const sub = ownedProjectsSubquery(req.admin);
|
||||
if (sub === null) return next();
|
||||
const projectId = Number(req.params.id);
|
||||
sub.clone()
|
||||
.where('projects.id', projectId)
|
||||
.first()
|
||||
.then((row) => {
|
||||
if (!row) return res.status(404).json({ error: 'Project not found' });
|
||||
next();
|
||||
})
|
||||
.catch(() => res.status(500).json({ error: 'Failed to verify project ownership' }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
requireEventOwnership,
|
||||
filterOwnedEventIds,
|
||||
scopeEventsQuery,
|
||||
ownedProjectIds,
|
||||
ownedProjectsSubquery,
|
||||
requireProjectOwnership,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
async function photoAuth(req, res, next) {
|
||||
@@ -89,7 +90,33 @@ async function photoAuth(req, res, next) {
|
||||
|
||||
// Check if it's an admin token (admins can view all photos)
|
||||
if (decoded.type === 'admin') {
|
||||
// For both thumbnails and photos with admin token, allow access
|
||||
// Enforce the same revocation / session-cutoff invalidation that
|
||||
// adminAuth does — otherwise a validly-signed admin JWT keeps
|
||||
// serving photos after logout, password change, or explicit
|
||||
// revocation (GHSA-x55x).
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
return res.status(401).json({ error: 'Session expired' });
|
||||
}
|
||||
// adminAuth also (a) rejects tokens for a now-deactivated admin and
|
||||
// (b) rejects any token minted before the admin's last password
|
||||
// change. Token revocation alone doesn't cover those, so without
|
||||
// these two checks a stale or pre-password-change admin token still
|
||||
// fetches every photo.
|
||||
const admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.select('id', 'password_changed_at')
|
||||
.first();
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Session expired' });
|
||||
}
|
||||
if (admin.password_changed_at) {
|
||||
const passwordChangedSeconds = Math.floor(
|
||||
new Date(admin.password_changed_at).getTime() / 1000
|
||||
);
|
||||
if (decoded.iat < passwordChangedSeconds) {
|
||||
return res.status(401).json({ error: 'Session expired' });
|
||||
}
|
||||
}
|
||||
return next();
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -19,7 +19,10 @@ const router = express.Router();
|
||||
// the plaintext, never recoverable after creation.
|
||||
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const tokens = await db('api_tokens')
|
||||
// Scope to the caller's own tokens unless super_admin — the previous
|
||||
// query returned every admin's token metadata (name/preview/scopes/
|
||||
// owner) to any settings.view holder (GHSA-jm7j).
|
||||
const tokensQuery = db('api_tokens')
|
||||
.leftJoin('admin_users', 'admin_users.id', 'api_tokens.created_by')
|
||||
.select(
|
||||
'api_tokens.id',
|
||||
@@ -33,6 +36,10 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
'admin_users.username as owner_username'
|
||||
)
|
||||
.orderBy('api_tokens.created_at', 'desc');
|
||||
if (req.admin.roleName !== 'super_admin') {
|
||||
tokensQuery.where('api_tokens.created_by', req.admin.id);
|
||||
}
|
||||
const tokens = await tokensQuery;
|
||||
res.json(tokens);
|
||||
} catch (error) {
|
||||
logger.error('Failed to list API tokens', { error: error.message });
|
||||
@@ -101,6 +108,12 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
const { id } = req.params;
|
||||
const row = await db('api_tokens').where({ id }).first();
|
||||
if (!row) return res.status(404).json({ error: 'Token not found' });
|
||||
// Only the token's owner (or a super_admin) may revoke it — otherwise
|
||||
// any settings.edit holder could revoke another admin's tokens
|
||||
// (GHSA-gprq). 404 rather than 403 so a non-owner can't probe token ids.
|
||||
if (req.admin.roleName !== 'super_admin' && row.created_by !== req.admin.id) {
|
||||
return res.status(404).json({ error: 'Token not found' });
|
||||
}
|
||||
if (row.revoked_at) return res.status(400).json({ error: 'Token already revoked' });
|
||||
|
||||
await db('api_tokens').where({ id }).update({ revoked_at: new Date() });
|
||||
|
||||
@@ -8,7 +8,7 @@ const { endSession } = require('../middleware/sessionTimeout');
|
||||
const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { setAdminAuthCookie, clearAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
const mfaService = require('../services/mfaService');
|
||||
const router = express.Router();
|
||||
@@ -168,12 +168,21 @@ router.post('/change-password', [
|
||||
|
||||
// Logout
|
||||
router.post('/logout', adminAuth, handleAsync(async (req, res) => {
|
||||
// Get token from header
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
// Use the token adminAuth actually authenticated with (req.token) — it may
|
||||
// have come from the admin_token cookie, not the Authorization header. The
|
||||
// old header-only read skipped revocation entirely for cookie-based logout,
|
||||
// leaving the JWT valid until expiry while reporting a successful logout.
|
||||
const token = req.token;
|
||||
if (token) {
|
||||
// End the session
|
||||
// End the in-memory session AND revoke the JWT (GHSA-cjqh) — the token
|
||||
// is otherwise valid until expiry, so photoAuth/adminAuth would keep
|
||||
// honouring it after logout. isTokenRevoked() checks this store.
|
||||
endSession(token);
|
||||
const { revokeToken } = require('../utils/tokenRevocation');
|
||||
await revokeToken(token, 'logout');
|
||||
}
|
||||
// Clear the auth cookie so the browser stops sending the (now revoked) JWT.
|
||||
clearAdminAuthCookie(res);
|
||||
|
||||
// Log activity
|
||||
await logActivity('admin_logout',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requirePermission, requireSuperAdmin } = require('../middleware/permissions');
|
||||
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { revokeToken } = require('../utils/tokenRevocation');
|
||||
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
||||
@@ -57,14 +57,33 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
|
||||
}
|
||||
break;
|
||||
case 's3':
|
||||
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
|
||||
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
|
||||
!updates.backup_s3_access_key || !updates.backup_s3_secret_key) {
|
||||
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// SSRF: validate an S3 endpoint whenever one is supplied — NOT only when
|
||||
// the payload also flips backup_destination_type to 's3'. The PUT
|
||||
// persists every backup_* field independently, so with S3 already
|
||||
// selected a caller could PATCH just backup_s3_endpoint to a
|
||||
// private-resolving host; the management ops (manifest, bucket/file
|
||||
// browse, cleanup, test-upload) then connect without going through
|
||||
// testConnection. Prod-only; dev points at localhost MinIO deliberately.
|
||||
if (process.env.NODE_ENV === 'production'
|
||||
&& updates.backup_s3_endpoint && updates.backup_s3_endpoint !== '••••••••') {
|
||||
const rawEndpoint = updates.backup_s3_endpoint;
|
||||
const withProto = /^https?:\/\//.test(rawEndpoint) ? rawEndpoint : `https://${rawEndpoint}`;
|
||||
let epHost = null;
|
||||
try { epHost = new URL(withProto).hostname; } catch { epHost = null; }
|
||||
const { isHostAllowed } = require('../utils/networkValidation');
|
||||
if (!epHost || !(await isHostAllowed(epHost))) {
|
||||
return res.status(400).json({ error: 'S3 endpoint resolves to a private or internal network address' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update settings
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (key.startsWith('backup_')) {
|
||||
@@ -139,7 +158,12 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r
|
||||
// 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) => {
|
||||
// Full-instance export dumps every table unredacted — bcrypt password
|
||||
// hashes, 2FA columns, and all integration secrets (SMTP/SSO/WhatsApp/
|
||||
// webhook/S3) in cleartext. The built-in `admin` role holds backup.create,
|
||||
// but is denied this data everywhere else (config APIs mask secrets as
|
||||
// ********). Gate the raw dump behind super_admin (GHSA-pv6w-rj34-wj9v).
|
||||
router.get('/picpeak/export', adminAuth, requireSuperAdmin(), async (req, res) => {
|
||||
const fsSync = require('fs');
|
||||
try {
|
||||
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
|
||||
@@ -216,6 +240,7 @@ router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), p
|
||||
tables: result.tables,
|
||||
filesRestored: result.filesRestored,
|
||||
usesExternalMedia: result.usesExternalMedia,
|
||||
crossEngine: result.crossEngine,
|
||||
sessionInvalidated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -351,9 +376,11 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
|
||||
break;
|
||||
}
|
||||
|
||||
// SSRF protection: block connections to private/internal addresses
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(host)) {
|
||||
// SSRF protection: resolve the host and block any private/internal
|
||||
// address. ssh does its own DNS at connect time, so a literal-only
|
||||
// check let a hostname resolving to an internal IP through (#GHSA-4jh8).
|
||||
const { isHostAllowed } = require('../utils/networkValidation');
|
||||
if (!(await isHostAllowed(host))) {
|
||||
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -249,33 +249,84 @@ router.get(
|
||||
const brandingLogoUrl = await getAppSetting('branding_logo_url');
|
||||
const resolved = await resolveLogoFile(profile);
|
||||
|
||||
// GHSA-29vm: report candidates RELATIVE to the storage roots rather than
|
||||
// echoing absolute container paths and process.cwd(). This endpoint exists
|
||||
// to answer "which candidate did/didn't exist", which relative paths answer
|
||||
// just as well without handing out the filesystem layout.
|
||||
const cwdStorage = path.join(process.cwd(), 'storage');
|
||||
const relativise = (p) => {
|
||||
for (const [name, root] of [['STORAGE', storageRoot], ['CWD_STORAGE', cwdStorage]]) {
|
||||
const rel = path.relative(root, p);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return `<${name}>/${rel.split(path.sep).join('/')}`;
|
||||
}
|
||||
}
|
||||
return path.basename(p);
|
||||
};
|
||||
|
||||
const inspect = (label, raw) => {
|
||||
const value = (raw || '').toString().trim();
|
||||
if (!value) return { label, value: null, candidates: [] };
|
||||
const stripped = value.replace(/^\/+/, '');
|
||||
const baseName = path.basename(value);
|
||||
// Mirrors resolveLogoFile's candidate list EXACTLY. It keeps the raw
|
||||
// absolute value as a candidate (multer stores branding_logo_path
|
||||
// absolute) and lets the storage-root containment filter reject it when
|
||||
// it points outside — so the diagnostic must include it too, or a
|
||||
// legitimately-contained absolute logo shows every candidate as missing
|
||||
// while resolvedTo names the file.
|
||||
// The stripped joins (`<ROOT>/<value-minus-leading-slash>`) are gated on
|
||||
// containment, NOT on path.isAbsolute(). isAbsolute() cannot tell a
|
||||
// multer disk path from a root-relative URL like `/custom/logo.png`, and
|
||||
// for the URL form `<STORAGE>/custom/logo.png` is a file the resolver
|
||||
// genuinely returns — skipping it made this endpoint report "no source
|
||||
// candidate exists" about a logo that renders fine.
|
||||
//
|
||||
// The gate is instead: does the raw value ALREADY resolve inside a
|
||||
// storage root? If so it is a real disk path, the raw candidate below
|
||||
// covers it, and the stripped join would only produce a double-prefixed
|
||||
// path that can never exist while re-embedding the absolute path
|
||||
// GHSA-29vm exists to stop echoing (redact() strips only the leading
|
||||
// root, so the inner one would survive).
|
||||
const valueInsideRoot = path.isAbsolute(value) && [
|
||||
path.resolve(storageRoot), path.resolve(cwdStorage),
|
||||
].some((root) => {
|
||||
const r = path.resolve(value);
|
||||
return r === root || r.startsWith(root + path.sep);
|
||||
});
|
||||
const strippedJoins = valueInsideRoot
|
||||
? []
|
||||
: [path.join(storageRoot, stripped), path.join(cwdStorage, stripped)];
|
||||
const candidates = [
|
||||
path.isAbsolute(value) ? value : null,
|
||||
path.join(storageRoot, stripped),
|
||||
...(path.isAbsolute(value) ? [value] : []),
|
||||
...strippedJoins,
|
||||
path.join(storageRoot, 'uploads', 'logos', baseName),
|
||||
path.join(storageRoot, 'branding', baseName),
|
||||
path.join(process.cwd(), 'storage', stripped),
|
||||
path.join(process.cwd(), 'storage', 'uploads', 'logos', baseName),
|
||||
path.join(process.cwd(), 'storage', 'branding', baseName),
|
||||
].filter(Boolean);
|
||||
path.join(cwdStorage, 'uploads', 'logos', baseName),
|
||||
path.join(cwdStorage, 'branding', baseName),
|
||||
];
|
||||
const roots = [path.resolve(storageRoot), path.resolve(cwdStorage)];
|
||||
const contained = candidates.filter((c) => {
|
||||
const r = path.resolve(c);
|
||||
return roots.some((root) => r === root || r.startsWith(root + path.sep));
|
||||
});
|
||||
return {
|
||||
label, value,
|
||||
candidates: [...new Set(candidates)].map((p) => ({
|
||||
path: p,
|
||||
label,
|
||||
// GHSA-29vm: branding_logo_path is stored absolute by multer, so
|
||||
// echoing it back handed out the filesystem layout just as the
|
||||
// candidate paths did. Relativise it the same way.
|
||||
value: path.isAbsolute(value) ? relativise(value) : value,
|
||||
candidates: [...new Set(contained)].map((p) => ({
|
||||
path: relativise(p),
|
||||
exists: (() => { try { return fs.existsSync(p) && fs.statSync(p).isFile(); } catch { return false; } })(),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
return successResponse(res, {
|
||||
storageRoot,
|
||||
cwd: process.cwd(),
|
||||
resolvedTo: resolved,
|
||||
// Absolute storageRoot / cwd deliberately omitted (GHSA-29vm); the
|
||||
// candidate paths below are shown relative to <STORAGE>/<CWD_STORAGE>.
|
||||
resolvedTo: resolved ? relativise(resolved) : null,
|
||||
sources: [
|
||||
inspect('business_profile.logo_path', profile?.logo_path),
|
||||
inspect('app_settings.branding_logo_path', brandingDiskPath),
|
||||
|
||||
@@ -141,8 +141,18 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
.trim()
|
||||
};
|
||||
|
||||
// Update hero_photo_id if provided (including null to clear it)
|
||||
// Update hero_photo_id if provided (including null to clear it). A
|
||||
// non-null hero must belong to this category (GHSA-j2f4) — the general
|
||||
// update path previously wrote it with no membership check at all.
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'hero_photo_id')) {
|
||||
if (hero_photo_id) {
|
||||
const heroPhoto = await db('photos')
|
||||
.where({ id: hero_photo_id, category_id: id })
|
||||
.first();
|
||||
if (!heroPhoto) {
|
||||
return res.status(404).json({ error: 'Photo not found in this category' });
|
||||
}
|
||||
}
|
||||
updateData.hero_photo_id = hero_photo_id || null;
|
||||
}
|
||||
|
||||
@@ -192,11 +202,16 @@ router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
|
||||
return res.status(404).json({ error: 'Category not found' });
|
||||
}
|
||||
|
||||
// If hero_photo_id is provided, verify it belongs to a photo in this category
|
||||
// If hero_photo_id is provided, verify the photo actually belongs to
|
||||
// THIS category — checking existence alone let an admin point a
|
||||
// category's hero at a photo from a different category or event
|
||||
// (GHSA-j2f4).
|
||||
if (hero_photo_id) {
|
||||
const photo = await db('photos').where('id', hero_photo_id).first();
|
||||
const photo = await db('photos')
|
||||
.where({ id: hero_photo_id, category_id: id })
|
||||
.first();
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
return res.status(404).json({ error: 'Photo not found in this category' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ const { body, param, query } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const { filterOwnedEventIds } = require('../middleware/ownership');
|
||||
const { db } = require('../database/db');
|
||||
|
||||
// Hour-entry routes are gated by the hoursLogging master so a direct API hit
|
||||
// can't read/edit/delete/bill logged hours while the feature is off (the
|
||||
@@ -528,9 +530,45 @@ router.put('/:id/events', [
|
||||
body('event_ids.*').isInt({ min: 1 }),
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const customerId = parseInt(req.params.id, 10);
|
||||
const submitted = req.body.event_ids.map(Number);
|
||||
|
||||
// The customer's CURRENT assignments. The "Manage galleries" dialog submits
|
||||
// the full initial list back — including any events owned by OTHER admins —
|
||||
// so we need this to tell "retain an existing foreign assignment" apart from
|
||||
// "newly grant a foreign event".
|
||||
const existingEventIds = (await db('event_customer_assignments')
|
||||
.where('customer_account_id', customerId)
|
||||
.pluck('event_id')).map(Number);
|
||||
const existingSet = new Set(existingEventIds);
|
||||
|
||||
// Events the caller may act on (GHSA-xr6x). A denied id is only acceptable
|
||||
// when the customer ALREADY has that assignment (a foreign event the caller
|
||||
// is merely keeping); a denied id that isn't already assigned is a fresh
|
||||
// attempt to mint access to a foreign/nonexistent event → reject.
|
||||
const { allowed } = await filterOwnedEventIds(req.admin, submitted);
|
||||
const allowedSet = new Set(allowed.map(Number));
|
||||
const illegalNew = submitted.filter((id) => !allowedSet.has(id) && !existingSet.has(id));
|
||||
if (illegalNew.length) {
|
||||
return res.status(403).json({ error: 'One or more events are not yours to assign' });
|
||||
}
|
||||
|
||||
// setAssignmentsForCustomer replaces the FULL assignment list, deleting any
|
||||
// existing row not in the submitted set. A restricted admin must not be able
|
||||
// to revoke another admin's customer↔event links that way, so always retain
|
||||
// the customer's existing assignments to events the caller does NOT own —
|
||||
// regardless of whether the client echoed them back. super_admin owns
|
||||
// everything, so nothing is force-preserved for them.
|
||||
let finalEventIds = allowed.map(Number);
|
||||
if (req.admin.roleName !== 'super_admin' && existingEventIds.length) {
|
||||
const { allowed: ownedExisting } = await filterOwnedEventIds(req.admin, existingEventIds);
|
||||
const ownedExistingSet = new Set(ownedExisting.map(Number));
|
||||
const foreignExisting = existingEventIds.filter((id) => !ownedExistingSet.has(id));
|
||||
finalEventIds = [...new Set([...finalEventIds, ...foreignExisting])];
|
||||
}
|
||||
const result = await customerAccountsService.setAssignmentsForCustomer(
|
||||
parseInt(req.params.id, 10),
|
||||
req.body.event_ids,
|
||||
customerId,
|
||||
finalEventIds,
|
||||
req.admin.id,
|
||||
);
|
||||
successResponse(res, result);
|
||||
|
||||
@@ -24,11 +24,47 @@ function normaliseDateKey(value) {
|
||||
return String(value).slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Event ids the caller's dashboard may aggregate over, or `null` when the
|
||||
* caller is unrestricted (GHSA-c2jj / gqx7 / jhcf).
|
||||
*
|
||||
* These endpoints are gated only by `analytics.view`, which the `editor` role
|
||||
* holds — yet the events *list* restricts editors to their own rows
|
||||
* (adminEvents/crud.js: `roleName === 'editor'` → `created_by = admin.id`).
|
||||
* The dashboard therefore reported instance-wide totals, and the analytics
|
||||
* endpoint returned other admins' gallery names and slugs, to a role that
|
||||
* cannot see those events anywhere else.
|
||||
*
|
||||
* Scoped on `editor` specifically to mirror the events list exactly, so the
|
||||
* `admin` role's dashboard is unchanged. (`filterOwnedEventIds` uses the
|
||||
* broader `!== super_admin` rule; the two conventions disagree in this
|
||||
* codebase and matching the list is the no-regression choice.)
|
||||
*
|
||||
* @returns {Promise<number[]|null>} ids to restrict to, or null for no limit
|
||||
*/
|
||||
function isScopedAdmin(admin) {
|
||||
return admin?.roleName === 'editor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict `query` to the caller's own events.
|
||||
*
|
||||
* Uses a SUBQUERY rather than materialising the id list. An editor owning more
|
||||
* events than the driver's bind-parameter limit (~999 on SQLite, 65535 on
|
||||
* Postgres) would otherwise blow past it once every id became a placeholder,
|
||||
* turning all three dashboard endpoints into 500s — and even well below that
|
||||
* limit the whole list was re-sent for each of the ~10 aggregates per request.
|
||||
*/
|
||||
function applyEventScope(query, admin, column) {
|
||||
if (!isScopedAdmin(admin)) return query;
|
||||
return query.whereIn(column, db('events').select('id').where('created_by', admin.id));
|
||||
}
|
||||
|
||||
// Get dashboard statistics
|
||||
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||
try {
|
||||
// Get active events count
|
||||
const activeEvents = await db('events')
|
||||
const activeEvents = await applyEventScope(db('events'), req.admin, 'id')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.count('id as count')
|
||||
@@ -39,7 +75,7 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
|
||||
const now = new Date();
|
||||
|
||||
const expiringEvents = await db('events')
|
||||
const expiringEvents = await applyEventScope(db('events'), req.admin, 'id')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
|
||||
@@ -48,12 +84,12 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
.first();
|
||||
|
||||
// Get total photos count
|
||||
const totalPhotos = await db('photos')
|
||||
const totalPhotos = await applyEventScope(db('photos'), req.admin, 'event_id')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
// Get storage usage (sum of all photo sizes)
|
||||
const storageUsed = await db('photos')
|
||||
const storageUsed = await applyEventScope(db('photos'), req.admin, 'event_id')
|
||||
.sum('size_bytes as total')
|
||||
.first();
|
||||
|
||||
@@ -61,21 +97,21 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const totalViews = await db('access_logs')
|
||||
const totalViews = await applyEventScope(db('access_logs'), req.admin, 'event_id')
|
||||
.where('action', 'view')
|
||||
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
// Get total downloads (last 30 days) - include both single and bulk downloads
|
||||
const totalDownloads = await db('access_logs')
|
||||
const totalDownloads = await applyEventScope(db('access_logs'), req.admin, 'event_id')
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
// Get archived events count
|
||||
const archivedEvents = await db('events')
|
||||
const archivedEvents = await applyEventScope(db('events'), req.admin, 'id')
|
||||
.where('is_archived', formatBoolean(true))
|
||||
.count('id as count')
|
||||
.first();
|
||||
@@ -83,7 +119,7 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
// Get total events count (all events regardless of status) — used by the
|
||||
// events list page to render accurate "All (N)" / Total Events counters
|
||||
// when the table is server-paginated (#346).
|
||||
const totalEvents = await db('events')
|
||||
const totalEvents = await applyEventScope(db('events'), req.admin, 'id')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
@@ -91,14 +127,14 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
const sixtyDaysAgo = new Date();
|
||||
sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60);
|
||||
|
||||
const previousViews = await db('access_logs')
|
||||
const previousViews = await applyEventScope(db('access_logs'), req.admin, 'event_id')
|
||||
.where('action', 'view')
|
||||
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
|
||||
.where('timestamp', '<', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const previousDownloads = await db('access_logs')
|
||||
const previousDownloads = await applyEventScope(db('access_logs'), req.admin, 'event_id')
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
|
||||
.where('timestamp', '<', thirtyDaysAgo.toISOString())
|
||||
@@ -136,9 +172,19 @@ router.get('/activity', adminAuth, requirePermission('analytics.view'), async (r
|
||||
try {
|
||||
const { limit } = getPagination(req, { limit: 10 });
|
||||
|
||||
const activities = await db('activity_logs')
|
||||
.select('activity_logs.*', 'events.event_name')
|
||||
.leftJoin('events', 'activity_logs.event_id', 'events.id')
|
||||
// Scope the feed to the caller's own events (GHSA-jhcf) — it otherwise
|
||||
// returned every admin's actions, including actor names and verbatim
|
||||
// metadata. `activity_logs.event_id` is NULLABLE: system-level entries
|
||||
// (logins, settings changes) carry no event, and those are deliberately
|
||||
// EXCLUDED for a scoped caller rather than shown, since they are exactly
|
||||
// the cross-admin actions this advisory is about.
|
||||
const activities = await applyEventScope(
|
||||
db('activity_logs')
|
||||
.select('activity_logs.*', 'events.event_name')
|
||||
.leftJoin('events', 'activity_logs.event_id', 'events.id'),
|
||||
req.admin,
|
||||
'activity_logs.event_id'
|
||||
)
|
||||
.orderBy('activity_logs.created_at', 'desc')
|
||||
.limit(limit);
|
||||
|
||||
@@ -244,7 +290,7 @@ router.get('/health', adminAuth, requirePermission('settings.view'), async (req,
|
||||
router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||
try {
|
||||
const days = sanitizeDays(req.query.days || 7);
|
||||
|
||||
|
||||
// Generate date range
|
||||
const dates = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
@@ -262,21 +308,21 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
const startDateStr = startDate.toISOString();
|
||||
|
||||
// Get views per day
|
||||
const viewsData = await db('access_logs')
|
||||
const viewsData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
|
||||
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
|
||||
.where('action', 'view')
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.groupByRaw('DATE(timestamp)');
|
||||
|
||||
// Get downloads per day - include both single and bulk downloads
|
||||
const downloadsData = await db('access_logs')
|
||||
const downloadsData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
|
||||
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.groupByRaw('DATE(timestamp)');
|
||||
|
||||
// Get unique visitors per day
|
||||
const visitorsData = await db('access_logs')
|
||||
const visitorsData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
|
||||
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(DISTINCT ip_address) as count'))
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.groupByRaw('DATE(timestamp)');
|
||||
@@ -303,7 +349,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
});
|
||||
|
||||
// Get top galleries by views with additional metrics
|
||||
const topGalleries = await db('access_logs')
|
||||
const topGalleries = await applyEventScope(db('access_logs'), req.admin, 'access_logs.event_id')
|
||||
.select('events.id', 'events.event_name', 'events.slug')
|
||||
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
|
||||
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
|
||||
@@ -324,7 +370,10 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
let devices = { desktop: 0, mobile: 0, tablet: 0 };
|
||||
let devicesSource = 'access_logs';
|
||||
|
||||
const adapter = await resolveAdapter();
|
||||
// The external tracker reports instance-wide device data with no way to
|
||||
// filter it by event, so a scoped caller must not receive it (GHSA-gqx7).
|
||||
// They fall through to the access_logs heuristic, which IS scoped.
|
||||
const adapter = isScopedAdmin(req.admin) ? null : await resolveAdapter();
|
||||
if (adapter) {
|
||||
try {
|
||||
const trackerDevices = await adapter.fetchDeviceBreakdown({
|
||||
@@ -346,7 +395,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
// Local heuristic on access_logs user_agent. Coarse — `LIKE` doesn't
|
||||
// cover every UA shape (some Android browsers, embedded webviews, etc.)
|
||||
// — and counts come back as strings on Postgres, hence Number() below.
|
||||
const deviceData = await db('access_logs')
|
||||
const deviceData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
|
||||
.select(
|
||||
db.raw(`
|
||||
CASE
|
||||
@@ -370,19 +419,19 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
}
|
||||
|
||||
// Calculate totals for the period (matching /stats logic)
|
||||
const totalViews = await db('access_logs')
|
||||
const totalViews = await applyEventScope(db('access_logs'), req.admin, 'event_id')
|
||||
.where('action', 'view')
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const totalDownloadsCount = await db('access_logs')
|
||||
const totalDownloadsCount = await applyEventScope(db('access_logs'), req.admin, 'event_id')
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const totalUniqueVisitors = await db('access_logs')
|
||||
const totalUniqueVisitors = await applyEventScope(db('access_logs'), req.admin, 'event_id')
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.countDistinct('ip_address as count')
|
||||
.first();
|
||||
|
||||
@@ -123,8 +123,20 @@ router.post('/backup', requirePermission('backup.create'), async (req, res) => {
|
||||
trackingUrl: '/api/admin/database-backup/progress'
|
||||
});
|
||||
|
||||
// Run backup in background
|
||||
databaseBackupService.backup(req.body).catch(error => {
|
||||
// Forward ONLY the real backup knobs (GHSA-jw8m). Passing req.body
|
||||
// straight through let the caller set `destinationPath`, which the
|
||||
// service merges over its config — so a backup.create holder (the
|
||||
// `admin` role, which has neither settings.edit nor backup.restore)
|
||||
// could dump the whole database into the PUBLIC /uploads static mount
|
||||
// and fetch it unauthenticated, hashes and encrypted SMTP creds included.
|
||||
// destinationPath is not a persistable setting; the request body was its
|
||||
// only source, so dropping it here costs no legitimate behaviour.
|
||||
const body = req.body || {};
|
||||
const options = {};
|
||||
for (const key of ['compress', 'validateIntegrity', 'includeChecksums']) {
|
||||
if (body[key] !== undefined) options[key] = body[key];
|
||||
}
|
||||
databaseBackupService.backup(options).catch(error => {
|
||||
logger.error('Manual database backup failed:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { body } = require('express-validator');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
@@ -128,7 +129,7 @@ router.get(
|
||||
);
|
||||
|
||||
const FRONTEND_URL_FALLBACK = 'https://app.example.com';
|
||||
const DEV_TEST_DIR = () => path.join(process.cwd(), 'storage', 'business-docs', 'dev-test');
|
||||
const DEV_TEST_DIR = () => path.join(getStoragePath(), 'business-docs', 'dev-test');
|
||||
|
||||
function fakeMoney(major, currency, locale = 'de') {
|
||||
return new Intl.NumberFormat(locale === 'de' ? 'de-CH' : 'en-GB', {
|
||||
|
||||
@@ -66,9 +66,11 @@ router.post('/config', [
|
||||
tls_reject_unauthorized
|
||||
} = req.body;
|
||||
|
||||
// Validate SMTP host is not a private/internal address (SSRF protection)
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(smtp_host)) {
|
||||
// Validate SMTP host is not a private/internal address (SSRF protection).
|
||||
// Resolves DNS so a public-looking hostname pointing at an internal IP
|
||||
// is caught, not just literal private addresses (#GHSA-ch64).
|
||||
const { isHostAllowed } = require('../utils/networkValidation');
|
||||
if (!(await isHostAllowed(smtp_host))) {
|
||||
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
|
||||
}
|
||||
|
||||
@@ -152,8 +154,8 @@ router.post('/incoming-config', [
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body;
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(imap_host)) {
|
||||
const { isHostAllowed } = require('../utils/networkValidation');
|
||||
if (!(await isHostAllowed(imap_host))) {
|
||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||
}
|
||||
const existing = await db('email_configs').first();
|
||||
@@ -183,8 +185,8 @@ router.post('/incoming-config/folders', adminAuth, requirePermission('email.view
|
||||
try {
|
||||
const { imap_host, imap_port, imap_secure, imap_user, imap_pass } = req.body || {};
|
||||
if (imap_host) {
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(imap_host)) {
|
||||
const { isHostAllowed } = require('../utils/networkValidation');
|
||||
if (!(await isHostAllowed(imap_host))) {
|
||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||
}
|
||||
}
|
||||
@@ -205,8 +207,8 @@ router.post('/incoming-config/test', adminAuth, requirePermission('email.view'),
|
||||
try {
|
||||
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body || {};
|
||||
if (imap_host) {
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(imap_host)) {
|
||||
const { isHostAllowed } = require('../utils/networkValidation');
|
||||
if (!(await isHostAllowed(imap_host))) {
|
||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||
}
|
||||
}
|
||||
@@ -385,11 +387,11 @@ router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit
|
||||
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)) {
|
||||
const { isHostAllowed } = require('../utils/networkValidation');
|
||||
if (b.imap_host && !(await isHostAllowed(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)) {
|
||||
if (b.smtp_host && !(await isHostAllowed(b.smtp_host))) {
|
||||
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
|
||||
}
|
||||
const patch = {
|
||||
@@ -434,8 +436,8 @@ router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit
|
||||
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)) {
|
||||
const { isHostAllowed } = require('../utils/networkValidation');
|
||||
if (b.imap_host && !(await isHostAllowed(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;
|
||||
|
||||
@@ -16,6 +16,7 @@ const path = require('path');
|
||||
const { escapeLikePattern } = require('../../utils/sqlSecurity');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../../utils/passwordValidation');
|
||||
const logger = require('../../utils/logger');
|
||||
const { sanitizeForLog, sanitizeValidationErrors } = require('../../utils/sanitizeForLog');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
@@ -126,10 +127,13 @@ module.exports = (router) => {
|
||||
body('customer_account_ids.*').optional().isInt({ min: 1 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
// Redact credentials — the body carries the gallery password (GHSA-r794).
|
||||
logger.debug('Create event request body', { body: sanitizeForLog(req.body) });
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
logger.error('Validation errors:', errors.array());
|
||||
// errors.array() embeds the SUBMITTED value per field — including a
|
||||
// rejected plaintext password (GHSA-r794).
|
||||
logger.error('Validation errors:', sanitizeValidationErrors(errors.array()));
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
@@ -1263,12 +1267,59 @@ module.exports = (router) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
|
||||
// Redact credentials — an invalid update still logs the whole body (GHSA-pgmp).
|
||||
logger.debug('Update event validation errors', { errors: sanitizeValidationErrors(errors.array()), body: sanitizeForLog(req.body) });
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = { ...req.body };
|
||||
|
||||
// Strip identity/provenance/secret columns from the mass-assigned
|
||||
// body (GHSA-3rqx). The handler spreads req.body straight into the
|
||||
// events UPDATE, so without this an events.edit holder could rewrite
|
||||
// ownership (created_by), routing identity (slug/share_link), the
|
||||
// share/client tokens, or the password hashes directly. Plaintext
|
||||
// `password`/`client_password` inputs are NOT stripped — those are the
|
||||
// supported way to change credentials and get hashed below; the
|
||||
// tokens are regenerated internally where needed.
|
||||
// The handler spreads req.body straight into the events UPDATE, so any
|
||||
// column an events.edit holder names is writable unless blocked here.
|
||||
// This is a COMPLETE deny-set of every server-managed / permission-gated
|
||||
// events column (enumerated from the schema); everything else is a
|
||||
// legitimate edit-form field and passes through, including input-only
|
||||
// keys (password/client_password) the handler transforms below. New
|
||||
// server-managed columns MUST be added here. (codex review — GHSA-3rqx.)
|
||||
const IMMUTABLE_EVENT_COLUMNS = [
|
||||
// Identity / provenance
|
||||
'id', 'created_by', 'created_at', 'updated_at', 'slug',
|
||||
// Routing + share/client tokens (generated at create / internally)
|
||||
'share_link', 'share_token', 'client_share_token', 'show_share_token',
|
||||
// Secrets (set via the plaintext password/client_password inputs)
|
||||
'password_hash', 'client_password_hash',
|
||||
// Server-consumed file paths — e.g. DELETE /:id/logo fs.unlink()s
|
||||
// hero_logo_path, so a forged value is an arbitrary-delete primitive.
|
||||
'hero_logo_path', 'hero_logo_url', 'archive_path', 'download_zip_path',
|
||||
// Server-managed timestamps
|
||||
'download_zip_generated_at', 'archived_at', 'revealed_at', 'event_reminder_sent_at',
|
||||
// Lifecycle — governed by dedicated permission-gated routes
|
||||
// (events.archive/restore, publish, activate/deactivate), not events.edit.
|
||||
'is_archived', 'is_draft', 'is_active',
|
||||
// Relationships — managed by projectService.assignEvent + its
|
||||
// customer-consistency checks, and events.edit ≠ quotes/contracts perms.
|
||||
'project_id', 'quote_id',
|
||||
// Legacy mirrors — rejected explicitly below in favour of customer_*.
|
||||
'host_name', 'host_email',
|
||||
];
|
||||
// Case-insensitive match: SQLite treats quoted identifiers
|
||||
// case-insensitively, so a `{ "Password_Hash": ... }` key would
|
||||
// otherwise survive a case-sensitive delete and still hit the real
|
||||
// column (codex review).
|
||||
const denied = new Set(IMMUTABLE_EVENT_COLUMNS.map((c) => c.toLowerCase()));
|
||||
for (const key of Object.keys(updates)) {
|
||||
if (denied.has(key.toLowerCase())) delete updates[key];
|
||||
}
|
||||
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
|
||||
@@ -1395,9 +1446,12 @@ module.exports = (router) => {
|
||||
}
|
||||
|
||||
// Log the update request for debugging
|
||||
// `updates` no longer holds the plaintext password (stripped above), but
|
||||
// it still carries client_password_hash and — when regenerate_client_token
|
||||
// was passed — a LIVE client_share_token bearer credential.
|
||||
logger.debug('Update event request', {
|
||||
id,
|
||||
updates,
|
||||
updates: sanitizeForLog(updates),
|
||||
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
|
||||
color_theme_type: typeof updates.color_theme,
|
||||
hero_photo_id: updates.hero_photo_id,
|
||||
@@ -1497,10 +1551,15 @@ module.exports = (router) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Update event
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update(updates);
|
||||
// Update event. Skip the write when the denylist (or masked secrets)
|
||||
// left nothing to change — Knex rejects .update({}) with an error,
|
||||
// which would surface as a 500 for an otherwise-valid no-op request
|
||||
// (e.g. a body of only protected fields). (codex review.)
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update(updates);
|
||||
}
|
||||
|
||||
// Customer-account assignments (#354). Same skip semantics as POST:
|
||||
// ignore when the customer portal flag is off so stale tabs don't
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const express = require('express');
|
||||
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
|
||||
const router = express.Router();
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
@@ -164,6 +165,24 @@ router.get('/events/:eventId/feedback',
|
||||
}
|
||||
);
|
||||
|
||||
// Ownership guard for by-feedback-id routes (GHSA-2qc2 / GHSA-32h4). These
|
||||
// take a :feedbackId (not :eventId), so requireEventOwnership can't apply —
|
||||
// resolve the feedback's event and enforce the same rule (super_admin sees
|
||||
// all; others need to own the event, or it's ownerless/legacy). Returns
|
||||
// false and sends a 404 (not 403 — don't leak which feedback ids exist)
|
||||
// when the caller may not act on it.
|
||||
async function assertOwnsFeedback(req, res, feedbackId) {
|
||||
if (req.admin.roleName === 'super_admin') return true;
|
||||
const fb = await db('photo_feedback').where('id', feedbackId).first('event_id');
|
||||
if (!fb) { res.status(404).json({ error: 'Feedback not found' }); return false; }
|
||||
const event = await db('events').where('id', fb.event_id).first('created_by');
|
||||
if (event && event.created_by && event.created_by !== req.admin.id) {
|
||||
res.status(404).json({ error: 'Feedback not found' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Moderate feedback (approve/hide/reject)
|
||||
router.put('/feedback/:feedbackId/:action',
|
||||
adminAuth,
|
||||
@@ -171,11 +190,12 @@ router.put('/feedback/:feedbackId/:action',
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { feedbackId, action } = req.params;
|
||||
|
||||
|
||||
if (!['approve', 'hide', 'reject'].includes(action)) {
|
||||
return res.status(400).json({ error: 'Invalid action' });
|
||||
}
|
||||
|
||||
if (!(await assertOwnsFeedback(req, res, feedbackId))) return;
|
||||
|
||||
await feedbackService.moderateFeedback(feedbackId, action, req.admin.id);
|
||||
|
||||
res.json({ success: true });
|
||||
@@ -193,7 +213,8 @@ router.delete('/feedback/:feedbackId',
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { feedbackId } = req.params;
|
||||
|
||||
if (!(await assertOwnsFeedback(req, res, feedbackId))) return;
|
||||
|
||||
await feedbackService.deleteFeedback(feedbackId, req.admin.id);
|
||||
|
||||
res.json({ success: true });
|
||||
@@ -347,7 +368,15 @@ router.get('/feedback/pending-moderation',
|
||||
requirePermission('events.view'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const pending = await feedbackService.getPendingModeration();
|
||||
// Scope to the caller's owned events unless super_admin (GHSA-3335).
|
||||
let ownedEventIds = null;
|
||||
if (req.admin.roleName !== 'super_admin') {
|
||||
const rows = await db('events')
|
||||
.where((q) => q.whereNull('created_by').orWhere('created_by', req.admin.id))
|
||||
.select('id');
|
||||
ownedEventIds = rows.map((r) => r.id);
|
||||
}
|
||||
const pending = await feedbackService.getPendingModeration(null, ownedEventIds);
|
||||
res.json(pending);
|
||||
} catch (error) {
|
||||
logger.error('Error getting pending moderation:', error);
|
||||
@@ -450,11 +479,13 @@ function convertToCSV(data) {
|
||||
const value = row[header];
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'boolean') return value ? 'yes' : 'no';
|
||||
if (typeof value === 'string'
|
||||
&& (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r'))) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
// Formula-neutralize user-controlled cells (guest_name/comment_text)
|
||||
// before quoting — quoting alone doesn't stop `=cmd()` (GHSA-3cw3).
|
||||
const neutralized = neutralizeSpreadsheetFormula(value);
|
||||
if (neutralized.includes(',') || neutralized.includes('"') || neutralized.includes('\n') || neutralized.includes('\r')) {
|
||||
return `"${neutralized.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
return neutralized;
|
||||
}).join(',');
|
||||
});
|
||||
|
||||
|
||||
@@ -39,8 +39,13 @@ function serializeGuest(row) {
|
||||
};
|
||||
}
|
||||
|
||||
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
|
||||
|
||||
function escapeCsvCell(value) {
|
||||
const str = value == null ? '' : String(value);
|
||||
// Neutralize spreadsheet formulas FIRST (a `=cmd()` guest name executes on
|
||||
// open — RFC-4180 quoting doesn't stop it), then quote-wrap (GHSA-wc99 /
|
||||
// GHSA-f4fp).
|
||||
const str = neutralizeSpreadsheetFormula(value);
|
||||
if (/[,"\n\r]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
@@ -502,6 +502,15 @@ router.get('/export', adminAuth, requirePermission('settings.view'), async (req,
|
||||
/**
|
||||
* Helper function to convert data to CSV
|
||||
*/
|
||||
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
|
||||
|
||||
function csvCell(value) {
|
||||
// Formula-neutralize, then RFC-4180 quote (the previous join('') did
|
||||
// neither — GHSA-37p4).
|
||||
const s = neutralizeSpreadsheetFormula(value);
|
||||
return /[,"\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}
|
||||
|
||||
function convertToCSV(data) {
|
||||
// Simplified CSV conversion for security logs
|
||||
const headers = ['timestamp', 'event_type', 'client_ip', 'details'];
|
||||
@@ -511,8 +520,8 @@ function convertToCSV(data) {
|
||||
log.client_ip,
|
||||
JSON.stringify(log.details || {})
|
||||
]);
|
||||
|
||||
return [headers.join(','), ...rows.map(row => row.join(','))].join('\n');
|
||||
|
||||
return [headers.join(','), ...rows.map(row => row.map(csvCell).join(','))].join('\n');
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
@@ -894,6 +894,7 @@ router.get(
|
||||
// re-fetching here keeps the route a thin shim over the
|
||||
// service rather than reaching inside its internals.
|
||||
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const inv = await db('invoices').where({ id }).first();
|
||||
const customer = inv ? await db('customer_accounts').where({ id: inv.customer_account_id }).first() : null;
|
||||
const filename = buildPdfFilename({
|
||||
@@ -902,7 +903,7 @@ router.get(
|
||||
fallback: `invoice-${id}`,
|
||||
});
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
|
||||
res.send(buf);
|
||||
})
|
||||
);
|
||||
@@ -919,6 +920,7 @@ router.post(
|
||||
// the customer so the filename still reflects who the invoice
|
||||
// is for; the number segment falls back to "invoice-preview".
|
||||
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const customer = payload.customerAccountId
|
||||
? await db('customer_accounts').where({ id: payload.customerAccountId }).first()
|
||||
: null;
|
||||
@@ -928,7 +930,7 @@ router.post(
|
||||
fallback: 'invoice-preview',
|
||||
});
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
|
||||
res.send(buf);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -315,9 +315,10 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
const crypto = require('crypto');
|
||||
const uploadId = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Counter base — same approximation as before. Strict uniqueness is
|
||||
// already enforced by the filename template + DB unique index, so a
|
||||
// small race here just retries a counter on conflict (rare).
|
||||
// Counter base — a per-request approximation (concurrent upload
|
||||
// requests can compute the same base; there is NO unique index on
|
||||
// photos.filename). Uniqueness of the final path comes from the
|
||||
// random suffix inside generatePhotoFilename (#931).
|
||||
const existingCount = await db('photos')
|
||||
.where({ event_id: eventId, type: photoType })
|
||||
.count('id as count')
|
||||
@@ -756,6 +757,16 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.update(updateData);
|
||||
|
||||
// A visibility or category change alters which photos belong in the
|
||||
// guest download bundle — drop the cached ZIP so it rebuilds fresh,
|
||||
// otherwise a hide→unhide cycle can leave the stale cache omitting
|
||||
// photos added in between (codex review).
|
||||
if (updateData.visibility !== undefined
|
||||
|| Object.prototype.hasOwnProperty.call(updateData, 'category_id')
|
||||
|| Object.prototype.hasOwnProperty.call(updateData, 'type')) {
|
||||
downloadZipService.invalidate(parseInt(eventId, 10));
|
||||
}
|
||||
|
||||
// Fetch and return the updated photo
|
||||
const updatedPhoto = await db('photos')
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
@@ -904,6 +915,14 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
|
||||
.where('event_id', eventId)
|
||||
.update(updateData);
|
||||
|
||||
// Visibility/category changes alter the guest download bundle — drop the
|
||||
// cached ZIP so it rebuilds fresh (codex review).
|
||||
if (updateData.visibility !== undefined
|
||||
|| Object.prototype.hasOwnProperty.call(updateData, 'category_id')
|
||||
|| Object.prototype.hasOwnProperty.call(updateData, 'type')) {
|
||||
downloadZipService.invalidate(parseInt(eventId, 10));
|
||||
}
|
||||
|
||||
res.json({ message: `${photoIds.length} photos updated successfully` });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update photos');
|
||||
@@ -1091,7 +1110,13 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
|
||||
average_rating: photo.average_rating || 0,
|
||||
comment_count: commentMap[photo.id] || 0,
|
||||
like_count: photo.like_count || 0,
|
||||
favorite_count: photo.favorite_count || 0
|
||||
favorite_count: photo.favorite_count || 0,
|
||||
// Engagement counters (#895 follow-up): the grid reads these, but
|
||||
// this explicit mapper never included them — so the Engagement
|
||||
// column showed 0 regardless of what the DB counted. This, not
|
||||
// stale data, was why per-image downloads always displayed 0.
|
||||
view_count: photo.view_count || 0,
|
||||
download_count: photo.download_count || 0
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1126,7 +1151,65 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
const storageKey = resolvePhotoStorageKey(event, photo);
|
||||
|
||||
res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`);
|
||||
// Content-Type resolution (#908 + external review). Invariant: the
|
||||
// header is ALWAYS image/* or video/*.
|
||||
// - photos.mime_type is never echoed verbatim unless it is a video/
|
||||
// type: the chunked-upload path stores the client-sent MIME
|
||||
// unvalidated, so a stored text/html served inline under the app
|
||||
// origin would be a same-origin XSS gift.
|
||||
// - Images ignore the stored value entirely — migration 039
|
||||
// backfilled image/jpeg onto every legacy row (PNGs included), so
|
||||
// the extension is the more trustworthy signal; normalized via the
|
||||
// shared map (image/jpg → image/jpeg), jpeg fallback when unknown.
|
||||
// - Videos prefer a stored video/ type, then the extension map
|
||||
// (.mov → video/quicktime, .webm → video/webm, …), then video/mp4.
|
||||
// The old ext-derived image/<ext> (image/mp4) is what made the
|
||||
// admin player's blob unplayable (#908).
|
||||
const { EXTENSION_TO_MIME } = require('../services/uploadSettings');
|
||||
const ext = path.extname(photo.filename).slice(1).toLowerCase();
|
||||
// Own-property lookup (review): a client-controlled filename ending in
|
||||
// .constructor / .__proto__ / .toString would otherwise return an
|
||||
// inherited Object.prototype member, and the extMime.startsWith below
|
||||
// would throw — a permanent 500 for that photo instead of the fallback.
|
||||
const extMime = Object.prototype.hasOwnProperty.call(EXTENSION_TO_MIME, ext)
|
||||
? EXTENSION_TO_MIME[ext]
|
||||
: null;
|
||||
// Full-token validation, not just a prefix check: the stored value is
|
||||
// client-controlled, and header-invalid characters (video/mp4\r\nX: y)
|
||||
// would make setHeader throw — a permanent 500 for that photo. Bare
|
||||
// 'video/' is equally invalid; both fall back to the extension map.
|
||||
const storedVideoMime = photo.mime_type && /^video\/[\w.+-]+$/.test(photo.mime_type)
|
||||
? photo.mime_type
|
||||
: null;
|
||||
// Honor a stored image MIME for any header-safe RASTER type (#908
|
||||
// review): the S3 auto-importer accepts arbitrary image/* from
|
||||
// mime-types and stores it (avif/bmp/tiff/heic/apng/ico/jxl/…), and a
|
||||
// hand-listed allowlist kept missing formats. Allow image/<token> but
|
||||
// NEVER the scriptable svg / *+xml family (image/svg+xml executes
|
||||
// inline). The strict token + anchors also block header injection
|
||||
// (image/x\r\nY:). Migration 039's blanket image/jpeg backfill on
|
||||
// legacy rows is why the mapped extension still wins ahead of this.
|
||||
const storedImageMime =
|
||||
photo.mime_type &&
|
||||
/^image\/[\w.+-]+$/.test(photo.mime_type) &&
|
||||
!/^image\/svg|xml/i.test(photo.mime_type)
|
||||
? photo.mime_type
|
||||
: null;
|
||||
const isVideo = photo.media_type === 'video' ||
|
||||
Boolean(storedVideoMime) ||
|
||||
Boolean(extMime && extMime.startsWith('video/'));
|
||||
// Never interpolate the raw extension on the image side: it would
|
||||
// synthesize image/svg+xml (scriptable inline) or header-invalid values
|
||||
// from client-controlled chunked-upload filenames. Precedence is
|
||||
// mapped-extension (also corrects the 039 legacy-jpeg backfill on PNGs)
|
||||
// -> safe stored raster MIME (auto-imported avif/bmp/tiff) -> image/jpeg.
|
||||
// A stored type outside the allowlist degrades to image/jpeg; browsers
|
||||
// sniff image bytes in <img>/blob contexts, so a mislabel is harmless
|
||||
// where an injected type is not.
|
||||
const contentType = isVideo
|
||||
? storedVideoMime || (extMime && extMime.startsWith('video/') ? extMime : null) || 'video/mp4'
|
||||
: (extMime && extMime.startsWith('image/') ? extMime : null) || storedImageMime || 'image/jpeg';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
|
||||
|
||||
@@ -15,8 +15,32 @@ const { requirePermission, userHasAnyPermission } = require('../middleware/permi
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const projectService = require('../services/projectService');
|
||||
const { db } = require('../database/db');
|
||||
const { ownedProjectsSubquery, requireProjectOwnership, filterOwnedEventIds } = require('../middleware/ownership');
|
||||
const { ForbiddenError } = require('../utils/errors');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// A deal that spans both quotes and contracts cascades a project link across
|
||||
// BOTH tables (projectService.linkDealToProject). So attaching one document
|
||||
// must also require manage permission on the OTHER domain the cascade will
|
||||
// touch — otherwise quotes.manage alone could re-point a linked contract, and
|
||||
// vice versa (GHSA-v4vw / codex review). No-op when the deal touches only the
|
||||
// one domain, or on older instances without the deal_uuid column.
|
||||
async function assertCascadePermitted(req, docTable, docId, otherTable, otherPerm) {
|
||||
let doc;
|
||||
try {
|
||||
doc = await db(docTable).where({ id: docId }).first('deal_uuid');
|
||||
} catch { return; }
|
||||
if (!doc || !doc.deal_uuid) return;
|
||||
let linked;
|
||||
try {
|
||||
linked = await db(otherTable).where({ deal_uuid: doc.deal_uuid }).first('id');
|
||||
} catch { return; }
|
||||
if (!linked) return;
|
||||
if (!(await userHasAnyPermission(req.admin.id, [otherPerm]))) {
|
||||
throw new ForbiddenError(`This deal also links a ${otherTable.replace(/s$/, '')}; the ${otherPerm} permission is required`);
|
||||
}
|
||||
}
|
||||
router.use(adminAuth);
|
||||
|
||||
// Projects is feature-flagged like bills/quotes — when off, the whole cockpit
|
||||
@@ -42,10 +66,15 @@ router.get('/', requirePermission('events.view'), handleAsync(async (req, res) =
|
||||
bills: await userHasAnyPermission(req.admin.id, ['bills.view']),
|
||||
quotes: await userHasAnyPermission(req.admin.id, ['quotes.view']),
|
||||
};
|
||||
// Only the caller's projects (GHSA-wrg5). Passed as a SUBQUERY so a large
|
||||
// project count can't hit the driver's bind-parameter limit; null means
|
||||
// unrestricted.
|
||||
const projectIds = ownedProjectsSubquery(req.admin);
|
||||
const projects = await projectService.listProjects({
|
||||
search: req.query.q || '',
|
||||
status: req.query.status || null,
|
||||
perms,
|
||||
projectIds,
|
||||
});
|
||||
return successResponse(res, { projects });
|
||||
}));
|
||||
@@ -65,7 +94,7 @@ router.post('/',
|
||||
);
|
||||
|
||||
// Detail
|
||||
router.get('/:id', requirePermission('events.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
|
||||
router.get('/:id', requirePermission('events.view'), requireProjectOwnership, [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const project = await projectService.getProjectById(parseInt(req.params.id, 10));
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||
@@ -75,6 +104,7 @@ router.get('/:id', requirePermission('events.view'), [param('id').isInt({ min: 1
|
||||
// Update
|
||||
router.put('/:id',
|
||||
requirePermission('events.edit'),
|
||||
requireProjectOwnership,
|
||||
[
|
||||
param('id').isInt({ min: 1 }),
|
||||
body('name').optional().isString().trim().isLength({ min: 1, max: 255 }),
|
||||
@@ -96,50 +126,98 @@ router.put('/:id',
|
||||
// Attach an event to the project
|
||||
router.post('/:id/events',
|
||||
requirePermission('events.edit'),
|
||||
requireProjectOwnership,
|
||||
[param('id').isInt({ min: 1 }), body('eventId').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const result = await projectService.assignEvent(parseInt(req.params.id, 10), parseInt(req.body.eventId, 10));
|
||||
const eventId = parseInt(req.body.eventId, 10);
|
||||
// Both sides must be the caller's (GHSA-wrg5): requireProjectOwnership
|
||||
// covers the project, this covers the INCOMING event. Otherwise an editor
|
||||
// could pull a foreign event into a project they own and then read that
|
||||
// event's rolled-up documents through /:id/overview.
|
||||
const { denied } = await filterOwnedEventIds(req.admin, [eventId]);
|
||||
if (denied.length) {
|
||||
return res.status(403).json({ error: 'That event is not yours to attach' });
|
||||
}
|
||||
const result = await projectService.assignEvent(parseInt(req.params.id, 10), eventId);
|
||||
return successResponse(res, result, 200, 'Event attached to project');
|
||||
}),
|
||||
);
|
||||
|
||||
// Attach a quote to the project (quotes carry no event_id — migration 121).
|
||||
// Requires quotes.manage in addition to events.edit — attaching a quote
|
||||
// mutates a separately-permissioned document domain (GHSA-v4vw).
|
||||
router.post('/:id/quotes',
|
||||
requirePermission('events.edit'),
|
||||
requirePermission(['events.edit', 'quotes.manage'], { requireAll: true }),
|
||||
requireProjectOwnership,
|
||||
[param('id').isInt({ min: 1 }), body('quoteId').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const result = await projectService.assignQuote(parseInt(req.params.id, 10), parseInt(req.body.quoteId, 10));
|
||||
const quoteId = parseInt(req.body.quoteId, 10);
|
||||
await assertCascadePermitted(req, 'quotes', quoteId, 'contracts', 'contracts.manage');
|
||||
const result = await projectService.assignQuote(parseInt(req.params.id, 10), quoteId, req.admin);
|
||||
return successResponse(res, result, 200, 'Quote attached to project');
|
||||
}),
|
||||
);
|
||||
|
||||
// Attach a contract to the project.
|
||||
// Attach a contract to the project. Requires contracts.manage in addition
|
||||
// to events.edit (GHSA-v4vw).
|
||||
router.post('/:id/contracts',
|
||||
requirePermission('events.edit'),
|
||||
requirePermission(['events.edit', 'contracts.manage'], { requireAll: true }),
|
||||
requireProjectOwnership,
|
||||
[param('id').isInt({ min: 1 }), body('contractId').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const result = await projectService.assignContract(parseInt(req.params.id, 10), parseInt(req.body.contractId, 10));
|
||||
const contractId = parseInt(req.body.contractId, 10);
|
||||
await assertCascadePermitted(req, 'contracts', contractId, 'quotes', 'quotes.manage');
|
||||
const result = await projectService.assignContract(parseInt(req.params.id, 10), contractId, req.admin);
|
||||
return successResponse(res, result, 200, 'Contract attached to project');
|
||||
}),
|
||||
);
|
||||
|
||||
// The cockpit aggregation — doc types gated on the admin's own permissions
|
||||
router.get('/:id/overview', requirePermission('events.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
|
||||
router.get('/:id/overview', requirePermission('events.view'), requireProjectOwnership, [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const perms = {
|
||||
bills: await userHasAnyPermission(req.admin.id, ['bills.view']),
|
||||
quotes: await userHasAnyPermission(req.admin.id, ['quotes.view']),
|
||||
contracts: await userHasAnyPermission(req.admin.id, ['contracts.view']),
|
||||
};
|
||||
const overview = await projectService.getProjectOverview(parseInt(req.params.id, 10), perms);
|
||||
const overview = await projectService.getProjectOverview(parseInt(req.params.id, 10), perms, req.admin);
|
||||
return successResponse(res, overview);
|
||||
}));
|
||||
|
||||
/**
|
||||
* These routes key on an `email_queue` id alone (GHSA-93x4) — nothing tied the
|
||||
* row to a project or event the caller can see, so any admin holding
|
||||
* `events.view` / `email.send` could preview, resend, cancel or retry ANY
|
||||
* queued mail on the instance by walking ids.
|
||||
*
|
||||
* Scoped via `email_queue.event_id` → the caller's owned events. `event_id` is
|
||||
* NULL for CRM document mail (quote/contract/invoice sends carry no event), and
|
||||
* those rows have no ownable parent here, so a scoped caller is denied them
|
||||
* rather than guessed into access. 404, not 403, so this isn't an id oracle.
|
||||
*/
|
||||
async function requireOwnedQueuedEmail(req, res, next) {
|
||||
try {
|
||||
if (req.admin?.roleName === 'super_admin') return next();
|
||||
const emailId = parseInt(req.params.emailId, 10);
|
||||
const row = await db('email_queue').where({ id: emailId }).first('event_id');
|
||||
if (!row || !row.event_id) {
|
||||
return res.status(404).json({ error: 'Email not found' });
|
||||
}
|
||||
const { denied } = await filterOwnedEventIds(req.admin, [row.event_id]);
|
||||
if (denied.length) {
|
||||
return res.status(404).json({ error: 'Email not found' });
|
||||
}
|
||||
return next();
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Email preview — the ACTUAL sent HTML (or null for pre-rendered_html rows)
|
||||
router.get('/email/:emailId/preview', requirePermission('events.view'), [param('emailId').isInt({ min: 1 })], handleAsync(async (req, res) => {
|
||||
router.get('/email/:emailId/preview', requirePermission('events.view'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const preview = await projectService.getEmailPreview(parseInt(req.params.emailId, 10));
|
||||
return successResponse(res, preview);
|
||||
@@ -152,9 +230,9 @@ const emailAction = (fn) => handleAsync(async (req, res) => {
|
||||
const result = await projectService[fn](parseInt(req.params.emailId, 10), req.admin.id);
|
||||
return successResponse(res, result);
|
||||
});
|
||||
router.post('/email/:emailId/resend', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('resendEmail'));
|
||||
router.post('/email/:emailId/cancel', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('cancelEmail'));
|
||||
router.post('/email/:emailId/retry', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('retryEmail'));
|
||||
router.post('/email/:emailId/send-now', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('sendEmailNow'));
|
||||
router.post('/email/:emailId/resend', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, emailAction('resendEmail'));
|
||||
router.post('/email/:emailId/cancel', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, emailAction('cancelEmail'));
|
||||
router.post('/email/:emailId/retry', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, emailAction('retryEmail'));
|
||||
router.post('/email/:emailId/send-now', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, emailAction('sendEmailNow'));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -537,6 +537,7 @@ router.get(
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const buf = await quoteService.renderQuotePdfBuffer(id);
|
||||
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const quote = await db('quotes').where({ id }).first();
|
||||
const customer = quote ? await db('customer_accounts').where({ id: quote.customer_account_id }).first() : null;
|
||||
const filename = buildPdfFilename({
|
||||
@@ -545,7 +546,7 @@ router.get(
|
||||
fallback: `quote-${id}`,
|
||||
});
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
|
||||
res.send(buf);
|
||||
})
|
||||
);
|
||||
@@ -559,6 +560,7 @@ router.post(
|
||||
const payload = mapPayloadToService(req.body);
|
||||
const buf = await quoteService.renderQuotePdfFromPayload(payload);
|
||||
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const customer = payload.customerAccountId
|
||||
? await db('customer_accounts').where({ id: payload.customerAccountId }).first()
|
||||
: null;
|
||||
@@ -568,7 +570,7 @@ router.post(
|
||||
fallback: 'quote-preview',
|
||||
});
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
|
||||
res.send(buf);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -91,6 +91,12 @@ router.post('/validate', requirePermission('backup.restore'), [
|
||||
}
|
||||
|
||||
try {
|
||||
// Constrain the caller-supplied paths to configured backup roots (GHSA-fw4c)
|
||||
const pathError = await checkRestorePathsAllowed(req.body);
|
||||
if (pathError) {
|
||||
return res.status(400).json({ success: false, error: pathError });
|
||||
}
|
||||
|
||||
// Transform S3 config from frontend format
|
||||
const s3Config = transformS3Config(req.body);
|
||||
|
||||
@@ -165,6 +171,12 @@ router.post('/start', requirePermission('backup.restore'), [
|
||||
});
|
||||
}
|
||||
|
||||
// Constrain the caller-supplied paths to configured backup roots (GHSA-fw4c)
|
||||
const pathError = await checkRestorePathsAllowed(req.body);
|
||||
if (pathError) {
|
||||
return res.status(400).json({ success: false, error: pathError });
|
||||
}
|
||||
|
||||
// Check permissions for dangerous options
|
||||
const settings = await getRestoreSettings();
|
||||
if (req.body.force && !settings.restore_allow_force) {
|
||||
@@ -759,4 +771,67 @@ async function getBackupConfig() {
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* GHSA-fw4c: `source` and `manifestPath` were validated only as "not empty"
|
||||
* before being handed to the privileged restore engine, which reads them,
|
||||
* parses the manifest and executes the referenced SQL against the live
|
||||
* database. Constrain them to the operator-configured backup locations.
|
||||
*
|
||||
* The allowlist is the SAME set the restore wizard discovers from
|
||||
* (`backup_destination_path` + `backup_manifest_path`), so the disaster-
|
||||
* recovery flow is untouched: an operator restoring from a rescued mount
|
||||
* already has to point those settings at it for the backup to be listed.
|
||||
* RESTORE_ALLOWED_ROOTS (colon-separated) is an escape hatch for unusual
|
||||
* layouts. S3 sources are URLs, not paths, and are validated elsewhere.
|
||||
*
|
||||
* @returns {Promise<string|null>} an error message, or null when acceptable
|
||||
*/
|
||||
// `source` is usually a SOURCE TYPE, not a path: the restore wizard posts
|
||||
// 'local' | 's3' | 'upload' and restoreService.restore() branches on those
|
||||
// literals before deriving an actual directory (see its comment at the
|
||||
// `options.source === 'local'` branch). Treating them as paths resolved
|
||||
// 'local' to <cwd>/local, failed containment, and 400'd the entire normal
|
||||
// restore workflow — so type tokens are excluded from the path check.
|
||||
const SOURCE_TYPE_TOKENS = ['local', 's3', 'upload'];
|
||||
|
||||
async function checkRestorePathsAllowed({ source, manifestPath }) {
|
||||
const isS3 = (v) => typeof v === 'string' && v.startsWith('s3://');
|
||||
const isTypeToken = (v) => typeof v === 'string'
|
||||
&& SOURCE_TYPE_TOKENS.includes(v.trim().toLowerCase());
|
||||
const candidates = [source, manifestPath]
|
||||
.filter((v) => v && !isS3(v) && !isTypeToken(v));
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const config = await getBackupConfig();
|
||||
const roots = [];
|
||||
if (config.backup_destination_path) roots.push(config.backup_destination_path);
|
||||
if (config.backup_manifest_path) roots.push(config.backup_manifest_path);
|
||||
for (const extra of (process.env.RESTORE_ALLOWED_ROOTS || '').split(':')) {
|
||||
if (extra.trim()) roots.push(extra.trim());
|
||||
}
|
||||
if (roots.length === 0) {
|
||||
// Nothing configured to compare against — a restore can't be scoped, so
|
||||
// don't pretend to enforce. Discovery would find nothing either.
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedRoots = roots.map((r) => path.resolve(r));
|
||||
for (const candidate of candidates) {
|
||||
const resolved = path.resolve(candidate);
|
||||
const inside = resolvedRoots.some(
|
||||
(root) => resolved === root || resolved.startsWith(root + path.sep)
|
||||
);
|
||||
if (!inside) {
|
||||
logger.warn('Refusing restore path outside the configured backup roots', {
|
||||
candidate, roots,
|
||||
});
|
||||
return 'Backup source and manifest path must be inside a configured backup location';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
// Exposed for tests: the source/manifestPath containment rules (GHSA-fw4c) are
|
||||
// worth pinning directly, especially the source-TYPE-token carve-out.
|
||||
module.exports._internal = { checkRestorePathsAllowed, SOURCE_TYPE_TOKENS };
|
||||
|
||||
@@ -7,6 +7,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolveSqlitePath } = require('../utils/databaseEngine');
|
||||
const { checkForUpdates, getCurrentChannel, getCurrentVersion, getReleasesSince, compareVersions } = require('../services/updateCheckService');
|
||||
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
|
||||
const { parseWhatsNew } = require('../utils/whatsNew');
|
||||
@@ -218,26 +219,25 @@ router.get('/updates/instructions', adminAuth, requirePermission('settings.view'
|
||||
// Get comprehensive system status
|
||||
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Database size - check if PostgreSQL or SQLite
|
||||
// Database size. Read the LIVE connection rather than re-deriving any of
|
||||
// this from the environment (#1038): DATABASE_CLIENT is not the only thing
|
||||
// that decides the engine, DB_NAME is not the only thing that decides the
|
||||
// database, and DATABASE_PATH was ignored outright here — so a SQLite
|
||||
// install with a custom path, or a Postgres install without an explicit
|
||||
// DATABASE_CLIENT, reported the size of something it was not using.
|
||||
let dbSize = 0;
|
||||
const dbClient = process.env.DATABASE_CLIENT || 'sqlite3';
|
||||
|
||||
if (dbClient === 'pg') {
|
||||
// PostgreSQL - query database size
|
||||
const liveConnection = db.client.config.connection || {};
|
||||
|
||||
if (db.client.config.client === 'pg') {
|
||||
try {
|
||||
const dbName = process.env.DB_NAME || 'picpeak';
|
||||
const result = await db.raw(`
|
||||
SELECT pg_database_size(?) as size
|
||||
`, [dbName]);
|
||||
const result = await db.raw('SELECT pg_database_size(current_database()) as size');
|
||||
dbSize = result.rows[0]?.size || 0;
|
||||
} catch (error) {
|
||||
logger.error('Error getting PostgreSQL database size:', error);
|
||||
}
|
||||
} else {
|
||||
// SQLite - check file size
|
||||
const dbPath = path.join(__dirname, '../../data/photo_sharing.db');
|
||||
try {
|
||||
const stats = await fs.stat(dbPath);
|
||||
const stats = await fs.stat(liveConnection.filename || resolveSqlitePath());
|
||||
dbSize = stats.size;
|
||||
} catch (error) {
|
||||
logger.error('Error getting SQLite database size:', error);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user