Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce6dbdab56 | |||
| 7cd7654f99 | |||
| 415497ad06 | |||
| 98d25601b4 | |||
| 2a04137a75 | |||
| f094cc06a7 | |||
| ed7ddd1e60 | |||
| 70f5a8c54e | |||
| 7c0c5c1cda | |||
| 4622478e44 | |||
| f92d4bb2d9 | |||
| 1080388f28 | |||
| 316bcbd679 | |||
| ec03089d57 | |||
| b798d8e4c1 | |||
| 59ea83c84e | |||
| 38b0e1d584 | |||
| abc960170b | |||
| cdde937d7f | |||
| e3247911a0 | |||
| e290207934 | |||
| f6b81fabf0 | |||
| c4d89c9d64 | |||
| a2bf1f644c | |||
| 443ec91de9 | |||
| 15cd5ede82 | |||
| 6d717544ec | |||
| 9f4b9bab46 | |||
| de3ae1f176 | |||
| 59ef2ee9af | |||
| 539f5db2b5 | |||
| 411d459338 | |||
| ff23efec81 | |||
| fb2f8333dc | |||
| 77b4aab61a | |||
| a5f7b38e02 | |||
| 9a437ee9e1 | |||
| 81b10f4e31 | |||
| 9168bdd504 | |||
| c61a6b089e | |||
| d20f80112f | |||
| 8d0c32902d | |||
| 662516a5ad | |||
| a31a2e25e2 | |||
| 20a291eb34 | |||
| f0e6d2dfb1 | |||
| 895e5ab3cc | |||
| acb25a9a1c | |||
| c97341e454 | |||
| 0e459b3293 | |||
| f83cbe9109 |
@@ -125,6 +125,11 @@ DB_NAME=picpeak_prod
|
||||
# address is refused by the SSRF check otherwise. Running n8n beside PicPeak is
|
||||
# normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that.
|
||||
#
|
||||
# Webhook and email-webhook deliveries connect to the DNS answer they just
|
||||
# validated and ignore HTTP_PROXY / HTTPS_PROXY. Behind a mandatory egress
|
||||
# proxy set the *_ALLOW_PRIVATE_URLS flag, which sends through the proxy
|
||||
# without pinning.
|
||||
#
|
||||
# A mail account with its own SMTP host (Settings -> Mail accounts) keeps
|
||||
# sending through it; this replaces the global transport only.
|
||||
#
|
||||
@@ -353,3 +358,9 @@ LOGS=./logs
|
||||
# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET.
|
||||
# Keep this value stable until participation has been deleted.
|
||||
# USAGE_ENCRYPTION_KEY=
|
||||
|
||||
# Graceful shutdown budget in milliseconds. On SIGTERM the server stops
|
||||
# accepting requests, drains workers and closes the pool; whatever is still
|
||||
# running after this long is abandoned so the process exits before Docker's
|
||||
# 10 s stop grace period (raise stop_grace_period together with this value).
|
||||
#SHUTDOWN_TIMEOUT_MS=8000
|
||||
|
||||
@@ -24,17 +24,20 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- OS: [e.g. Ubuntu 22.04]
|
||||
- Browser: [e.g. Chrome 120, Safari 17]
|
||||
- PicPeak Version: [e.g. 1.0.22]
|
||||
- Deployment Method: [e.g. Docker Compose, Manual]
|
||||
- Database: [e.g. PostgreSQL 15, SQLite]
|
||||
- OS and version:
|
||||
- Browser and version:
|
||||
- PicPeak version and Docker image tag (if applicable):
|
||||
- Deployment method: [Docker Compose, all-in-one container, manual]
|
||||
- Database and version: [PostgreSQL, SQLite]
|
||||
|
||||
**Logs**
|
||||
Please include relevant logs:
|
||||
```
|
||||
# Backend logs
|
||||
docker-compose logs backend | tail -50
|
||||
# Backend logs (Docker Compose)
|
||||
docker compose logs --tail=50 backend
|
||||
|
||||
# Or all-in-one container logs (replace picpeak if your container has another name)
|
||||
docker logs --tail=50 picpeak
|
||||
|
||||
# Frontend console errors
|
||||
[paste any browser console errors]
|
||||
@@ -44,4 +47,4 @@ docker-compose logs backend | tail -50
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Possible Solution**
|
||||
If you have an idea how to fix the issue, please describe it here.
|
||||
If you have an idea how to fix the issue, please describe it here.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📚 Documentation
|
||||
url: https://github.com/PicPeak/picpeak/blob/main/DEPLOYMENT.md
|
||||
about: Please read the documentation before opening an issue
|
||||
url: https://docs.picpeak.app
|
||||
about: Installation, configuration and feature guides
|
||||
- name: 💬 Discussions
|
||||
url: https://github.com/PicPeak/picpeak/discussions
|
||||
about: Ask questions and discuss with the community
|
||||
- name: 🔒 Security Issues
|
||||
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
|
||||
about: Please review our security policy for reporting vulnerabilities
|
||||
about: Please review our security policy for reporting vulnerabilities
|
||||
|
||||
@@ -9,6 +9,7 @@ assignees: ''
|
||||
|
||||
**What documentation needs improvement?**
|
||||
Please specify which document or section needs attention:
|
||||
- [ ] Documentation website (https://docs.picpeak.app)
|
||||
- [ ] README.md
|
||||
- [ ] DEPLOYMENT.md
|
||||
- [ ] CONTRIBUTING.md
|
||||
@@ -16,6 +17,8 @@ Please specify which document or section needs attention:
|
||||
- [ ] Code Comments
|
||||
- [ ] Other: ___________
|
||||
|
||||
Link to the affected page or file:
|
||||
|
||||
**Describe the issue**
|
||||
What's wrong or missing in the documentation?
|
||||
|
||||
@@ -30,4 +33,4 @@ Who is this documentation for?
|
||||
- [ ] End users (photographers/clients)
|
||||
|
||||
**Additional context**
|
||||
Add any other context, examples, or references here.
|
||||
Add any other context, examples, or references here.
|
||||
|
||||
+21
-17
@@ -6,11 +6,6 @@ name: Tests
|
||||
# calendar) plus the photo / settings / OG / auth surface — wiring them
|
||||
# into CI makes regressions visible at PR time instead of post-merge.
|
||||
#
|
||||
# Six backend suites are excluded via --testPathIgnorePatterns. They
|
||||
# fail on `upstream/beta` too (pre-existing mock/infra issues, NOT CRM
|
||||
# regressions). Excluding them here keeps CI green from day 1; revisit
|
||||
# each individually as its own fix.
|
||||
#
|
||||
# Triggers on any change that could affect either suite. The backend
|
||||
# job intentionally omits frontend paths and vice versa so unrelated
|
||||
# PRs don't pay both build costs.
|
||||
@@ -89,18 +84,7 @@ jobs:
|
||||
# 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:
|
||||
# adminSettings.logo — supertest fixture
|
||||
# integration/adminPhotos.reference — supertest fixture
|
||||
# integration/webhookDelivery — supertest fixture
|
||||
# services/backupService.enhanced — knex mock chain
|
||||
# routes/__tests__/adminAuth — supertest fixture
|
||||
# (adminNotifications was excluded; #597 fix re-enables it.)
|
||||
npx jest \
|
||||
--testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \
|
||||
--ci
|
||||
run: npx jest --ci
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -121,10 +105,30 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Lint frontend (including Rules of Hooks)
|
||||
working-directory: ./frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Run Vitest suite
|
||||
working-directory: ./frontend
|
||||
run: npm test -- --run
|
||||
|
||||
nginx:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
strategy:
|
||||
matrix:
|
||||
# Match the two shipped frontend Dockerfiles.
|
||||
image: ['nginx:1.28-alpine', 'nginx:1.30-alpine']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify token-safe nginx logging
|
||||
env:
|
||||
NGINX_TEST_IMAGE: ${{ matrix.image }}
|
||||
run: python3 tests/nginx/test_request_logging.py
|
||||
|
||||
# Optional face-detection sidecar (#1074). Runs on every PR regardless of
|
||||
# whether the feature is enabled anywhere — these tests need no model
|
||||
# weights (they stub the pipeline out) and cover the auth boundary, the
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.130.0-beta.0"
|
||||
".": "3.131.7-beta.0"
|
||||
}
|
||||
|
||||
+109
@@ -5,6 +5,115 @@ 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.131.7-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.6-beta.0...v3.131.7-beta.0) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** keep header-style tiles from overflowing their cards ([#1422](https://github.com/PicPeak/picpeak/issues/1422)) ([7cd7654](https://github.com/PicPeak/picpeak/commit/7cd7654f99967080724d7498b72bea4986ae825d))
|
||||
|
||||
## [3.131.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.5-beta.0...v3.131.6-beta.0) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** cap how many cached zips rebuild at once in the background ([#1418](https://github.com/PicPeak/picpeak/issues/1418)) ([98d2560](https://github.com/PicPeak/picpeak/commit/98d25601b465ecc44d0875b52891073720fabbbc))
|
||||
|
||||
## [3.131.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.4-beta.0...v3.131.5-beta.0) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** stop the pre-zip build leaking storage reads ([#1402](https://github.com/PicPeak/picpeak/issues/1402)) ([f094cc0](https://github.com/PicPeak/picpeak/commit/f094cc06a78e776795e60cb3b11e8653681a2f31))
|
||||
|
||||
## [3.131.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.3-beta.0...v3.131.4-beta.0) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** bump sharp, nodemailer, multer, js-yaml, joi for security fixes ([#1374](https://github.com/PicPeak/picpeak/issues/1374)) ([f6b81fa](https://github.com/PicPeak/picpeak/commit/f6b81fabf05ab0bbce48e63bbdf3812b30aa10de))
|
||||
* **backend:** contain and sanitize the SQLite restore source path ([#1384](https://github.com/PicPeak/picpeak/issues/1384)) ([316bcbd](https://github.com/PicPeak/picpeak/commit/316bcbd67965ddec74801308a722b506bb8da265))
|
||||
* **backend:** enforce event ownership on short URL deletion ([#1379](https://github.com/PicPeak/picpeak/issues/1379)) ([e290207](https://github.com/PicPeak/picpeak/commit/e290207934708f8bf41676aec39a43474f0f6172))
|
||||
* **backend:** reject a replayed TOTP code within its validity window ([#1389](https://github.com/PicPeak/picpeak/issues/1389)) ([cdde937](https://github.com/PicPeak/picpeak/commit/cdde937d7fce130d67e331bd968cb2c4734902d4))
|
||||
* **backend:** require actor to hold every permission of a role they grant ([#1378](https://github.com/PicPeak/picpeak/issues/1378)) ([59ea83c](https://github.com/PicPeak/picpeak/commit/59ea83c84efdcf6d488853a25ba05f1d15ef1150))
|
||||
* **backend:** shorten payment-check token TTL and notify admin on use ([#1385](https://github.com/PicPeak/picpeak/issues/1385)) ([e324791](https://github.com/PicPeak/picpeak/commit/e3247911a071b5277d877a1fa0a39c544d3a03e8))
|
||||
* **backend:** use the strong password generator for resets and enforce must_change_password ([#1387](https://github.com/PicPeak/picpeak/issues/1387)) ([b798d8e](https://github.com/PicPeak/picpeak/commit/b798d8e4c19541da6ee1f58c2756182817b6c716))
|
||||
* **backend:** validate business-profile logo uploads by content, not filename ([#1381](https://github.com/PicPeak/picpeak/issues/1381)) ([abc9601](https://github.com/PicPeak/picpeak/commit/abc960170b1eac0fa1f3110015e8fce671d428ab))
|
||||
* **backend:** validate event id before using it in the logo storage filename ([#1382](https://github.com/PicPeak/picpeak/issues/1382)) ([38b0e1d](https://github.com/PicPeak/picpeak/commit/38b0e1d5842217030e7dd48247e523ded4b19c58))
|
||||
* **backend:** validate the S3 endpoint host before the restore download ([#1383](https://github.com/PicPeak/picpeak/issues/1383)) ([ec03089](https://github.com/PicPeak/picpeak/commit/ec03089d57c88ff0f7c21b6a4947ffb9ef9771f9))
|
||||
* **gallery:** bound and reclaim storage reads in the remaining zip builders ([#1410](https://github.com/PicPeak/picpeak/issues/1410)) ([70f5a8c](https://github.com/PicPeak/picpeak/commit/70f5a8c54e096f70c030d63414d5954190cb68a8))
|
||||
* **gallery:** keep an admin draft preview out of the guest share-login flow ([f92d4bb](https://github.com/PicPeak/picpeak/commit/f92d4bb2d9c2ea84f59dd4cfaa3a4272f1eec56b))
|
||||
* **gallery:** keep videos playable under enhanced and maximum protection ([#1404](https://github.com/PicPeak/picpeak/issues/1404)) ([1080388](https://github.com/PicPeak/picpeak/commit/1080388f28b846553cd670c66e9632275eb9c994))
|
||||
* **gallery:** let an admin preview a draft through its short share URL ([f92d4bb](https://github.com/PicPeak/picpeak/commit/f92d4bb2d9c2ea84f59dd4cfaa3a4272f1eec56b))
|
||||
* **gallery:** let an admin preview a draft through its short share URL ([#1405](https://github.com/PicPeak/picpeak/issues/1405)) ([f92d4bb](https://github.com/PicPeak/picpeak/commit/f92d4bb2d9c2ea84f59dd4cfaa3a4272f1eec56b))
|
||||
* **upload:** let the csrf gate pass application/octet-stream chunks ([#1401](https://github.com/PicPeak/picpeak/issues/1401)) ([7c0c5c1](https://github.com/PicPeak/picpeak/commit/7c0c5c1cda3921cbd9404dd77d64bdec7a9ae2aa))
|
||||
* **upload:** stop buffering a chunk body before anything checks its size ([#1406](https://github.com/PicPeak/picpeak/issues/1406)) ([4622478](https://github.com/PicPeak/picpeak/commit/4622478e44d5e63da031a2f27c5a5c335282eacb))
|
||||
|
||||
## [3.131.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.2-beta.0...v3.131.3-beta.0) (2026-09-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **video:** try metadata extraction and thumbnail generation independently ([#1371](https://github.com/PicPeak/picpeak/issues/1371)) ([a2bf1f6](https://github.com/PicPeak/picpeak/commit/a2bf1f644c78734fc9a86a24d441b7a70bdb38fb))
|
||||
|
||||
## [3.131.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.1-beta.0...v3.131.2-beta.0) (2026-09-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backup:** honor the configured database-backup destination path ([#1366](https://github.com/PicPeak/picpeak/issues/1366)) ([15cd5ed](https://github.com/PicPeak/picpeak/commit/15cd5ede82171f5869342de869903f55c73f3871))
|
||||
|
||||
## [3.131.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.131.0-beta.0...v3.131.1-beta.0) (2026-09-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **usage:** explain and de-emphasize the pending-packet button lock ([#1363](https://github.com/PicPeak/picpeak/issues/1363)) ([9f4b9ba](https://github.com/PicPeak/picpeak/commit/9f4b9bab46264d83dcdf698ce5bc318703eb10ee))
|
||||
|
||||
## [3.131.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.130.2-beta.0...v3.131.0-beta.0) (2026-09-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **setup:** add anonymous usage-reporting opt-in to the first-run wizard ([8d0c329](https://github.com/PicPeak/picpeak/commit/8d0c32902dd78324286a7585188d0c292692c8ec))
|
||||
* **setup:** add product usage consent to the first-run wizard ([539f5db](https://github.com/PicPeak/picpeak/commit/539f5db2b5e28dc42742fbbbe082fc3c23a7263e))
|
||||
* **usage:** prompt existing admins once for usage reporting after an update ([59ef2ee](https://github.com/PicPeak/picpeak/commit/59ef2ee9af74e97e042934e6ea5fab3b0b687617))
|
||||
* **usage:** prompt existing admins once for usage reporting after an update ([d20f801](https://github.com/PicPeak/picpeak/commit/d20f80112f95c7d718f936ea14aadf7eb0accdea))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* complete graceful shutdown and revoke tokens without expiry ([411d459](https://github.com/PicPeak/picpeak/commit/411d459338289cae7dce8cddbe7c78dae3d4f449))
|
||||
* interrupt idle worker waits during shutdown ([a31a2e2](https://github.com/PicPeak/picpeak/commit/a31a2e25e2666989ee226c0d7884e23f50fe58da))
|
||||
* retain revocations for tokens without expiry ([662516a](https://github.com/PicPeak/picpeak/commit/662516a5ad2a0aadd87dfff3fba4f2456e88a69d))
|
||||
* **setup:** refresh usage state after accepting consent ([a5f7b38](https://github.com/PicPeak/picpeak/commit/a5f7b38e02f20e66047431c5cb04e6f34c60c689))
|
||||
* **setup:** require the full usage reporting disclosure ([9168bdd](https://github.com/PicPeak/picpeak/commit/9168bdd5048b4419db7b0f4444375f54c06f5bbb))
|
||||
* **usage:** cap the update-prompt modal height so it scrolls on short viewports ([c61a6b0](https://github.com/PicPeak/picpeak/commit/c61a6b089e56beec1de5c106440764518f507012))
|
||||
* **usage:** classify prompt acknowledgement in privacy coverage ([fb2f833](https://github.com/PicPeak/picpeak/commit/fb2f8333dca79b9cada9f349671920e34db38d5d))
|
||||
* **usage:** preserve consent choices and make the prompt accessible ([9a437ee](https://github.com/PicPeak/picpeak/commit/9a437ee9e19f6ecb8bff8747de71d3a9527d9512))
|
||||
* **usage:** synchronize setup consent and dismissal state ([77b4aab](https://github.com/PicPeak/picpeak/commit/77b4aab61a54d92b46fdc93fce5a075e3dc1d794))
|
||||
|
||||
## [3.130.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.130.1-beta.0...v3.130.2-beta.0) (2026-09-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce gallery access and consolidate gallery workflows ([#1357](https://github.com/PicPeak/picpeak/issues/1357)) ([f0e6d2d](https://github.com/PicPeak/picpeak/commit/f0e6d2dfb12460cb1d003802f346e2026fa1c016))
|
||||
|
||||
## [3.130.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.130.0-beta.0...v3.130.1-beta.0) (2026-09-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **images:** probe and clean up preview tiers under the extension the encoder actually wrote ([#1355](https://github.com/PicPeak/picpeak/issues/1355)) ([acb25a9](https://github.com/PicPeak/picpeak/commit/acb25a9a1ce9887e51ff769d98f65379a5a1b803))
|
||||
* **images:** single-flight lazy rendition generation and keep the old rendition during replacement ([#1350](https://github.com/PicPeak/picpeak/issues/1350)) ([c97341e](https://github.com/PicPeak/picpeak/commit/c97341e4547257aab57fb746bad4203a1adaf560))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* define security support across stable and main ([#1351](https://github.com/PicPeak/picpeak/issues/1351)) ([0e459b3](https://github.com/PicPeak/picpeak/commit/0e459b3293ce9132ee8bb8324c6e76692e580cc5))
|
||||
* refresh repository support and community links ([#1349](https://github.com/PicPeak/picpeak/issues/1349)) ([f83cbe9](https://github.com/PicPeak/picpeak/commit/f83cbe9109c5c7b25a0b50e0f0e3244d32421e7e))
|
||||
|
||||
## [3.130.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.129.0-beta.0...v3.130.0-beta.0) (2026-09-07)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ Unsure where to begin? You can start by looking through these issues:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Node.js 22.12.0 or later (matches `backend/package.json`)
|
||||
- Docker & Docker Compose
|
||||
- Git
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
|
||||
| 🧾 CRM & Accounting | [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm) · [disclaimers](https://docs.picpeak.app/features/crm/disclaimers) |
|
||||
| 🗺️ Roadmap | [GitHub Issues](https://github.com/PicPeak/picpeak/issues) |
|
||||
|
||||
**Project meta:** [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
**Project meta:** [Support](SUPPORT.md) · [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
|
||||
## 📊 Comparison with Alternatives
|
||||
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Getting help with PicPeak
|
||||
|
||||
## Documentation
|
||||
|
||||
Start with [docs.picpeak.app](https://docs.picpeak.app) for installation,
|
||||
configuration, gallery features and administration guides.
|
||||
|
||||
- [Getting started](https://docs.picpeak.app/getting-started)
|
||||
- [Deployment](https://docs.picpeak.app/deployment)
|
||||
- [Admin settings](https://docs.picpeak.app/guides/admin-settings)
|
||||
- [Release channels](https://docs.picpeak.app/deployment/release-channels)
|
||||
|
||||
## Questions and troubleshooting
|
||||
|
||||
Use [GitHub Discussions](https://github.com/PicPeak/picpeak/discussions) for
|
||||
setup questions, troubleshooting and advice from the community. Include your
|
||||
PicPeak version, deployment method and what you have already tried.
|
||||
|
||||
## Bugs and feature requests
|
||||
|
||||
Search [existing issues](https://github.com/PicPeak/picpeak/issues) first, then
|
||||
[choose an issue template](https://github.com/PicPeak/picpeak/issues/new/choose)
|
||||
to report a bug, suggest a feature or identify a documentation problem.
|
||||
|
||||
For bugs, include the exact version, reproduction steps and relevant logs.
|
||||
See [Contributing](CONTRIBUTING.md) for development and pull request guidance.
|
||||
|
||||
## Security vulnerabilities
|
||||
|
||||
Follow the [security policy](SECURITY.md) and use
|
||||
[private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
or email **info@picpeak.app**. Do not report vulnerabilities in public issues
|
||||
or discussions.
|
||||
@@ -23,7 +23,7 @@ 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;
|
||||
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage; let logInfo;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
|
||||
@@ -54,6 +54,10 @@ describe('admin thumbnail regeneration (#1129)', () => {
|
||||
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Same module registry as the route, so the spy sees its calls. The
|
||||
// completion line is what drain() below waits for.
|
||||
logInfo = jest.spyOn(require('../../src/utils/logger'), 'info');
|
||||
|
||||
// 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());
|
||||
@@ -94,8 +98,20 @@ describe('admin thumbnail regeneration (#1129)', () => {
|
||||
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));
|
||||
/**
|
||||
* The work runs in setImmediate, after the response. Wait for the loop's
|
||||
* "regeneration complete" log line rather than a fixed 150 ms: under a
|
||||
* loaded machine (fifteen suites in parallel, each booting a migrated
|
||||
* SQLite) the loop occasionally took longer than that, and the assertions
|
||||
* then ran against a half-finished mock call list.
|
||||
*/
|
||||
const drain = async () => {
|
||||
const deadline = Date.now() + 10000;
|
||||
const done = () => logInfo.mock.calls.some((c) => /regeneration complete/.test(String(c[0])));
|
||||
while (!done() && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
|
||||
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
|
||||
const eventId = await seedEvent();
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* POST /api/admin/business-profile/logo and PUT /api/admin/business-profile
|
||||
* — GHSA-6wrv-9pr4-hhmw regression coverage.
|
||||
*
|
||||
* The upload route used to take the stored file extension straight from
|
||||
* the client-supplied filename and only checked `file.mimetype` against an
|
||||
* allowlist — a file could declare an image MIME type while carrying a
|
||||
* `.html`/`.js` extension and arbitrary content, land in the same-origin
|
||||
* `/uploads/logos` static mount, and execute as script. The mass-assignable
|
||||
* `logoPath` field on PUT compounded it: an attacker could point the
|
||||
* "logo" at any other uploaded file.
|
||||
*
|
||||
* These tests pin:
|
||||
* (a) a MIME/extension mismatch is rejected at upload,
|
||||
* (b) the extension actually written to disk always matches the
|
||||
* validated MIME type, never the client-supplied filename,
|
||||
* (c) legitimate PNG/JPEG/SVG uploads still succeed,
|
||||
* (d) `logoPath` on PUT cannot be set to an arbitrary string pointing at
|
||||
* another file, only to a path the upload route itself produced.
|
||||
*
|
||||
* Defense-in-depth (not a re-opening of the above): fileFilter only pairs
|
||||
* the claimed MIME type against the extension — it can't see the bytes,
|
||||
* since it runs before multer finishes writing the stream to disk. A file
|
||||
* whose declared MIME/extension pair is valid but whose actual content
|
||||
* doesn't match (e.g. a PNG-declared upload that isn't really a PNG) is
|
||||
* now caught by validateFileContent() (magic-number check) after multer
|
||||
* writes it, closing the gap where declared-vs-actual content diverges.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bplogo-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bplogo-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
// Real magic-number-prefixed payloads, for content-sniffing to accept.
|
||||
const REAL_PNG_BYTES = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||
Buffer.from('not a real png body, but the header is real'),
|
||||
]);
|
||||
const REAL_JPEG_BYTES = Buffer.concat([
|
||||
Buffer.from([0xFF, 0xD8, 0xFF]),
|
||||
Buffer.from('not a real jpeg body, but the header is real'),
|
||||
]);
|
||||
|
||||
describe('business profile — logo upload content/extension validation', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let token;
|
||||
|
||||
const uploadLogo = (buffer, filename, mimetype) => request(app)
|
||||
.post('/api/admin/business-profile/logo')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.attach('logo', buffer, { filename, contentType: mimetype });
|
||||
|
||||
const put = (payload) => request(app)
|
||||
.put('/api/admin/business-profile')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(payload);
|
||||
|
||||
const get = () => request(app)
|
||||
.get('/api/admin/business-profile')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
const profileOf = (res) => (res.body.data || res.body).profile;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = buildRouteApp('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
// fileFilter rejections surface via Express's generic error handler
|
||||
// (the pre-existing behaviour of every sibling logo/favicon upload
|
||||
// route in this codebase — none of them special-case multer's
|
||||
// fileFilter `Error` into a 400 either), so the status code itself
|
||||
// can be 400 or 500 depending on environment. What actually matters
|
||||
// for GHSA-6wrv-9pr4-hhmw is that the request never succeeds and
|
||||
// nothing with the dangerous extension is ever written to disk.
|
||||
const logosDirFiles = () => {
|
||||
const logosDir = path.join(process.env.STORAGE_PATH, 'uploads', 'logos');
|
||||
return fs.existsSync(logosDir) ? fs.readdirSync(logosDir) : [];
|
||||
};
|
||||
|
||||
it('rejects an HTML/script payload disguised as an image via mismatched extension', async () => {
|
||||
const evil = Buffer.from('<script>alert(document.domain)</script>');
|
||||
const res = await uploadLogo(evil, 'evil.html', 'image/svg+xml');
|
||||
expect(res.status).not.toBe(200);
|
||||
expect(logosDirFiles().some((f) => f.endsWith('.html'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a .js file disguised with an image MIME type', async () => {
|
||||
const evil = Buffer.from('alert(1)');
|
||||
const res = await uploadLogo(evil, 'evil.js', 'image/png');
|
||||
expect(res.status).not.toBe(200);
|
||||
expect(logosDirFiles().some((f) => f.endsWith('.js'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a disallowed MIME type outright', async () => {
|
||||
const res = await uploadLogo(Buffer.from('whatever'), 'file.pdf', 'application/pdf');
|
||||
expect(res.status).not.toBe(200);
|
||||
expect(logosDirFiles().some((f) => f.endsWith('.pdf'))).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a legitimate PNG upload and stores it with a .png extension', async () => {
|
||||
const res = await uploadLogo(REAL_PNG_BYTES, 'logo.png', 'image/png');
|
||||
expect(res.status).toBe(200);
|
||||
const logoPath = (res.body.data || res.body).logoPath;
|
||||
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.png$/);
|
||||
|
||||
const onDisk = path.join(process.env.STORAGE_PATH, logoPath.replace(/^\//, ''));
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
expect(profileOf(await get()).logoPath).toBe(logoPath);
|
||||
});
|
||||
|
||||
it('accepts a legitimate JPEG upload and stores it with a .jpg extension', async () => {
|
||||
const res = await uploadLogo(REAL_JPEG_BYTES, 'logo.jpg', 'image/jpeg');
|
||||
expect(res.status).toBe(200);
|
||||
const logoPath = (res.body.data || res.body).logoPath;
|
||||
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.jpg$/);
|
||||
});
|
||||
|
||||
it('rejects a PNG-declared upload whose bytes are not actually a PNG, and leaves nothing on disk', async () => {
|
||||
const before = logosDirFiles();
|
||||
const res = await uploadLogo(Buffer.from('totally not a png'), 'logo.png', 'image/png');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/content does not match/i);
|
||||
|
||||
// No new file left behind: the rejected upload's own file was cleaned
|
||||
// up, and every other file on disk (if any) is unchanged.
|
||||
expect(logosDirFiles()).toEqual(before);
|
||||
});
|
||||
|
||||
it('accepts a legitimate SVG upload and always stores it with a .svg extension, even under a spoofed filename', async () => {
|
||||
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>');
|
||||
// Client-declared filename ext is .svg here to pass validateFileType
|
||||
// (mismatched ext is covered by the rejection tests above); the point
|
||||
// of this test is that the ON-DISK extension comes from the MIME type
|
||||
// lookup table, not path.extname(originalname).
|
||||
const res = await uploadLogo(svg, 'vector-logo.svg', 'image/svg+xml');
|
||||
expect(res.status).toBe(200);
|
||||
const logoPath = (res.body.data || res.body).logoPath;
|
||||
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.svg$/);
|
||||
});
|
||||
|
||||
it('rejects logoPath on PUT set to an arbitrary string pointing at another file', async () => {
|
||||
const before = profileOf(await get()).logoPath;
|
||||
|
||||
const res = await put({ logoPath: '/uploads/logos/cms-somepage-1234.png' });
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
expect(profileOf(await get()).logoPath).toBe(before);
|
||||
});
|
||||
|
||||
it('rejects logoPath on PUT with a path-traversal payload', async () => {
|
||||
const res = await put({ logoPath: '/uploads/logos/../../../../etc/passwd' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts logoPath on PUT when it matches the pattern this route itself writes', async () => {
|
||||
const upload = await uploadLogo(REAL_PNG_BYTES, 'logo2.png', 'image/png');
|
||||
const uploadedPath = (upload.body.data || upload.body).logoPath;
|
||||
|
||||
// Round-trip: PUT-ing back the exact value the upload endpoint
|
||||
// returned (what the frontend's generic profile save does) must
|
||||
// keep working.
|
||||
const res = await put({ logoPath: uploadedPath });
|
||||
expect(res.status).toBe(200);
|
||||
expect(profileOf(await get()).logoPath).toBe(uploadedPath);
|
||||
});
|
||||
|
||||
it('still allows clearing logoPath with an empty string', async () => {
|
||||
const res = await put({ logoPath: '' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(profileOf(await get()).logoPath).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -129,8 +129,15 @@ describe('preview tiers (#1095)', () => {
|
||||
it('derives every non-default tier key for cleanup', () => {
|
||||
// Tiers live outside preview_path, so delete/archive/regenerate have no
|
||||
// other way to find them. 1920 is excluded because that IS preview_path.
|
||||
// Two candidates per width — the encoder picks `.jpg` or `.webp` and the
|
||||
// cleanup list cannot know which without probing the source.
|
||||
const keys = imageProcessor.previewTierKeys({ id: 5, path: 'e/a.jpg', source_origin: 'managed' });
|
||||
expect(keys).toHaveLength(imageProcessor.PREVIEW_WIDTHS.length - 1);
|
||||
const widths = imageProcessor.PREVIEW_WIDTHS.filter((w) => w !== 1920);
|
||||
expect(keys).toHaveLength(widths.length * 2);
|
||||
for (const w of widths) {
|
||||
expect(keys).toContain(`previews/preview_w${w}_p5_a.jpg`);
|
||||
expect(keys).toContain(`previews/preview_w${w}_p5_a.webp`);
|
||||
}
|
||||
expect(keys.some((k) => k.includes('w1920'))).toBe(false);
|
||||
expect(keys.every((k) => k.includes('p5_'))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -54,6 +54,7 @@ maybe('product usage on Postgres', () => {
|
||||
await require('../../migrations/core/204_product_usage_privacy_receipts').up(db);
|
||||
await require('../../migrations/core/205_product_usage_consent_version').up(db);
|
||||
await require('../../migrations/core/206_product_usage_delivery_backoff').up(db);
|
||||
await require('../../migrations/core/212_product_usage_prompt_shown').up(db);
|
||||
|
||||
await db.schema.createTable('app_settings', (t) => {
|
||||
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
|
||||
@@ -120,6 +121,24 @@ maybe('product usage on Postgres', () => {
|
||||
// back as a STRING — the tick() gate compares it against a number.
|
||||
expect(cols.attempts).toBeDefined();
|
||||
expect(cols.next_attempt_at).toBeDefined();
|
||||
expect(cols.prompt_shown).toBeDefined();
|
||||
});
|
||||
|
||||
it('backfills the prompt for existing participation using PostgreSQL booleans', async () => {
|
||||
const migration = require('../../migrations/core/212_product_usage_prompt_shown');
|
||||
await migration.down(db);
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' });
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
expect(await service().status()).toMatchObject({ status: 'active', prompt_shown: true, consent_version: 'usage-consent.v2' });
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'disabled' });
|
||||
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true });
|
||||
});
|
||||
|
||||
it('persists a fresh installation declining without opting in on PostgreSQL', async () => {
|
||||
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: false });
|
||||
await service().markPromptShown();
|
||||
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
|
||||
});
|
||||
|
||||
it('reruns the backoff migration safely', async () => {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
const knex = require('knex');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { randomUUID } = require('crypto');
|
||||
const migration = require('../../migrations/core/211_revocations_without_expiry');
|
||||
|
||||
for (const client of ['sqlite3', 'pg']) {
|
||||
const enabled = client !== 'pg' || process.env.PICPEAK_PG_TEST_URL;
|
||||
(enabled ? describe : describe.skip)(`token revocation expiry (${client})`, () => {
|
||||
let db, owner, schema, revocation;
|
||||
const sign = claims => jwt.sign({ id: 1, type: 'admin', jti: randomUUID(), ...claims }, process.env.JWT_SECRET);
|
||||
|
||||
beforeAll(async () => {
|
||||
if (client === 'pg') {
|
||||
schema = `revocation_${randomUUID().replace(/-/g, '')}`;
|
||||
owner = knex({ client, connection: process.env.PICPEAK_PG_TEST_URL });
|
||||
await owner.schema.createSchema(schema);
|
||||
db = knex({ client, connection: process.env.PICPEAK_PG_TEST_URL, searchPath: [schema] });
|
||||
} else {
|
||||
db = knex({ client, connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
}
|
||||
// Exercise the upgrade from the real legacy NOT NULL schema as well as
|
||||
// repeated migration runs, without sharing another test's database.
|
||||
await require('../../migrations/legacy/017_add_token_revocation_tables').up(db);
|
||||
await db('revoked_tokens').insert({ token_id: 'existing', expires_at: '2099-01-01T00:00:00.000Z' });
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
revocation = require('../../src/utils/tokenRevocation');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.destroy();
|
||||
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
|
||||
jest.dontMock('../../src/database/db');
|
||||
});
|
||||
|
||||
it('preserves existing revocations and their unique key during upgrade', async () => {
|
||||
expect(await db('revoked_tokens').where({ token_id: 'existing' }).first()).toBeTruthy();
|
||||
await expect(db('revoked_tokens').insert({ token_id: 'existing', expires_at: null })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each([true, false])('permanently revokes a token without exp (jti: %s)', async withJti => {
|
||||
const token = sign(withJti ? {} : { jti: undefined });
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
expect(await revocation.isTokenRevoked(payload)).toBe(false);
|
||||
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
|
||||
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(payload)).toBe(true);
|
||||
const rows = await db('revoked_tokens').where({ token_id: revocation.buildTokenId(payload) });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].expires_at).toBeNull();
|
||||
});
|
||||
|
||||
it('cleans up expired revocations and retains future ones', async () => {
|
||||
const expired = sign({ exp: Math.floor(Date.now() / 1000) - 60 });
|
||||
const future = sign({ exp: Math.floor(Date.now() / 1000) + 3600 });
|
||||
expect(await revocation.revokeToken(expired, 'logout')).toBe(true);
|
||||
expect(await revocation.revokeToken(future, 'logout')).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(expired))).toBe(false);
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(future))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([true, false])('upgrades an expiring entry with the same key permanently (jti: %s)', async withJti => {
|
||||
const claims = { id: 99, iat: Math.floor(Date.now() / 1000), jti: withJti ? randomUUID() : undefined };
|
||||
const expiring = sign({ ...claims, exp: claims.iat - 60 });
|
||||
const permanent = sign(claims);
|
||||
expect(await revocation.revokeToken(expiring, 'logout')).toBe(true);
|
||||
expect(await revocation.revokeToken(permanent, 'logout')).toBe(true);
|
||||
expect(await revocation.revokeToken(expiring, 'logout')).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(permanent))).toBe(true);
|
||||
});
|
||||
|
||||
it('retains a signed token whose numeric expiry cannot fit a database timestamp', async () => {
|
||||
const token = sign({ exp: 1e100 });
|
||||
expect(await revocation.revokeToken(token, 'logout')).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(token))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a rollback that would remove permanent revocations', async () => {
|
||||
await expect(migration.down(db)).rejects.toThrow('permanent token revocations');
|
||||
expect((await db('revoked_tokens').columnInfo('expires_at')).nullable).toBe(true);
|
||||
// A rollback with only expiring records remains supported and reversible.
|
||||
await db('revoked_tokens').whereNull('expires_at').delete();
|
||||
await migration.down(db);
|
||||
await migration.down(db);
|
||||
expect((await db('revoked_tokens').columnInfo('expires_at')).nullable).toBe(false);
|
||||
await migration.up(db);
|
||||
expect(await db('revoked_tokens').where({ token_id: 'existing' }).first()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -5,9 +5,10 @@ process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
|
||||
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
|
||||
|
||||
const http = require('http');
|
||||
const { db } = require('../../src/database/db');
|
||||
const webhookService = require('../../src/services/webhookService');
|
||||
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
let db, cleanup, adminId;
|
||||
let webhookService;
|
||||
let __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker;
|
||||
|
||||
// Local-only test stub: matches what dev/webhook-receiver/server.js does
|
||||
// in the docker-compose flow but spun up inside the Jest process so the
|
||||
@@ -43,7 +44,7 @@ async function insertWebhook(url, events = ['event.published'], extras = {}) {
|
||||
secret_preview: preview,
|
||||
events: JSON.stringify(events),
|
||||
active: extras.active !== false,
|
||||
created_by: 1,
|
||||
created_by: adminId,
|
||||
}).returning('id');
|
||||
const id = insert[0]?.id || insert[0];
|
||||
return { id, secret: plaintext };
|
||||
@@ -56,16 +57,15 @@ async function clearWebhooks() {
|
||||
|
||||
describe('webhook delivery worker (#327)', () => {
|
||||
beforeAll(async () => {
|
||||
// Schema is expected to already be applied by `npm run migrate`. We
|
||||
// just verify the webhooks tables exist; if not, the test harness has
|
||||
// missed running migration 082.
|
||||
const ok = await db.schema.hasTable('webhooks');
|
||||
if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first');
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
webhookService = require('../../src/services/webhookService');
|
||||
({ __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker'));
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
stopWebhookDeliveryWorker();
|
||||
await db.destroy();
|
||||
await stopWebhookDeliveryWorker();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* GHSA-h4w8-57xq-53fx enforcement half: `must_change_password` was written
|
||||
* by the admin password-reset flow (userManagementService.resetAdminPassword)
|
||||
* and returned in a few response payloads, but no route-blocking logic ever
|
||||
* checked it — a reset admin could keep using the old/weak password on every
|
||||
* protected route indefinitely. adminAuth() is now the server-side backstop:
|
||||
* a flagged admin gets 403 MUST_CHANGE_PASSWORD on everything except the
|
||||
* routes they need to clear the flag (change-password) or leave (logout).
|
||||
*
|
||||
* Mirrors the mocking shape of adminAuthRoleFallback.test.js — a stub `db`
|
||||
* chain, no real SQLite needed, so this stays a fast unit test.
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
|
||||
jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn().mockResolvedValue(false) }));
|
||||
jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), info: jest.fn() }));
|
||||
|
||||
let mockMustChangePassword = false;
|
||||
const mockAdminRow = { id: 7, username: 'scoped', email: 's@example.com', password_changed_at: null, role_id: 1, role_name: 'editor' };
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => ({
|
||||
leftJoin() { return this; },
|
||||
where() { return this; },
|
||||
select() { return this; },
|
||||
first: () => Promise.resolve({ ...mockAdminRow, must_change_password: mockMustChangePassword }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { adminAuth } = require('../../src/middleware/auth');
|
||||
|
||||
const SECRET = 'test-secret-for-must-change-password';
|
||||
|
||||
function makeReq(originalUrl) {
|
||||
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: {}, originalUrl };
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
return {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
}
|
||||
|
||||
describe('adminAuth must_change_password enforcement (GHSA-h4w8-57xq-53fx)', () => {
|
||||
const OLD_SECRET = process.env.JWT_SECRET;
|
||||
beforeAll(() => { process.env.JWT_SECRET = SECRET; });
|
||||
afterAll(() => { process.env.JWT_SECRET = OLD_SECRET; });
|
||||
beforeEach(() => { mockMustChangePassword = false; });
|
||||
|
||||
it('blocks an arbitrary protected route with 403 MUST_CHANGE_PASSWORD when the flag is set', async () => {
|
||||
mockMustChangePassword = true;
|
||||
const req = makeReq('/api/admin/dashboard/stats');
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual(expect.objectContaining({ code: 'MUST_CHANGE_PASSWORD' }));
|
||||
expect(req.admin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not block when the flag is not set', async () => {
|
||||
mockMustChangePassword = false;
|
||||
const req = makeReq('/api/admin/dashboard/stats');
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.admin.mustChangePassword).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/api/admin/auth/change-password'],
|
||||
['/api/admin/auth/logout'],
|
||||
])('still allows %s through when the flag is set', async (originalUrl) => {
|
||||
mockMustChangePassword = true;
|
||||
const req = makeReq(originalUrl);
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.admin.mustChangePassword).toBe(true);
|
||||
expect(res.statusCode).toBeNull();
|
||||
});
|
||||
|
||||
it('allows the exempt change-password path even with a query string', async () => {
|
||||
mockMustChangePassword = true;
|
||||
const req = makeReq('/api/admin/auth/change-password?foo=bar');
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not exempt a route that merely starts with the change-password path', async () => {
|
||||
mockMustChangePassword = true;
|
||||
const req = makeReq('/api/admin/auth/change-password-history');
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The chunked upload client posts each chunk as application/octet-stream and
|
||||
* the route reads the raw request stream. The CSRF gate answered every such
|
||||
* request 415 before it reached the route, so the endpoint never accepted a
|
||||
* chunk (PicPeak/picpeak#1377).
|
||||
*
|
||||
* The gate's origin check is the CSRF defence. The Content-Type list only
|
||||
* has to keep out what a cross-site page can send without a preflight, and
|
||||
* application/octet-stream is not on that list: an HTML form cannot produce
|
||||
* it and fetch() with it is not CORS-safelisted.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }));
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
// Same order as server.js: scoped JSON parser, then the gate on /api.
|
||||
app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use('/api', require('../../src/middleware/csrf'));
|
||||
// Mirrors the chunk route in adminPhotos.js: consume the raw stream.
|
||||
app.post('/api/admin/photos/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
res.json({ received: Buffer.concat(chunks).toString('base64') });
|
||||
});
|
||||
app.post('/api/admin/other', (req, res) => res.json({ body: req.body }));
|
||||
return app;
|
||||
}
|
||||
|
||||
const CHUNK_PATH = '/api/admin/photos/1/chunked-upload/abc/chunk/0';
|
||||
|
||||
describe('CSRF gate and application/octet-stream', () => {
|
||||
it('lets a same-origin octet-stream chunk reach the route byte for byte', async () => {
|
||||
// Not valid UTF-8, so a text decode anywhere on the path would show up.
|
||||
const payload = Buffer.concat([Buffer.from('chunkbytes'), Buffer.from([0xff, 0x00, 0xfe])]);
|
||||
const res = await request(buildApp())
|
||||
.post(CHUNK_PATH)
|
||||
.set('sec-fetch-site', 'same-origin')
|
||||
.set('Content-Type', 'application/octet-stream')
|
||||
.send(payload);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Buffer.from(res.body.received, 'base64').equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it('still rejects a cross-site octet-stream post on origin', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post(CHUNK_PATH)
|
||||
.set('sec-fetch-site', 'cross-site')
|
||||
.set('Content-Type', 'application/octet-stream')
|
||||
.send(Buffer.from('chunkbytes'));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('still rejects the types a form can send', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/api/admin/other')
|
||||
.set('sec-fetch-site', 'same-origin')
|
||||
.set('Content-Type', 'text/plain')
|
||||
.send('x=1');
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
|
||||
it('leaves a JSON route with an empty body on an octet-stream post', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/api/admin/other')
|
||||
.set('sec-fetch-site', 'same-origin')
|
||||
.set('Content-Type', 'application/octet-stream')
|
||||
.send(Buffer.from('{"a":1}'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ const fake = { maintenance: 'true', revoked: false, beforeCutoff: false, admin:
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const db = jest.fn((table) => {
|
||||
const q = {
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
first: jest.fn(async () => {
|
||||
@@ -27,6 +28,7 @@ jest.mock('../../src/database/db', () => {
|
||||
return { setting_key: 'general_maintenance_mode', setting_value: fake.maintenance };
|
||||
}
|
||||
if (table === 'admin_users') return fake.admin;
|
||||
if (table === 'events') return { id: 1, slug: 'preview', created_by: 1, is_active: 1 };
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
@@ -34,6 +36,7 @@ jest.mock('../../src/database/db', () => {
|
||||
});
|
||||
return { db, withRetry: (fn) => fn() };
|
||||
});
|
||||
jest.mock('../../src/middleware/permissions', () => ({ userHasAllPermissions: jest.fn().mockResolvedValue(true) }));
|
||||
jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }));
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn(async () => fake.revoked) }));
|
||||
jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn(async () => fake.beforeCutoff) }));
|
||||
@@ -75,7 +78,7 @@ describe('general rate limiter skip', () => {
|
||||
});
|
||||
|
||||
describe('admin preview requires a live admin session', () => {
|
||||
const req = (token) => ({ query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} });
|
||||
const req = (token) => ({ params: { slug: 'preview' }, query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} });
|
||||
beforeEach(() => { fake.revoked = false; fake.beforeCutoff = false; fake.admin = { id: 1, password_changed_at: null }; });
|
||||
|
||||
it('passes for a live session and sets req.isAdminPreview', async () => {
|
||||
@@ -108,13 +111,23 @@ describe('multipart origin gate', () => {
|
||||
const req = (headers) => ({ headers: { host: 'photos.example.com', ...headers } });
|
||||
it('accepts same-origin, same-site and non-browser requests', () => {
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-origin' }))).toBe(true);
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(true);
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(false);
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'none' }))).toBe(true);
|
||||
expect(multipartOriginAllowed(req({}))).toBe(true);
|
||||
expect(multipartOriginAllowed(req({ origin: 'https://photos.example.com' }))).toBe(true);
|
||||
// Same-origin install without FRONTEND_URL: Origin matches the Host.
|
||||
expect(multipartOriginAllowed({ headers: { host: 'gallery.local', origin: 'http://gallery.local' } })).toBe(true);
|
||||
});
|
||||
it('trusts Fetch Metadata same-origin before the Origin/scheme comparison', () => {
|
||||
// TLS terminated upstream without X-Forwarded-Proto: req.protocol is http
|
||||
// while the browser's Origin is https. Login must still work.
|
||||
// gallery.local is not in the configured allowlist, so only the Host/scheme
|
||||
// comparison or Fetch Metadata can admit it.
|
||||
const proxied = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local', 'sec-fetch-site': 'same-origin' } };
|
||||
expect(multipartOriginAllowed(proxied)).toBe(true);
|
||||
const legacyBrowser = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local' } };
|
||||
expect(multipartOriginAllowed(legacyBrowser)).toBe(false);
|
||||
});
|
||||
it('rejects cross-site form posts', () => {
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'cross-site' }))).toBe(false);
|
||||
expect(multipartOriginAllowed(req({ origin: 'https://evil.example' }))).toBe(false);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const knex = require('knex');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { UsageService } = require('../../src/usage/UsageService');
|
||||
const { generateIdentity, digest, canonical } = require('../../src/usage/protocol.cjs');
|
||||
const migration = require('../../migrations/core/212_product_usage_prompt_shown');
|
||||
|
||||
let db;
|
||||
let directory;
|
||||
beforeEach(async () => {
|
||||
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-prompt-test-'));
|
||||
for (const name of [
|
||||
'201_product_usage', '202_product_usage_cancel_requested', '203_product_usage_cancel_seq',
|
||||
'204_product_usage_privacy_receipts', '205_product_usage_consent_version', '206_product_usage_delivery_backoff'
|
||||
]) await require(`../../migrations/core/${name}`).up(db);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.each(['active', 'activation_pending', 'deletion_pending', 'identity_conflict'])(
|
||||
'preserves an existing %s participation without altering its consent or pending packet', async (status) => {
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status, consent_version: 'usage-consent.v2', pending_packet: 'retained-packet',
|
||||
});
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
const state = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(state).toMatchObject({ status, consent_version: 'usage-consent.v2', pending_packet: 'retained-packet', prompt_shown: 1 });
|
||||
}
|
||||
);
|
||||
|
||||
test('a previously participating installation stays acknowledged after a confirmed withdrawal', async () => {
|
||||
const actions = [];
|
||||
const service = new UsageService(db, {
|
||||
secret: 'test-only-prompt-encryption-secret-32-characters',
|
||||
endpoint: 'https://collector.example.test',
|
||||
bindingPath: path.join(directory, 'instance.key'),
|
||||
fetch: async (_url, init) => {
|
||||
const { packet } = JSON.parse(init.body);
|
||||
actions.push(packet.action);
|
||||
return new Response(JSON.stringify({
|
||||
packet_id: packet.packet_id, installation_id: packet.installation_id,
|
||||
packet_digest: digest(canonical(packet)), action: packet.action,
|
||||
sequence: packet.sequence, status: 'deleted',
|
||||
}));
|
||||
},
|
||||
});
|
||||
const identity = generateIdentity();
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'active', notice_dismissed: 1, consent_version: 'usage-consent.v5', sequence: 1,
|
||||
installation_id: identity.installation_id, public_key: identity.public_key,
|
||||
private_key_encrypted: service.encrypt(identity.private_key), instance_binding: await service.binding(true),
|
||||
});
|
||||
await migration.up(db);
|
||||
const state = await service.disable();
|
||||
expect(actions).toEqual(['delete']);
|
||||
expect(state).toMatchObject({ status: 'disabled', prompt_shown: true, installation_id: null });
|
||||
expect(state.privacy_receipts.last_deletion.status).toBe('collector-confirmed');
|
||||
});
|
||||
|
||||
test('a fresh installation can decline once without changing consent or the separate banner', async () => {
|
||||
await migration.up(db);
|
||||
const fetch = jest.fn();
|
||||
const service = new UsageService(db, { fetch });
|
||||
expect(await service.status()).toMatchObject({ status: 'disabled', prompt_shown: false, notice_dismissed: false });
|
||||
await service.markPromptShown();
|
||||
await migration.up(db);
|
||||
expect(await service.status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('migration guards tolerate a missing table', async () => {
|
||||
await db.schema.dropTable('product_usage_state');
|
||||
await expect(migration.up(db)).resolves.toBeUndefined();
|
||||
await expect(migration.down(db)).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
const knex = require('knex');
|
||||
const migration = require('../../migrations/core/210_events_updated_at');
|
||||
const { toTimestamp } = require('../../src/utils/dateNormalize');
|
||||
const { randomUUID } = require('crypto');
|
||||
|
||||
const engines = [['sqlite', null], ...(process.env.PICPEAK_PG_TEST_URL ? [['pg', process.env.PICPEAK_PG_TEST_URL]] : [])];
|
||||
describe.each(engines)('event timestamp migration contract (%s)', (engine, connection) => {
|
||||
let db, owner, schema;
|
||||
beforeEach(async () => {
|
||||
if (engine === 'pg') {
|
||||
schema = `event_contract_${randomUUID().replace(/-/g, '')}`;
|
||||
owner = knex({ client: 'pg', connection });
|
||||
await owner.schema.createSchema(schema);
|
||||
db = knex({ client: 'pg', connection, searchPath: [schema] });
|
||||
} else db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
});
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
|
||||
});
|
||||
it('upgrades legacy data, is repeatable and preserves subsequent edits', async () => {
|
||||
await db.schema.createTable('events', table => {
|
||||
table.increments('id'); table.timestamp('created_at').defaultTo(db.fn.now()); table.boolean('is_active').defaultTo(true);
|
||||
});
|
||||
const created = '2026-01-02T03:04:05.000Z';
|
||||
await db('events').insert({ created_at: created });
|
||||
await migration.up(db); await migration.up(db);
|
||||
let row = await db('events').first();
|
||||
expect(toTimestamp(row.updated_at)).toBe(Date.parse(created));
|
||||
await db('events').where({ id: row.id }).update({ updated_at: db.fn.now(), is_active: engine === 'pg' ? false : 0 });
|
||||
const changed = (await db('events').first()).updated_at;
|
||||
await migration.up(db); row = await db('events').first();
|
||||
expect(toTimestamp(row.updated_at)).toBe(toTimestamp(changed)); expect([false, 0]).toContain(row.is_active);
|
||||
});
|
||||
it('handles a fresh table and an already present updated_at column', async () => {
|
||||
await db.schema.createTable('events', table => { table.increments('id'); table.timestamp('created_at'); table.timestamp('updated_at'); });
|
||||
await migration.up(db);
|
||||
expect(await db.schema.hasColumn('events', 'updated_at')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
const knex = require('knex');
|
||||
const { randomUUID } = require('crypto');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const request = require('supertest');
|
||||
const pgUrl = process.env.PICPEAK_PG_TEST_URL;
|
||||
(pgUrl ? describe : describe.skip)('fresh PostgreSQL gallery contract', () => {
|
||||
let owner, db, schema, tmpDir, cleanup, previousClient;
|
||||
beforeAll(async () => {
|
||||
schema = `fresh_gallery_${randomUUID().replace(/-/g, '')}`;
|
||||
owner = knex({ client: 'pg', connection: pgUrl });
|
||||
await owner.schema.createSchema(schema);
|
||||
previousClient = process.env.DATABASE_CLIENT;
|
||||
process.env.DATABASE_CLIENT = 'pg';
|
||||
process.env.JWT_SECRET = 'fresh-pg-gallery-test-secret-at-least-32-characters';
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-fresh-pg-'));
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
jest.doMock('../../knexfile', () => ({ client: 'pg', connection: pgUrl, searchPath: [schema] }));
|
||||
({ db } = require('../../src/database/db'));
|
||||
// bootCrmDb runs the complete core chain against the shared db singleton.
|
||||
({ cleanup } = await require('../integration/helpers/crmDb').bootCrmDb());
|
||||
}, 120000);
|
||||
afterAll(async () => {
|
||||
await require('../../src/services/serviceShutdown').stopServices();
|
||||
if (cleanup) await cleanup(); else if (db) await db.destroy();
|
||||
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
|
||||
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
if (previousClient === undefined) delete process.env.DATABASE_CLIENT; else process.env.DATABASE_CLIENT = previousClient;
|
||||
jest.dontMock('../../knexfile');
|
||||
});
|
||||
it('creates through the real admin route, then toggles a typed boolean and timestamp', async () => {
|
||||
const { seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId);
|
||||
const app = buildRouteApp('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
const bearer = `Bearer ${mintAdminToken(adminId)}`;
|
||||
const created = await request(app).post('/api/admin/events').set('Authorization', bearer).send({
|
||||
event_type: 'wedding', event_name: 'Fresh PostgreSQL', event_date: '2026-10-01',
|
||||
customer_name: 'Customer', customer_email: 'customer@example.test', admin_email: 'admin@example.test',
|
||||
password: 'Strong-Test-Photo-Pass-924!', expiration_days: 30, feedback_enabled: true,
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
const event = await db('events').where({ event_name: 'Fresh PostgreSQL' }).first();
|
||||
expect(event.created_by).toBe(adminId);
|
||||
expect(event.is_active).toBe(true);
|
||||
expect(event.updated_at).toBeInstanceOf(Date);
|
||||
expect(await db('event_feedback_settings').where({ event_id: event.id }).first()).toBeTruthy();
|
||||
const toggled = await request(app).post(`/api/admin/events/${event.id}/toggle-status`).set('Authorization', bearer).send({});
|
||||
expect(toggled.status).toBe(200);
|
||||
const row = await db('events').where({ id: event.id }).first();
|
||||
expect(row.is_active).toBe(false);
|
||||
expect(row.updated_at).toBeInstanceOf(Date);
|
||||
await require('../../migrations/core/210_events_updated_at').up(db);
|
||||
expect((await db('events').where({ id: event.id }).first()).updated_at).toEqual(row.updated_at);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Same bug class as GHSA-9q5j-vqfw-32hr (fixed in adminEvents/logo.js) —
|
||||
* the signed-PDF upload's multer `filename` callback built the stored
|
||||
* path directly from `req.params.id` with no integer validation:
|
||||
*
|
||||
* filename: (req, file, cb) => {
|
||||
* cb(null, `contract-${req.params.id}-${Date.now()}${ext}`);
|
||||
* }
|
||||
*
|
||||
* `POST /:id/upload-signed-pdf` declares `param('id').isInt({ min: 1 })`,
|
||||
* but express-validator's check only runs inside the route handler via
|
||||
* validateRequest(req) — AFTER multer has already parsed the multipart
|
||||
* body and invoked the filename callback. A traversal payload in the raw
|
||||
* `:id` URL segment reaches multer completely unvalidated.
|
||||
*
|
||||
* Fixed by rejecting any non-positive-integer id before it is used to
|
||||
* build the filename, independent of the declared-but-too-late
|
||||
* express-validator check.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// ALLOWED_MEDIA_TYPES in fileSecurityUtils.js only defines image/video
|
||||
// entries, so the route's real fileFilter (validateFileType(..., ['application/pdf']))
|
||||
// rejects every PDF upload with "Only PDF files are allowed" — a
|
||||
// separate, pre-existing bug unrelated to the path-traversal fix under
|
||||
// test here (also present in publicContracts.js, which is why neither
|
||||
// suite exercises a successful upload). Stub validateFileType so this
|
||||
// suite can drive the full route, including the filename-callback fix,
|
||||
// end-to-end.
|
||||
jest.mock('../../src/utils/fileSecurityUtils', () => {
|
||||
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
|
||||
return {
|
||||
...actual,
|
||||
validateFileType: (filename, mimetype, allowedTypes) => allowedTypes.includes(mimetype),
|
||||
};
|
||||
});
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-contracts-signed-pdf-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-contracts-signed-pdf-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('POST /api/admin/contracts/:id/upload-signed-pdf — path traversal guard', () => {
|
||||
let db; let cleanup; let app; let adminId; let customerId; let token;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
// Feature flag defaults OFF on a fresh install — the contracts
|
||||
// router 403s every route until it's on.
|
||||
await db('feature_flags').where({ key: 'contracts' }).update({ value: true });
|
||||
|
||||
app = buildRouteApp('/api/admin/contracts', require('../../src/routes/adminContracts'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
const signedDir = () => path.join(process.env.STORAGE_PATH, 'uploads/contracts/signed');
|
||||
|
||||
async function insertContract(over = {}) {
|
||||
const base = {
|
||||
contract_number: `K-TEST-${Math.random().toString(16).slice(2, 8)}`,
|
||||
customer_account_id: customerId,
|
||||
title: 'Test Contract',
|
||||
issue_date: new Date().toISOString().slice(0, 10),
|
||||
status: 'sent',
|
||||
language: 'de',
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const inserted = await db('contracts').insert(base).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
}
|
||||
|
||||
it('rejects a traversal payload in the id param instead of writing outside uploads/contracts/signed', async () => {
|
||||
// '../../../../tmp/pwned' URL-encoded so the raw request path still
|
||||
// has a single segment (matches Express's `:id`), but Express
|
||||
// decodes the param back into literal '../' sequences before the
|
||||
// route sees it.
|
||||
const traversalId = encodeURIComponent('../../../../tmp/pwned');
|
||||
|
||||
const res = await auth(
|
||||
request(app).post(`/api/admin/contracts/${traversalId}/upload-signed-pdf`)
|
||||
).attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.body.error).toMatch(/invalid contract id/i);
|
||||
|
||||
// No file should have been written anywhere — the filename callback
|
||||
// must error out before multer opens a write stream.
|
||||
const escapedFile = path.join(os.tmpdir(), 'pwned');
|
||||
expect(fs.existsSync(escapedFile)).toBe(false);
|
||||
if (fs.existsSync(signedDir())) {
|
||||
expect(fs.readdirSync(signedDir())).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('still accepts a normal numeric contract id', async () => {
|
||||
const id = await insertContract();
|
||||
|
||||
const res = await auth(
|
||||
request(app).post(`/api/admin/contracts/${id}/upload-signed-pdf`)
|
||||
).attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const files = fs.readdirSync(signedDir());
|
||||
expect(files.some((f) => f.startsWith(`contract-${id}-`))).toBe(true);
|
||||
|
||||
const row = await db('contracts').where({ id }).first();
|
||||
expect(row.status).toBe('fully_signed');
|
||||
expect(row.signed_pdf_path).toMatch(new RegExp(`contract-${id}-`));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* GHSA-9q5j-vqfw-32hr — the event-logo upload's multer `filename` callback
|
||||
* built the stored path directly from `req.params.id` with no integer
|
||||
* validation:
|
||||
*
|
||||
* filename: (req, file, cb) => {
|
||||
* cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
|
||||
* }
|
||||
*
|
||||
* A traversal payload in the `:id` route param (URL-encoded so it still
|
||||
* matches a single Express path segment, then decoded back into literal
|
||||
* `../` sequences by Express before handlers see it) could escape the
|
||||
* intended uploads/logos/events/ directory. Most directly reachable via a
|
||||
* super_admin session: requireEventOwnership short-circuits with next() and
|
||||
* zero DB lookup for that role (src/middleware/ownership.js), so nothing
|
||||
* upstream of multer validates the id first.
|
||||
*
|
||||
* Fixed by rejecting any non-positive-integer id before it is used to build
|
||||
* the filename, regardless of role or ownership-check ordering.
|
||||
*/
|
||||
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-events-logo-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-logo-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
async function insertEvent(db, adminId, over = {}) {
|
||||
const base = {
|
||||
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Wedding',
|
||||
event_date: '2026-05-29',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
||||
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_by: adminId,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('POST /api/admin/events/:id/logo — path traversal guard', () => {
|
||||
let db; let cleanup; let app; let adminId; let token;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
// super_admin: requireEventOwnership short-circuits with no DB lookup
|
||||
// for this role, so it reaches multer with nothing upstream having
|
||||
// validated the id — the exact path GHSA-9q5j-vqfw-32hr exploited.
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
const logoDir = () => path.join(process.env.STORAGE_PATH, 'uploads/logos/events');
|
||||
|
||||
it('rejects a traversal payload in the id param instead of writing outside uploads/logos/events', async () => {
|
||||
// '../../../../tmp/pwned' URL-encoded so the raw request path still has
|
||||
// a single segment (matches Express's `:id`), but Express decodes the
|
||||
// param back into literal '../' sequences before the route sees it.
|
||||
const traversalId = encodeURIComponent('../../../../tmp/pwned');
|
||||
|
||||
const res = await auth(
|
||||
request(app).post(`/api/admin/events/${traversalId}/logo`)
|
||||
).attach('logo', Buffer.from('fake image data'), 'logo.png');
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.body.error).toMatch(/invalid event id/i);
|
||||
|
||||
// No file should have been written anywhere — the filename callback
|
||||
// must error out before multer opens a write stream.
|
||||
const escapedFile = path.join(os.tmpdir(), 'pwned');
|
||||
expect(fs.existsSync(escapedFile)).toBe(false);
|
||||
if (fs.existsSync(logoDir())) {
|
||||
expect(fs.readdirSync(logoDir())).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('still accepts a normal numeric event id', async () => {
|
||||
const id = await insertEvent(db, adminId, { event_name: 'Logo Event' });
|
||||
|
||||
const res = await auth(
|
||||
request(app).post(`/api/admin/events/${id}/logo`)
|
||||
).attach('logo', Buffer.from('fake image data'), 'logo.png');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hero_logo_url).toMatch(new RegExp(`^/uploads/logos/events/event-${id}-logo-`));
|
||||
|
||||
const files = fs.readdirSync(logoDir());
|
||||
expect(files.some((f) => f.startsWith(`event-${id}-logo-`))).toBe(true);
|
||||
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.hero_logo_url).toBe(res.body.hero_logo_url);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ const { authenticator } = require('otplib');
|
||||
const {
|
||||
bootCrmDb, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
const mfaService = require('../../src/services/mfaService');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
@@ -235,6 +236,138 @@ describe('MFA disable — /api/admin/auth/mfa/disable', () => {
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
});
|
||||
|
||||
// Concurrency regression: a plain UPDATE with no conditional guard let two
|
||||
// requests carrying the same captured code both read the same
|
||||
// two_factor_last_used_step and both persist, defeating replay protection.
|
||||
// The guarded UPDATE (mfaService.persistTotpStep) makes only the first
|
||||
// writer's affected-row count > 0; the loser must be rejected.
|
||||
it('two concurrent disable requests with the SAME captured code: only one succeeds', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code }),
|
||||
request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code }),
|
||||
]);
|
||||
|
||||
expect([r1.status, r2.status].sort()).toEqual([200, 400]);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA regenerate recovery codes — /api/admin/auth/mfa/recovery-codes', () => {
|
||||
it('a valid TOTP regenerates the recovery codes and persists the step', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/recovery-codes')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.recoveryCodes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('a wrong code is rejected (400)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
const valid = authenticator.generate(secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/recovery-codes')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: wrong });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
// Concurrency regression (see the disable test above for the mechanism):
|
||||
// this is the endpoint called out as the worst lost-update case, since it
|
||||
// both rotates the recovery codes and (previously) persisted the step in
|
||||
// one unconditional UPDATE.
|
||||
it('two concurrent regenerations with the SAME captured code: only one succeeds', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
request(adminApp)
|
||||
.post('/api/admin/auth/mfa/recovery-codes')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code }),
|
||||
request(adminApp)
|
||||
.post('/api/admin/auth/mfa/recovery-codes')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code }),
|
||||
]);
|
||||
|
||||
expect([r1.status, r2.status].sort()).toEqual([200, 400]);
|
||||
const winner = r1.status === 200 ? r1 : r2;
|
||||
expect(winner.body.recoveryCodes).toHaveLength(10);
|
||||
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_last_used_step).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService.persistTotpStep — atomic replay-tracking persist', () => {
|
||||
// Deterministic simulation of the race: two "concurrent" requests that
|
||||
// read the SAME two_factor_last_used_step and computed the SAME totpStep
|
||||
// from the same captured code. Calling persistTotpStep twice in a row with
|
||||
// that identical totpStep reproduces exactly the DB-level outcome of a
|
||||
// true race, without relying on event-loop timing.
|
||||
it('the second writer with the same totpStep affects 0 rows and is rejected', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
const code = authenticator.generate(secret);
|
||||
const totpStep = mfaService.verifyTotpEncryptedStep(code, row.two_factor_secret, null);
|
||||
expect(totpStep).toEqual(expect.any(Number));
|
||||
|
||||
const first = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
|
||||
expect(first).toBe(true);
|
||||
|
||||
// The row's two_factor_last_used_step has now already advanced to
|
||||
// totpStep by the time this "losing" write runs — the guard condition
|
||||
// (whereNull OR < totpStep) is false, so 0 rows are affected.
|
||||
const second = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
|
||||
expect(second).toBe(false);
|
||||
|
||||
const after = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(Number(after.two_factor_last_used_step)).toBe(totpStep);
|
||||
});
|
||||
|
||||
it('succeeds when the new step advances past the current one', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
const code = authenticator.generate(secret);
|
||||
const totpStep = mfaService.verifyTotpEncryptedStep(code, row.two_factor_secret, null);
|
||||
|
||||
const ok = await mfaService.persistTotpStep(db, admin.id, totpStep, {});
|
||||
expect(ok).toBe(true);
|
||||
|
||||
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
|
||||
const nextCode = nextStepAuthenticator.generate(secret);
|
||||
const nextStep = mfaService.verifyTotpEncryptedStep(nextCode, row.two_factor_secret, totpStep);
|
||||
expect(nextStep).toBeGreaterThan(totpStep);
|
||||
|
||||
const advanced = await mfaService.persistTotpStep(db, admin.id, nextStep, {});
|
||||
expect(advanced).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
@@ -284,6 +417,86 @@ describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
expect(res.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
// GHSA-qcwx-r25m-j869: verifyTotp() was stateless, so otplib's window:1
|
||||
// tolerance let the same 6-digit code complete two independent logins
|
||||
// within its ~90s validity window. mfaService now tracks each admin's
|
||||
// last-consumed TOTP step and rejects a code that doesn't advance past it.
|
||||
it('#GHSA-qcwx-r25m-j869 — a TOTP code cannot be replayed into a second login', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
// First use of the code completes a login.
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const first = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c1.body.mfaToken, code });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.user).toBeDefined();
|
||||
|
||||
// Replaying the SAME code for an independent second login must fail,
|
||||
// even though otplib's window:1 tolerance still considers it valid.
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const replay = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c2.body.mfaToken, code });
|
||||
expect(replay.status).toBe(401);
|
||||
expect(replay.body.code).toBe('MFA_INVALID');
|
||||
expect(replay.body.user).toBeUndefined();
|
||||
|
||||
// A freshly generated code for the NEXT TOTP step is not a replay and
|
||||
// succeeds. Generated via a cloned authenticator with a future epoch
|
||||
// rather than mocking Date.now(), so mfaService's own step computation
|
||||
// (real Date.now()) still lands the match one step ahead.
|
||||
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
|
||||
const nextCode = nextStepAuthenticator.generate(secret);
|
||||
const c3 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const third = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c3.body.mfaToken, code: nextCode });
|
||||
expect(third.status).toBe(200);
|
||||
expect(third.body.user).toBeDefined();
|
||||
expect(third.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
// Concurrency regression: verifyTotpEncryptedStep()'s "does this advance"
|
||||
// check was read against a snapshot taken earlier in the request, then a
|
||||
// PLAIN update persisted the step — two concurrent requests carrying the
|
||||
// SAME captured code could both pass the check and both complete a login
|
||||
// before either write landed. The persist is now a conditional UPDATE
|
||||
// (mfaService.persistTotpStep), so only the first writer's affected-row
|
||||
// count is > 0 and the other is correctly treated as a replay.
|
||||
it('two concurrent login/mfa requests with the SAME captured code: only one completes', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
request(authApp).post('/api/auth/admin/login/mfa').send({ mfaToken: c1.body.mfaToken, code }),
|
||||
request(authApp).post('/api/auth/admin/login/mfa').send({ mfaToken: c2.body.mfaToken, code }),
|
||||
]);
|
||||
|
||||
expect([r1.status, r2.status].sort()).toEqual([200, 401]);
|
||||
const winner = r1.status === 200 ? r1 : r2;
|
||||
const loser = r1.status === 200 ? r2 : r1;
|
||||
expect(winner.body.user).toBeDefined();
|
||||
expect(loser.body.user).toBeUndefined();
|
||||
expect(loser.body.code).toBe('MFA_INVALID');
|
||||
});
|
||||
|
||||
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* GHSA-9h7q-2jpf-vj85 — DELETE /api/admin/short-urls/:id only checked
|
||||
* `events.edit` permission, with no ownership scoping. GET and POST for an
|
||||
* event's short URLs both chain requireEventOwnership; DELETE takes the
|
||||
* short URL row's own :id (not :eventId), so any admin holding events.edit
|
||||
* could delete another admin's branded gallery short URL. The route now
|
||||
* resolves the short URL's event first and applies the same ownership
|
||||
* predicate requireEventOwnership uses. 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-suown-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'suown-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-suown-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('short URL delete ownership scoping', () => {
|
||||
let db; let cleanup; let app; let service;
|
||||
let superTok; let ownerTok; let foreignTok;
|
||||
let ownerId;
|
||||
let foreignShortUrlId;
|
||||
|
||||
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
|
||||
|
||||
async function seedEvent(createdBy, slugSuffix) {
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const [id] = await db('events').insert({
|
||||
slug: `suown-${slugSuffix}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Event',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@e.com',
|
||||
admin_email: 'a@e.com',
|
||||
password_hash: 'x',
|
||||
share_link: `suown-${slugSuffix}`,
|
||||
share_token: `suown-share-${slugSuffix}`,
|
||||
expires_at: farFuture,
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
created_by: createdBy,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
return db('events').where({ id }).first();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
service = require('../../src/services/galleryShortUrlService');
|
||||
|
||||
const superIns = await db('admin_users').insert({
|
||||
username: 'suown-super', email: 'suown-super@example.com',
|
||||
password_hash: 'x', must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const superId = superIns[0]?.id ?? superIns[0];
|
||||
await assignAdminRole(db, superId, 'super_admin');
|
||||
superTok = mintAdminToken(superId);
|
||||
|
||||
const ownerIns = await db('admin_users').insert({
|
||||
username: 'suown-owner', email: 'suown-owner@example.com',
|
||||
password_hash: 'x', must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
ownerId = ownerIns[0]?.id ?? ownerIns[0];
|
||||
await assignAdminRole(db, ownerId, 'editor');
|
||||
ownerTok = mintAdminToken(ownerId);
|
||||
|
||||
const foreignIns = await db('admin_users').insert({
|
||||
username: 'suown-foreign', email: 'suown-foreign@example.com',
|
||||
password_hash: 'x', must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const foreignId = foreignIns[0]?.id ?? foreignIns[0];
|
||||
await assignAdminRole(db, foreignId, 'editor');
|
||||
foreignTok = mintAdminToken(foreignId);
|
||||
|
||||
// Event owned by `owner`, NOT `foreign`.
|
||||
await seedEvent(ownerId, 'owned');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin', require('../../src/routes/adminShortUrls'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
// Fresh short URL per DELETE test so earlier deletes don't interfere.
|
||||
const event = await db('events').where({ created_by: ownerId }).first();
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: `suown-target-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
createdBy: ownerId,
|
||||
});
|
||||
foreignShortUrlId = row.id;
|
||||
});
|
||||
|
||||
it('an admin who does not own the event cannot delete its short URL (403, row survives)', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete(`/api/admin/short-urls/${foreignShortUrlId}`),
|
||||
foreignTok,
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const row = await db('gallery_short_urls').where({ id: foreignShortUrlId }).first();
|
||||
expect(row).toBeDefined();
|
||||
expect(row.deleted_at).toBeFalsy();
|
||||
});
|
||||
|
||||
it('the owning admin can delete its own short URL', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete(`/api/admin/short-urls/${foreignShortUrlId}`),
|
||||
ownerTok,
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
const row = await db('gallery_short_urls').where({ id: foreignShortUrlId }).first();
|
||||
expect(row.deleted_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('super_admin can delete any short URL', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete(`/api/admin/short-urls/${foreignShortUrlId}`),
|
||||
superTok,
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
const row = await db('gallery_short_urls').where({ id: foreignShortUrlId }).first();
|
||||
expect(row.deleted_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deleting a nonexistent short URL id returns 404', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete('/api/admin/short-urls/9999999'),
|
||||
superTok,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('deleting a nonexistent short URL id as a non-owner also returns 404 (existence check runs first)', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete('/api/admin/short-urls/9999999'),
|
||||
foreignTok,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ jest.mock('../../src/services/productUsageService', () =>
|
||||
'tick',
|
||||
'status',
|
||||
'dismiss',
|
||||
'markPromptShown',
|
||||
'enable',
|
||||
'disable',
|
||||
'abandon',
|
||||
@@ -63,6 +64,11 @@ beforeAll(async () => {
|
||||
t.integer('role_id');
|
||||
t.boolean('is_active');
|
||||
t.timestamp('password_changed_at');
|
||||
// adminAuth() now selects this on every request (GHSA-h4w8-57xq-53fx
|
||||
// must_change_password enforcement) — without the column the join
|
||||
// throws and every route in this file 401s before reaching the
|
||||
// permission check it's meant to test.
|
||||
t.boolean('must_change_password');
|
||||
});
|
||||
await mockDb.schema.createTable('permissions', (t) => {
|
||||
t.increments('id');
|
||||
@@ -129,6 +135,7 @@ const ROUTES = [
|
||||
['post', '/abandon'],
|
||||
['post', '/retry'],
|
||||
['post', '/dismiss'],
|
||||
['post', '/prompt-seen'],
|
||||
['get', '/preview'],
|
||||
['get', '/export'],
|
||||
['put', '/feedback-preferences'],
|
||||
@@ -178,6 +185,15 @@ test('owner sees no-store status and supplies consent to the service', async ()
|
||||
.expect(200);
|
||||
expect(service.enable).toHaveBeenCalledWith('usage-consent.v1');
|
||||
});
|
||||
test('only a settings editor can acknowledge the prompt without opting in', async () => {
|
||||
await request(app)
|
||||
.post('/api/admin/usage/prompt-seen')
|
||||
.set('Authorization', `Bearer ${token('admin')}`)
|
||||
.expect('Cache-Control', 'no-store')
|
||||
.expect(200);
|
||||
expect(service.markPromptShown).toHaveBeenCalledTimes(1);
|
||||
expect(service.enable).not.toHaveBeenCalled();
|
||||
});
|
||||
test('public/gallery paths and failed/unauthenticated admin operations never set feature markers', async () => {
|
||||
const { EventEmitter } = require('events');
|
||||
const simulate = (path, admin, statusCode) => {
|
||||
|
||||
@@ -1,460 +1,82 @@
|
||||
/**
|
||||
* Regression test for the /admin/login → /admin/dashboard → /admin/login
|
||||
* redirect loop reported on v3.32.4-beta.0.
|
||||
*
|
||||
* Cause: GET /auth/session was less strict than the adminAuth middleware.
|
||||
* The session endpoint accepted tokens that the protected endpoints
|
||||
* subsequently rejected with 401, which the frontend's interceptor
|
||||
* translated into a hard redirect to /admin/login. /auth/session then
|
||||
* said "valid: true" again on the next page load and the cycle closed.
|
||||
*
|
||||
* /auth/session must reject the same admin tokens adminAuth would
|
||||
* reject, specifically: deactivated admin user, deleted admin user,
|
||||
* password changed since iat. Same for gallery: archived event.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
/** Session restoration uses the same live policy as protected routes. */
|
||||
const request = require('supertest');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
process.env.JWT_SECRET = 'session-symmetry-test-secret';
|
||||
|
||||
const fakeDb = {
|
||||
adminUsers: [],
|
||||
events: [],
|
||||
revokedTokens: [],
|
||||
};
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const formatBoolean = (v) => (v ? 1 : 0);
|
||||
void formatBoolean;
|
||||
function dbFn(table) {
|
||||
if (table === 'admin_users') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
// The session route joins roles for the adminUser payload (#798);
|
||||
// fake rows carry no role fields, so the join is a pass-through.
|
||||
leftJoin() {
|
||||
return this;
|
||||
},
|
||||
where(criteria) {
|
||||
rowFilter = (row) => {
|
||||
return Object.entries(criteria).every(([rawKey, v]) => {
|
||||
// Joined queries prefix columns ('admin_users.id') — the fake
|
||||
// rows use bare names.
|
||||
const k = rawKey.replace(/^admin_users\./, '');
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
};
|
||||
return this;
|
||||
},
|
||||
select(...cols) {
|
||||
this._cols = cols;
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
const row = fakeDb.adminUsers.find(rowFilter);
|
||||
if (!row) return undefined;
|
||||
if (!this._cols) return row;
|
||||
const out = {};
|
||||
for (const c of this._cols) {
|
||||
// Support 'table.col' and 'table.col as alias' shapes.
|
||||
const [source, alias] = c.split(/\s+as\s+/i);
|
||||
const bare = source.includes('.') ? source.split('.').pop() : source;
|
||||
out[alias || bare] = row[bare];
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === 'events') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
rowFilter = (row) =>
|
||||
Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return fakeDb.events.find(rowFilter);
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table: ${table}`);
|
||||
}
|
||||
return { db: dbFn, formatBoolean: () => 1 };
|
||||
const crypto = require('crypto');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
process.env.JWT_SECRET = 'session-symmetry-test-secret-with-at-least-32-characters';
|
||||
let db, cleanup, app, adminId, customerId, eventId, cutoff;
|
||||
const slug = 'session-symmetry';
|
||||
const sign = (claims = {}) => jwt.sign({ type: 'admin', id: adminId, username: 'tester',
|
||||
iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims },
|
||||
process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '4h' });
|
||||
const gallery = (claims = {}) => sign({ type: 'gallery', eventId, eventSlug: slug, ...claims });
|
||||
const session = bearer => request(app).get(`/api/auth/session?slug=${slug}`).set('Authorization', `Bearer ${bearer}`);
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId);
|
||||
const row = await require('../../src/services/eventCreationService').createEvent({
|
||||
event_type: 'wedding', event_name: 'Session symmetry', event_date: '2026-10-01',
|
||||
slug, password: 'Session-Strong-Password-924!', expiration_days: 30,
|
||||
customer_email: 'customer@example.test', admin_email: 'admin@example.test',
|
||||
}, { actor: { id: adminId }, source: 'v1' });
|
||||
eventId = row.id;
|
||||
await db('events').where({ id: eventId }).update({ slug });
|
||||
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
|
||||
cutoff = require('../../src/utils/sessionCutoff');
|
||||
app = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||
}, 120000);
|
||||
beforeEach(async () => {
|
||||
await db('admin_users').where({ id: adminId }).update({ is_active: 1, password_changed_at: null });
|
||||
await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null });
|
||||
await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0,
|
||||
expires_at: new Date(Date.now() + 86400000).toISOString() });
|
||||
await cutoff.setSessionsValidAfter(0);
|
||||
});
|
||||
|
||||
jest.mock('../../src/utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({
|
||||
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
|
||||
revokeToken: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenUtils', () => ({
|
||||
getAdminTokenFromRequest: (req) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
|
||||
return null;
|
||||
},
|
||||
getGalleryTokenFromRequest: () => null,
|
||||
setAdminAuthCookie: jest.fn(),
|
||||
setGalleryAuthCookies: jest.fn(),
|
||||
clearAdminAuthCookie: jest.fn(),
|
||||
clearGalleryAuthCookies: jest.fn(),
|
||||
buildCookieOptionsWithExpiry: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) }));
|
||||
// Mock sessionTimeout's isSessionExpired so each test controls the return.
|
||||
// Default: not expired (so existing tests keep passing without setup).
|
||||
jest.mock('../../src/middleware/sessionTimeout', () => ({
|
||||
endSession: jest.fn(),
|
||||
isSessionExpired: jest.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/auth', authRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
|
||||
const issuedAt = iat ?? Math.floor(Date.now() / 1000);
|
||||
// Note: do NOT pass noTimestamp:true here — that strips iat from the
|
||||
// payload entirely, defeating the password-change comparison. Provide
|
||||
// iat (and exp) via the payload directly instead.
|
||||
return jwt.sign(
|
||||
{ id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 },
|
||||
process.env.JWT_SECRET,
|
||||
{ issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) {
|
||||
return jwt.sign(
|
||||
{ eventId, eventSlug, type: 'gallery', ...extra },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
describe('GET /auth/session — symmetry with protected middleware', () => {
|
||||
beforeEach(() => {
|
||||
fakeDb.adminUsers = [];
|
||||
fakeDb.events = [];
|
||||
fakeDb.revokedTokens = [];
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.type).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user has been deactivated', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: false,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user no longer exists', async () => {
|
||||
// adminUsers is empty
|
||||
const token = signAdminToken({ id: 999 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when password was changed after the token was issued', async () => {
|
||||
// iat must be in the past, exp must be in the future so jwt.verify
|
||||
// doesn't reject the token before /auth/session even gets to look
|
||||
// at password_changed_at.
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true when password was changed BEFORE the token was issued', async () => {
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60;
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is archived', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: true,
|
||||
expires_at: null,
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is expired', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() - 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true for an active gallery token', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 () => {
|
||||
// via:'customer' runs at accessLevel 'guest' but bypasses reveal mode,
|
||||
// so it is a credential that does not look like one.
|
||||
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,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
fakeDb.revokedTokens.push(1);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
// Session-timeout symmetry — issue #350 recurrence on v3.39.1-beta.0.
|
||||
// sessionTimeoutMiddleware (mounted on /api/admin) rejects idle/old-iat
|
||||
// tokens with 401 SESSION_TIMEOUT, but /auth/session previously didn't.
|
||||
// The new isSessionExpired helper closes that asymmetry.
|
||||
describe('session-timeout symmetry', () => {
|
||||
const { isSessionExpired } = require('../../src/middleware/sessionTimeout');
|
||||
|
||||
beforeEach(() => {
|
||||
isSessionExpired.mockReset();
|
||||
// Default to "active session" so the other admin checks above also
|
||||
// pass when this branch runs.
|
||||
isSessionExpired.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when isSessionExpired reports the token has timed out', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockResolvedValue(true);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.error).toBe('Session expired');
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token (helper says not expired)', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockResolvedValue(false);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(isSessionExpired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call isSessionExpired for gallery tokens', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(isSessionExpired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through (treats as valid) if the helper itself throws', async () => {
|
||||
// Defensive: the require() in auth.js is wrapped in try/catch so a
|
||||
// missing/broken helper doesn't fail-closed during early bootstrap.
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockRejectedValue(new Error('boom'));
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await require('../../src/services/serviceShutdown').stopServices();
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
it('hydrates an active admin and its role', async () => {
|
||||
const res = await session(sign());
|
||||
expect(res.body).toMatchObject({ valid: true, type: 'admin', adminUser: { id: adminId, role: { name: 'super_admin' } } });
|
||||
});
|
||||
it.each(['disabled', 'password', 'deleted', 'idle'])('rejects an admin after %s', async reason => {
|
||||
let bearer = sign();
|
||||
if (reason === 'disabled') await db('admin_users').where({ id: adminId }).update({ is_active: 0 });
|
||||
if (reason === 'password') await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date().toISOString() });
|
||||
if (reason === 'deleted') bearer = sign({ id: 999999 });
|
||||
if (reason === 'idle') bearer = sign({ iat: Math.floor(Date.now() / 1000) - 7200 });
|
||||
expect((await session(bearer)).body.valid).toBe(false);
|
||||
});
|
||||
it('accepts a session issued after a previous password change', async () => {
|
||||
await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date(Date.now() - 120000).toISOString() });
|
||||
expect((await session(sign())).body.valid).toBe(true);
|
||||
});
|
||||
it.each(['archived', 'expired', 'draft', 'inactive'])('rejects a gallery that is %s', async reason => {
|
||||
await db('events').where({ id: eventId }).update({
|
||||
...(reason === 'archived' && { is_archived: 1 }), ...(reason === 'draft' && { is_draft: 1 }),
|
||||
...(reason === 'inactive' && { is_active: 0 }), ...(reason === 'expired' && { expires_at: new Date(Date.now() - 1000).toISOString() }),
|
||||
});
|
||||
expect((await session(gallery())).body.valid).toBe(false);
|
||||
});
|
||||
it.each(['guest', 'client', 'customer'])('restores the %s gallery session kind', async kind => {
|
||||
const res = await session(gallery(kind === 'customer' ? { via: 'customer', customerId } : { accessLevel: kind }));
|
||||
expect(res.body).toMatchObject({ valid: true, accessLevel: kind === 'client' ? 'client' : 'guest', viaCustomer: kind === 'customer' });
|
||||
});
|
||||
it.each(['revoked', 'restore'])('invalidates both admin and gallery sessions after %s', async reason => {
|
||||
const tokens = [sign(), gallery()];
|
||||
if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||
else for (const bearer of tokens) await require('../../src/utils/tokenRevocation').revokeToken(bearer, 'test');
|
||||
for (const bearer of tokens) expect((await session(bearer)).body.valid).toBe(false);
|
||||
});
|
||||
it('refuses a deactivated customer gallery session', async () => {
|
||||
const bearer = gallery({ via: 'customer', customerId });
|
||||
expect((await session(bearer)).body.valid).toBe(true);
|
||||
await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 });
|
||||
expect((await session(bearer)).body.valid).toBe(false);
|
||||
});
|
||||
it('refuses an unrelated JWT type', async () => {
|
||||
const res = await session(sign({ type: 'password-reset' }));
|
||||
expect(res.status).toBe(403); expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* PUT /api/admin/database-backup/config must reject a
|
||||
* database_backup_destination_path that resolves inside a publicly served
|
||||
* directory (GHSA-jw8m-43r2-jqrm class, #1365).
|
||||
*
|
||||
* Before #1365, database_backup_destination_path was silently ignored by
|
||||
* databaseBackupService.backup() (a destructuring bug always fell back to
|
||||
* the hardcoded /backup/database), so this setting being freely writable by
|
||||
* any backup.create holder — the built-in `admin` role has it without
|
||||
* settings.edit or backup.restore — was harmless. Making the setting
|
||||
* actually take effect reopens the exact exfiltration path GHSA-jw8m fixed
|
||||
* for the per-request override, through the persisted setting instead.
|
||||
*/
|
||||
|
||||
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-config-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-config-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('database backup destination-path config guard (GHSA-jw8m class, #1365)', () => {
|
||||
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-config@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(); });
|
||||
|
||||
it('rejects a destination inside the public uploads/logos mount', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'uploads', 'logos') });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
// The seeded default must survive untouched — the rejected value never lands.
|
||||
const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first();
|
||||
expect(JSON.parse(row.setting_value)).toBe('/backup/database');
|
||||
});
|
||||
|
||||
it('rejects a destination inside the public fonts mount', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'fonts') });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts a destination outside any public mount', async () => {
|
||||
const safePath = path.join(process.env.STORAGE_PATH, 'db-backups');
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_destination_path: safePath });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first();
|
||||
expect(JSON.parse(row.setting_value)).toBe(safePath);
|
||||
});
|
||||
|
||||
// A retention of 0 or less pushes cleanupOldBackups' cutoff to today or
|
||||
// the future, deleting every completed backup on the next scheduled run
|
||||
// — a backup.create holder achieving what backup.delete gates on /cleanup.
|
||||
it.each([-1, 0])('rejects database_backup_retention_days=%s', async (bad) => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_retention_days: bad });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts a positive database_backup_retention_days', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_retention_days: 90 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db('app_settings').where({ setting_key: 'database_backup_retention_days' }).first();
|
||||
expect(JSON.parse(row.setting_value)).toBe(90);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/** Real routes + migrated SQLite: the same session policy protects lists and media. */
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
process.env.JWT_SECRET = 'gallery-policy-regression-secret-at-least-32-characters';
|
||||
|
||||
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
|
||||
secureImageAccess: (req, _res, next) => {
|
||||
req.clientInfo = { fingerprint: 'policy-test', ip: '127.0.0.1', userAgent: 'jest' };
|
||||
next();
|
||||
},
|
||||
getSecurityStatus: (_req, res) => res.json({}),
|
||||
}));
|
||||
|
||||
let db, cleanup, app, adminId, customerId, foreignId, event, secure, cutoff, revokeToken;
|
||||
const eventId = 70001, photoId = 70002, slug = 'policy-test';
|
||||
const token = (claims = {}) => jwt.sign({ type: 'gallery', eventId, eventSlug: slug,
|
||||
iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims },
|
||||
process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '1h' });
|
||||
const get = (url, bearer) => {
|
||||
const req = request(app).get(url);
|
||||
return bearer ? req.set('Authorization', `Bearer ${bearer}`) : req;
|
||||
};
|
||||
const endpoints = [`/api/gallery/${slug}/photos`, `/api/gallery/${slug}/photo/${photoId}`,
|
||||
`/api/gallery/${slug}/thumbnail/${photoId}`, `/api/gallery/${slug}/download/${photoId}`];
|
||||
const expectDirect = async (bearer, status, suffix = '') => {
|
||||
for (const url of endpoints) expect((await get(url + suffix, bearer)).status).toBe(status);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId);
|
||||
const [row] = await db('admin_users').insert({ username: 'foreign', email: 'foreign@example.test', password_hash: 'unused', is_active: 1 }).returning('id');
|
||||
foreignId = row.id ?? row;
|
||||
await assignAdminRole(db, foreignId, 'viewer');
|
||||
await db('events').insert({ id: eventId, slug, event_type: 'wedding', event_name: 'Policy test',
|
||||
event_date: '2026-01-01', host_email: 'h@example.test', admin_email: 'a@example.test', password_hash: 'unused',
|
||||
share_link: '/gallery/policy-test', created_by: adminId });
|
||||
const file = path.join(process.env.STORAGE_PATH, `events/active/${slug}/individual/fixture.jpg`);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await require('sharp')({ create: { width: 8, height: 8, channels: 3, background: '#228844' } }).jpeg().toFile(file);
|
||||
await db('photos').insert({ id: photoId, event_id: eventId, filename: 'fixture.jpg', path: `${slug}/individual/fixture.jpg`,
|
||||
type: 'individual', mime_type: 'image/jpeg', processing_status: 'complete', size_bytes: (await fs.stat(file)).size });
|
||||
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
|
||||
secure = require('../../src/services/secureImageService');
|
||||
jest.spyOn(secure, 'createClientFingerprint').mockReturnValue('policy-test');
|
||||
cutoff = require('../../src/utils/sessionCutoff');
|
||||
({ revokeToken } = require('../../src/utils/tokenRevocation'));
|
||||
app = express(); app.use(express.json()); app.use(cookieParser());
|
||||
app.use('/api', require('../../src/middleware/csrf'));
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
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);
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0, require_password: 1,
|
||||
expires_at: new Date(Date.now() + 86400000).toISOString(), reveal_mode: 0 });
|
||||
await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null });
|
||||
if (!await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).first()) {
|
||||
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
|
||||
}
|
||||
await cutoff.setSessionsValidAfter(0);
|
||||
event = await db('events').where({ id: eventId }).first();
|
||||
});
|
||||
afterAll(async () => { secure?.dispose(); if (cleanup) await cleanup(); });
|
||||
|
||||
it('serves a valid session as a real list and JPEG', async () => {
|
||||
const bearer = token();
|
||||
const list = await get(endpoints[0], bearer);
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.photos).toEqual(expect.arrayContaining([expect.objectContaining({ id: photoId })]));
|
||||
const image = await get(endpoints[1], bearer);
|
||||
expect(image.status).toBe(200); expect(image.headers['content-type']).toMatch(/image\/jpeg/);
|
||||
expect(image.body.length).toBeGreaterThan(100);
|
||||
});
|
||||
it('scopes draft previews to the owner and current read permissions', async () => {
|
||||
await db('events').where({ id: eventId }).update({ is_draft: 1 });
|
||||
await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1');
|
||||
await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1');
|
||||
// Ownership alone does not grant a user without a role read access.
|
||||
const role = (await db('admin_users').where({ id: adminId }).first()).role_id;
|
||||
await db('admin_users').where({ id: adminId }).update({ role_id: null });
|
||||
try { await expectDirect(mintAdminToken(adminId), 403, '?admin_preview=1'); }
|
||||
finally { await db('admin_users').where({ id: adminId }).update({ role_id: role }); }
|
||||
});
|
||||
it.each(['revocation', 'restore'])('rejects gallery sessions after %s', async (reason) => {
|
||||
const bearer = token();
|
||||
if (reason === 'revocation') expect(await revokeToken(bearer, 'test')).toBe(true);
|
||||
else await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||
await expectDirect(bearer, 401);
|
||||
});
|
||||
it.each(['deactivated', 'password changed'])('rejects an assigned customer when %s', async (reason) => {
|
||||
const bearer = token({ via: 'customer', customerId });
|
||||
await expectDirect(bearer, 200);
|
||||
await db('customer_accounts').where({ id: customerId }).update(reason === 'deactivated'
|
||||
? { is_active: 0 } : { password_changed_at: new Date().toISOString() });
|
||||
await expectDirect(bearer, 401);
|
||||
});
|
||||
it.each(['ISO', 'epoch'])('enforces expiry immediately for public and JWT access (%s)', async (format) => {
|
||||
const expiry = Date.now() - 1000;
|
||||
await db('events').where({ id: eventId }).update({ require_password: 0, expires_at: format === 'ISO' ? new Date(expiry).toISOString() : expiry });
|
||||
await expectDirect(undefined, 404); await expectDirect(token(), 404);
|
||||
await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1');
|
||||
});
|
||||
it.each(['revocation', 'restore', 'expiry', 'customer'])('rechecks signed and secure image grants after %s', async (reason) => {
|
||||
const bearer = token(reason === 'customer' ? { via: 'customer', customerId } : {});
|
||||
const signed = await request(app).post(`/api/images/${slug}/photo/${photoId}/generate-url`).set('Authorization', `Bearer ${bearer}`).send({});
|
||||
expect(signed.status).toBe(200);
|
||||
const minted = await request(app).post(`/api/secure-images/${slug}/generate-token`).set('Authorization', `Bearer ${bearer}`).send({ photoId });
|
||||
expect(minted.status).toBe(200);
|
||||
const secureUrl = `/api/secure-images/${slug}/secure/${photoId}/${minted.body.token}`;
|
||||
expect((await get(signed.body.url)).status).toBe(200);
|
||||
expect((await get(secureUrl)).status).toBe(200);
|
||||
if (reason === 'revocation') await revokeToken(bearer, 'test');
|
||||
if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||
if (reason === 'expiry') await db('events').where({ id: eventId }).update({ expires_at: new Date(Date.now() - 1000).toISOString() });
|
||||
if (reason === 'customer') await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 });
|
||||
const status = reason === 'expiry' ? 404 : 401;
|
||||
expect((await get(signed.body.url)).status).toBe(status);
|
||||
expect((await get(secureUrl)).status).toBe(status);
|
||||
});
|
||||
|
||||
it('blocks an empty cross-site cookie POST before the reveal state changes', async () => {
|
||||
await db('events').where({ id: eventId }).update({ reveal_mode: 1, revealed_at: null });
|
||||
const cookie = `admin_token=${mintAdminToken(adminId)}`;
|
||||
const url = `/api/admin/events/${eventId}/reveal`;
|
||||
const blocked = await request(app).post(url).set('Cookie', cookie).set('Origin', 'https://attacker.example')
|
||||
.set('Sec-Fetch-Site', 'cross-site').set('Content-Type', 'application/x-www-form-urlencoded').send('');
|
||||
expect(blocked.status).toBe(403);
|
||||
expect((await db('events').where({ id: eventId }).first()).revealed_at).toBeNull();
|
||||
process.env.ADMIN_URL = 'https://admin.example.test';
|
||||
try {
|
||||
const allowed = await request(app).post(url).set('Cookie', cookie).set('Origin', process.env.ADMIN_URL)
|
||||
.set('Sec-Fetch-Site', 'cross-site').send({});
|
||||
expect(allowed.status).toBe(200);
|
||||
expect((await db('events').where({ id: eventId }).first()).revealed_at).not.toBeNull();
|
||||
} finally { delete process.env.ADMIN_URL; }
|
||||
});
|
||||
|
||||
it('toggles status on a fully migrated fresh database and records updated_at', async () => {
|
||||
const response = await request(app).post(`/api/admin/events/${eventId}/toggle-status`)
|
||||
.set('Authorization', `Bearer ${mintAdminToken(adminId)}`).send({});
|
||||
expect(response.status).toBe(200);
|
||||
const row = await db('events').where({ id: eventId }).first();
|
||||
expect([false, 0]).toContain(row.is_active);
|
||||
expect(Number.isFinite(require('../../src/utils/dateNormalize').toTimestamp(row.updated_at))).toBe(true);
|
||||
});
|
||||
it('paginates after feedback filtering, with a total independent of page size', async () => {
|
||||
const ids = [70003, 70004, 70005];
|
||||
await db('photos').insert(ids.map(id => ({ id, event_id: eventId, filename: `${id}.jpg`, path: 'unused',
|
||||
type: 'individual', like_count: 1, processing_status: 'complete' })));
|
||||
await db('event_feedback_settings').insert({ event_id: eventId, feedback_enabled: 1, show_feedback_to_guests: 1 });
|
||||
try {
|
||||
const bearer = token();
|
||||
const first = await get(`${endpoints[0]}?filter=liked&limit=2&page=1&sort=filename&order=asc`, bearer);
|
||||
const second = await get(`${endpoints[0]}?filter=liked&limit=2&page=2&sort=filename&order=asc`, bearer);
|
||||
expect(first.status).toBe(200); expect(second.status).toBe(200);
|
||||
expect(first.body.pagination).toMatchObject({ total: 3, has_more: true });
|
||||
expect(second.body.pagination).toMatchObject({ total: 3, has_more: false });
|
||||
expect([...first.body.photos, ...second.body.photos].map(photo => photo.id)).toEqual(ids);
|
||||
} finally {
|
||||
await db('photos').whereIn('id', ids).del();
|
||||
await db('event_feedback_settings').where({ event_id: eventId }).del();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['assignment removed', 'anonymized'])('rejects an existing customer grant after %s', async reason => {
|
||||
const bearer = token({ via: 'customer', customerId });
|
||||
await expectDirect(bearer, 200);
|
||||
if (reason === 'assignment removed') await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).del();
|
||||
else await require('../../src/services/customerAccountsService').eraseCustomer(customerId, adminId);
|
||||
await expectDirect(bearer, reason === 'assignment removed' ? 403 : 401);
|
||||
});
|
||||
it('denies foreign editors and allows the editor who owns the gallery', async () => {
|
||||
await assignAdminRole(db, foreignId, 'editor');
|
||||
try {
|
||||
await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1');
|
||||
await db('events').where({ id: eventId }).update({ created_by: foreignId });
|
||||
await expectDirect(mintAdminToken(foreignId), 200, '?admin_preview=1');
|
||||
} finally {
|
||||
await db('events').where({ id: eventId }).update({ created_by: adminId });
|
||||
await assignAdminRole(db, foreignId, 'viewer');
|
||||
}
|
||||
});
|
||||
it('bounds a large gallery response while retaining the complete count', async () => {
|
||||
const rows = Array.from({ length: 5000 }, (_, index) => ({ id: 80000 + index, event_id: eventId,
|
||||
filename: `large-${index}.jpg`, path: 'unused', type: 'individual', processing_status: 'complete' }));
|
||||
try {
|
||||
await db.batchInsert('photos', rows, 100);
|
||||
const response = await get(`${endpoints[0]}?limit=999999&page=1`, token());
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.photos).toHaveLength(250);
|
||||
expect(response.body.pagination).toMatchObject({ total: 5001, limit: 250, has_more: true });
|
||||
} finally { await db('photos').where('id', '>=', 80000).where({ event_id: eventId }).del(); }
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Previewing an unpublished gallery through its SHORT share URL (#1386).
|
||||
*
|
||||
* /info has honoured admin_preview since #868, but two sibling routes never
|
||||
* did, and both sit on the short-URL path:
|
||||
*
|
||||
* GET /resolve/:identifier — filtered drafts out via ACTIVE_EVENT_FILTER
|
||||
* GET /:slug/verify-token/:token — same, inline
|
||||
*
|
||||
* With "use short gallery URLs" OFF the admin's View Gallery link carries the
|
||||
* slug, GalleryPage never calls /resolve, and the preview worked. With it ON
|
||||
* the link is the token form, GalleryPage resolves it first, and the draft
|
||||
* 404'd as "Gallery Not Found" — which is exactly what was reported.
|
||||
*
|
||||
* The relaxation is admin-preview-only, so the other half of these tests is
|
||||
* the part that must NOT move: anonymous callers still get 404 for a draft,
|
||||
* and GHSA-rh8r's rule (never hand a share_token back on a bare slug lookup)
|
||||
* has to survive the new path too.
|
||||
*/
|
||||
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-draft-preview-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'draft-preview-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-draft-preview-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
// Share-token fixtures, deliberately low-entropy and obviously fake. They
|
||||
// have to satisfy SHARE_TOKEN_REGEX (32 hex chars), and random-looking hex of
|
||||
// that shape is exactly what secret scanners flag — GitGuardian raised two
|
||||
// "Generic High Entropy Secret" findings on the first version of this file.
|
||||
const DRAFT_SLUG = 'draft-preview-event';
|
||||
const DRAFT_TOKEN = 'deadbeefdeadbeefdeadbeefdeadbeef';
|
||||
const LIVE_SLUG = 'published-event';
|
||||
const LIVE_TOKEN = 'feedfacefeedfacefeedfacefeedface';
|
||||
|
||||
describe('draft preview through the short share URL (#1386)', () => {
|
||||
let db; let cleanup; let app; let adminId; let foreignId;
|
||||
|
||||
const asAdmin = (req, id = adminId) => req.set('Authorization', `Bearer ${mintAdminToken(id)}`);
|
||||
|
||||
async function insertEvent({ slug, token, isDraft }) {
|
||||
await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-09-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${slug}/${token}`,
|
||||
share_token: token,
|
||||
require_password: 0,
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: isDraft ? 1 : 0,
|
||||
created_by: adminId,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId);
|
||||
const [row] = await db('admin_users').insert({
|
||||
username: 'foreign', email: 'foreign@example.test', password_hash: 'unused', is_active: 1,
|
||||
}).returning('id');
|
||||
foreignId = row?.id ?? row;
|
||||
await assignAdminRole(db, foreignId, 'viewer');
|
||||
|
||||
await insertEvent({ slug: DRAFT_SLUG, token: DRAFT_TOKEN, isDraft: true });
|
||||
await insertEvent({ slug: LIVE_SLUG, token: LIVE_TOKEN, isDraft: false });
|
||||
|
||||
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('the reported case — admin previewing a draft', () => {
|
||||
it('resolves the draft by share token (was 404 "Gallery Not Found")', async () => {
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.slug).toBe(DRAFT_SLUG);
|
||||
expect(res.body.matchType).toBe('token');
|
||||
});
|
||||
|
||||
it('resolves the draft by full share link', async () => {
|
||||
const identifier = encodeURIComponent(`/gallery/${DRAFT_SLUG}/${DRAFT_TOKEN}`);
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/resolve/${identifier}?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.slug).toBe(DRAFT_SLUG);
|
||||
});
|
||||
|
||||
it('clears verify-token for the draft, the next step of the same flow', async () => {
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what must not move', () => {
|
||||
it('404s an anonymous resolve of the draft token', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404s even with admin_preview=1 but no admin token', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404s for an admin who cannot access this event', async () => {
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
|
||||
foreignId,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404s an anonymous verify-token for the draft', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('still withholds the share_token on a bare slug lookup (GHSA-rh8r)', async () => {
|
||||
// The draft path must not become a way around the token-withholding rule.
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/resolve/${DRAFT_SLUG}?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.matchType).toBe('slug');
|
||||
expect(res.body.token).toBeUndefined();
|
||||
expect(res.body.share_link).toBeUndefined();
|
||||
expect(res.body.share_url).toBeUndefined();
|
||||
expect(JSON.stringify(res.body)).not.toContain(DRAFT_TOKEN);
|
||||
});
|
||||
|
||||
it('leaves the published gallery resolving anonymously, as before', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${LIVE_TOKEN}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.slug).toBe(LIVE_SLUG);
|
||||
expect(res.body.token).toBe(LIVE_TOKEN);
|
||||
});
|
||||
|
||||
it('still 404s an identifier that matches nothing', async () => {
|
||||
const res = await asAdmin(
|
||||
request(app).get('/api/gallery/resolve/no-such-gallery?admin_preview=1'),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Videos under enhanced/maximum image protection (#1370).
|
||||
*
|
||||
* Both halves of the video path used to be routed through /api/secure-images
|
||||
* once an event left `standard` protection, and neither half could carry a
|
||||
* video:
|
||||
*
|
||||
* 1. galleryQueryService emitted `/api/secure-images/{slug}/secure/{id}/{{token}}`
|
||||
* as the video's `url`. The lightbox drops that straight into a <video>
|
||||
* element, nothing substitutes `{{token}}` (the helper that could is
|
||||
* unreferenced), and the route answers 403 "Invalid or expired token".
|
||||
* 2. Even with a valid token it would still fail: the secure-images route
|
||||
* pipes every byte through secureImageService.processProtectedImage,
|
||||
* which calls sharp() and throws on an mp4 → 404.
|
||||
*
|
||||
* The guest saw a poster frozen at 0:00 with no error of any kind.
|
||||
*
|
||||
* Videos now keep the JWT route at every protection level. That is not a new
|
||||
* exposure — thumbnails of those same videos have always been served from it —
|
||||
* so these tests also pin the inverse: still images must keep bouncing to the
|
||||
* secure endpoint. Every assertion here fails on the unfixed code except the
|
||||
* two guarding images.
|
||||
*/
|
||||
|
||||
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-video-urls-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'video-urls-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-video-urls-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const SLUG = 'protected-video-gallery';
|
||||
const VIDEO_BYTES = Buffer.from('not really an mp4, but the route only streams bytes');
|
||||
|
||||
describe('videos stay playable under enhanced/maximum protection (#1370)', () => {
|
||||
let db; let cleanup; let app; let eventId; let videoId; let imageId;
|
||||
|
||||
async function setProtection(level) {
|
||||
await db('events').where('id', eventId).update({ protection_level: level });
|
||||
}
|
||||
|
||||
async function photoPayload(id) {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
|
||||
expect(res.status).toBe(200);
|
||||
const photo = res.body.photos.find((p) => p.id === id);
|
||||
expect(photo).toBeDefined();
|
||||
return photo;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const ev = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Protected Video',
|
||||
event_date: '2026-09-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/s`,
|
||||
share_token: 'protected-video-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, same as
|
||||
// the sibling gallery suites.
|
||||
require_password: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = ev[0]?.id ?? ev[0];
|
||||
|
||||
const mediaDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG, 'individual');
|
||||
fs.mkdirSync(mediaDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(mediaDir, 'clip.mp4'), VIDEO_BYTES);
|
||||
fs.writeFileSync(path.join(mediaDir, 'still.jpg'), Buffer.from('jpeg-ish'));
|
||||
|
||||
const vid = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'clip.mp4',
|
||||
path: `${SLUG}/individual/clip.mp4`,
|
||||
type: 'individual',
|
||||
media_type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
duration: 43,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
videoId = vid[0]?.id ?? vid[0];
|
||||
|
||||
const img = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'still.jpg',
|
||||
path: `${SLUG}/individual/still.jpg`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
imageId = img[0]?.id ?? img[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(); });
|
||||
|
||||
describe.each(['enhanced', 'maximum'])('protection_level = %s', (level) => {
|
||||
beforeAll(async () => { await setProtection(level); });
|
||||
|
||||
test('the video url is the JWT route, not a {{token}} template', async () => {
|
||||
const photo = await photoPayload(videoId);
|
||||
expect(photo.url).toBe(`/api/gallery/${SLUG}/photo/${videoId}`);
|
||||
expect(photo.url).not.toContain('{{token}}');
|
||||
expect(photo.requires_token).toBe(false);
|
||||
});
|
||||
|
||||
test('the video streams instead of bouncing to the secure endpoint', async () => {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photo/${videoId}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('video/mp4');
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
expect(Buffer.from(res.body)).toEqual(VIDEO_BYTES);
|
||||
});
|
||||
|
||||
test('range requests still work, so seeking is possible', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photo/${videoId}`)
|
||||
.set('Range', 'bytes=0-9');
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers['content-range']).toBe(`bytes 0-9/${VIDEO_BYTES.length}`);
|
||||
});
|
||||
|
||||
test('still images keep bouncing to the secure endpoint', async () => {
|
||||
const photo = await photoPayload(imageId);
|
||||
expect(photo.url).toBe(`/api/secure-images/${SLUG}/secure/${imageId}/{{token}}`);
|
||||
expect(photo.requires_token).toBe(true);
|
||||
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photo/${imageId}`);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.body.error).toBe('Secure access required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('protection_level = standard', () => {
|
||||
beforeAll(async () => { await setProtection('standard'); });
|
||||
|
||||
test('both media types take the JWT route, as before', async () => {
|
||||
expect((await photoPayload(videoId)).url).toBe(`/api/gallery/${SLUG}/photo/${videoId}`);
|
||||
expect((await photoPayload(imageId)).url).toBe(`/api/gallery/${SLUG}/photo/${imageId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Gallery tokens must carry a per-token `jti`. tokenRevocation falls back to
|
||||
* `${eventId}-${iat}-gallery` without one, so a guest logging out would revoke
|
||||
* every other guest whose token was minted for the same event in the same
|
||||
* second (QR-code share links at an event make that routine).
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const mintSites = [
|
||||
'src/routes/auth.js',
|
||||
'src/routes/customer.js',
|
||||
'src/routes/gallery/slideshow.js',
|
||||
];
|
||||
|
||||
describe('gallery token mint sites', () => {
|
||||
it.each(mintSites)('%s sets a unique jti on every gallery token', (file) => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../', file), 'utf8');
|
||||
const payloads = source.split('jwt.sign(').slice(1)
|
||||
.map((chunk) => chunk.split('process.env.JWT_SECRET')[0])
|
||||
.filter((payload) => payload.includes("type: 'gallery'"));
|
||||
expect(payloads.length).toBeGreaterThan(0);
|
||||
for (const payload of payloads) expect(payload).toContain('jti: crypto.randomUUID()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revocation key', () => {
|
||||
beforeAll(() => { process.env.JWT_SECRET = process.env.JWT_SECRET || 'jti-regression-secret-at-least-32-characters-long'; });
|
||||
it('is distinct for two same-second gallery logins of the same event', () => {
|
||||
const { buildTokenId } = require('../../src/utils/tokenRevocation');
|
||||
const crypto = require('crypto');
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const mint = () => jwt.decode(jwt.sign({ eventId: 7, type: 'gallery', iat, jti: crypto.randomUUID() }, process.env.JWT_SECRET));
|
||||
expect(buildTokenId(mint())).not.toBe(buildTokenId(mint()));
|
||||
// Without a jti the key collapses to eventId + login second.
|
||||
const bare = jwt.decode(jwt.sign({ eventId: 7, type: 'gallery', iat }, process.env.JWT_SECRET));
|
||||
expect(buildTokenId(bare)).toBe(buildTokenId({ ...bare }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
const request = require('supertest');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { randomUUID } = require('crypto');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
|
||||
let db, cleanup, adminId, customerId, eventId, apps, revocation;
|
||||
const slug = 'logout-revocation';
|
||||
const cases = [
|
||||
['auth', '/logout', 'admin', 'admin_token'],
|
||||
['auth', '/gallery/logout', 'gallery', `gallery_token_${slug}`],
|
||||
['customerAuth', '/logout', 'customer', 'customer_token'],
|
||||
['adminAuth', '/logout', 'admin', 'admin_token'],
|
||||
];
|
||||
const sign = type => jwt.sign({
|
||||
type, ...(type === 'admin' ? { id: adminId } : type === 'customer' ? { customerId } : { eventId, eventSlug: slug }),
|
||||
jti: randomUUID(),
|
||||
}, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId);
|
||||
const event = await require('../../src/services/eventCreationService').createEvent({
|
||||
event_type: 'wedding', event_name: 'Logout revocation', event_date: '2026-10-01',
|
||||
slug, password: 'Logout-Strong-Password-924!', expiration_days: 30,
|
||||
customer_email: 'customer@example.test', admin_email: 'admin@example.test',
|
||||
}, { actor: { id: adminId }, source: 'v1' });
|
||||
eventId = event.id;
|
||||
await db('events').where({ id: eventId }).update({ slug });
|
||||
revocation = require('../../src/utils/tokenRevocation');
|
||||
apps = Object.fromEntries(['auth', 'adminAuth', 'customerAuth'].map(name => [
|
||||
name, buildRouteApp('/', require(`../../src/routes/${name}`)),
|
||||
]));
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
afterAll(async () => {
|
||||
await require('../../src/services/serviceShutdown').stopServices();
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it.each(cases)('%s%s revokes a no-expiry %s cookie session', async (route, path, type, cookie) => {
|
||||
const token = sign(type);
|
||||
const sessionApp = type === 'customer' ? apps.customerAuth : apps.auth;
|
||||
const sessionPath = type === 'gallery' ? `/session?slug=${slug}` : '/session';
|
||||
const session = () => request(sessionApp).get(sessionPath).set('Cookie', `${cookie}=${token}`);
|
||||
const before = await session();
|
||||
expect(before.status).toBe(200);
|
||||
if (type !== 'customer') expect(before.body.valid).toBe(true);
|
||||
|
||||
const res = await request(apps[route]).post(path).set('Cookie', `${cookie}=${token}`).send({ slug });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['set-cookie'].some(value => value.startsWith(`${cookie}=;`))).toBe(true);
|
||||
await revocation.cleanupExpiredRevocations();
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(token))).toBe(true);
|
||||
const after = await session();
|
||||
if (type === 'customer') expect(after.status).toBe(401);
|
||||
else expect(after.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it.each(cases)('%s%s reports failed persistence for %s logout and clears its cookie', async (route, path, type, cookie) => {
|
||||
const token = sign(type);
|
||||
const realQuery = db.client.query;
|
||||
jest.spyOn(db.client, 'query').mockImplementation(function (connection, query) {
|
||||
if (/^insert into [`"]revoked_tokens[`"]/.test(query.sql)) {
|
||||
return Promise.reject(new Error('simulated revocation write failure'));
|
||||
}
|
||||
return realQuery.call(this, connection, query);
|
||||
});
|
||||
const res = await request(apps[route]).post(path).set('Cookie', `${cookie}=${token}`).send({ slug });
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBeTruthy();
|
||||
expect(res.body.message).not.toBe('Logged out successfully');
|
||||
expect(res.headers['set-cookie'].some(value => value.startsWith(`${cookie}=;`))).toBe(true);
|
||||
expect(await revocation.isTokenRevoked(jwt.decode(token))).toBe(false);
|
||||
});
|
||||
@@ -25,14 +25,18 @@ process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
const tokenGuards = require('../../src/utils/publicTokenGuards');
|
||||
const { errorHandler } = require('../../src/middleware/errorHandler');
|
||||
|
||||
describe('publicContracts routes', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let appWithErrorHandler;
|
||||
let customerId;
|
||||
let contractId;
|
||||
|
||||
@@ -51,6 +55,17 @@ describe('publicContracts routes', () => {
|
||||
contractId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
|
||||
|
||||
// A second app instance wired to the REAL production error handler
|
||||
// (buildRouteApp's is a simplified stand-in that only reads
|
||||
// err.statusCode/err.status, which a bare MulterError doesn't set).
|
||||
// Used below to verify the actual 4xx contract end-to-end, not just
|
||||
// that multer aborted the request.
|
||||
appWithErrorHandler = express();
|
||||
appWithErrorHandler.use(express.json());
|
||||
appWithErrorHandler.use(cookieParser());
|
||||
appWithErrorHandler.use('/api/public/contracts', require('../../src/routes/publicContracts'));
|
||||
appWithErrorHandler.use(errorHandler);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -131,6 +146,40 @@ describe('publicContracts routes', () => {
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// CVE-2026-82333 regression (#1374 follow-up): multer 2.3.0 added an
|
||||
// opt-in `fieldArrayIndexLimit` that must be set to actually close the
|
||||
// field-parser DoS — the version bump alone does nothing. This route is
|
||||
// unauthenticated (token-in-URL only), so it's the sharpest place to
|
||||
// prove a crafted request with an oversized array-index field name
|
||||
// (`evil[999999999]`) is rejected rather than accepted or left to hang.
|
||||
it('rejects a multipart request with an oversized array-index field name', async () => {
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId,
|
||||
});
|
||||
const res = await request(app)
|
||||
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
|
||||
.field('evil[999999999]', 'x')
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
// multer aborts the request before the handler runs; buildRouteApp's
|
||||
// generic error handler falls back to 500 for a bare MulterError
|
||||
// (see appWithErrorHandler test below for the real 4xx contract), so
|
||||
// here we only assert the upload was NOT accepted/processed.
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.body.error).not.toBe(undefined);
|
||||
});
|
||||
|
||||
it('maps the oversized array-index rejection to a 400 through the real error handler', async () => {
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId,
|
||||
});
|
||||
const res = await request(appWithErrorHandler)
|
||||
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
|
||||
.field('evil[999999999]', 'x')
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:token/pdf', () => {
|
||||
|
||||
@@ -22,29 +22,38 @@ process.env.JWT_SECRET = process.env.JWT_SECRET || 'restorepath-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
// `bootCrmDb()` hands back the process-wide `db` singleton (module cache —
|
||||
// see its own comment), so it must only be called ONCE per test file: a
|
||||
// second call re-runs migrations against the same connection, and the first
|
||||
// call's `cleanup()` (db.destroy()) would tear down the connection both
|
||||
// describe blocks below share. Boot once at file scope; each describe below
|
||||
// only touches app_settings / env vars, never the connection lifecycle.
|
||||
let db; let cleanup; let checkRestorePathsAllowed;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
async function setBackupSetting(key, value) {
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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(); });
|
||||
await setBackupSetting('backup_destination_path', '/backup');
|
||||
});
|
||||
|
||||
it('allows the wizard\'s source TYPE tokens', async () => {
|
||||
for (const source of ['local', 's3', 'upload']) {
|
||||
@@ -84,3 +93,89 @@ describe('restore path allowlist (GHSA-fw4c)', () => {
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GHSA-xfvx-j447-732c: `checkRestorePathsAllowed` constrained the top-level
|
||||
* `source`/`manifestPath` request fields (GHSA-fw4c above), but never looked
|
||||
* INSIDE the manifest itself. `manifest.database.backup_file` — handed
|
||||
* straight to restoreService's candidate resolution and eventually
|
||||
* interpolated into `sqlite3 .restore '<path>'` — was unchecked, so an
|
||||
* absolute path there could point the restore at an arbitrary file even
|
||||
* though `source`/`manifestPath` both passed containment.
|
||||
*/
|
||||
describe('restore path allowlist — manifest database.backup_file containment (GHSA-xfvx)', () => {
|
||||
let tmpRoot;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setBackupSetting('backup_destination_path', '/backup');
|
||||
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-xfvx-manifest-'));
|
||||
// Additional allowed root via the documented escape hatch — keeps this
|
||||
// describe block's fixtures out of the shared '/backup' root above.
|
||||
process.env.RESTORE_ALLOWED_ROOTS = tmpRoot;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.RESTORE_ALLOWED_ROOTS;
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const writeManifest = (name, databaseSection) => {
|
||||
const manifestPath = path.join(tmpRoot, name);
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
manifest: { version: '1.0', id: 'test' },
|
||||
backup: { type: 'full' },
|
||||
system: { platform: 'linux' },
|
||||
application: { version: '1.0.0' },
|
||||
files: { count: 0, manifest: [] },
|
||||
database: databaseSection,
|
||||
verification: { total_checksum: null, checksum_algorithm: null },
|
||||
}));
|
||||
return manifestPath;
|
||||
};
|
||||
|
||||
it('rejects a manifest whose database.backup_file is an absolute path outside every configured root', async () => {
|
||||
const manifestPath = writeManifest('evil-1.json', { backup_file: '/etc/passwd' });
|
||||
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
|
||||
expect(err).toMatch(/database\.backup_file must be inside a configured backup location/i);
|
||||
});
|
||||
|
||||
it('accepts a manifest whose database.backup_file is an absolute path inside a configured root', async () => {
|
||||
const dbFile = path.join(tmpRoot, 'database', 'picpeak-db-sqlite-1.sql.gz');
|
||||
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
|
||||
fs.writeFileSync(dbFile, 'not a real sqlite dump, just a fixture');
|
||||
const manifestPath = writeManifest('legit-1.json', { backup_file: dbFile });
|
||||
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it('does not choke on a manifest whose database.backup_file is a legitimate relative path', async () => {
|
||||
// Relative candidates are resolved against restoreService's own
|
||||
// `backupPath` (which this route-level pre-check doesn't have — it only
|
||||
// sees `source`/`manifestPath`), so this layer intentionally defers
|
||||
// relative-path containment to restoreService.performDatabaseRestore
|
||||
// and must not false-positive here.
|
||||
const manifestPath = writeManifest('legit-2.json', { backup_file: 'database/picpeak-db-sqlite-1.sql.gz' });
|
||||
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects everything when no backup location is configured at all (fail closed, not fail open)', async () => {
|
||||
// Simulate an install that never had backup_destination_path /
|
||||
// backup_manifest_path seeded/configured, and isn't using the
|
||||
// RESTORE_ALLOWED_ROOTS escape hatch either.
|
||||
const savedRoots = process.env.RESTORE_ALLOWED_ROOTS;
|
||||
delete process.env.RESTORE_ALLOWED_ROOTS;
|
||||
await db('app_settings').whereIn('setting_key', ['backup_destination_path', 'backup_manifest_path']).del();
|
||||
|
||||
try {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source: '/backup/run-1', manifestPath: '/backup/run-1/manifest.json',
|
||||
});
|
||||
expect(err).toMatch(/no backup location is configured/i);
|
||||
} finally {
|
||||
process.env.RESTORE_ALLOWED_ROOTS = savedRoots;
|
||||
await setBackupSetting('backup_destination_path', '/backup');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,8 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
|
||||
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
|
||||
photoId,
|
||||
`gallery_public_${eventId}_${Date.now()}`,
|
||||
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
|
||||
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600,
|
||||
galleryAccess: require('../../src/services/galleryAccessService').grant({ id: eventId }, 'public') },
|
||||
);
|
||||
|
||||
const view = (slug, photoId, token) => request(app)
|
||||
@@ -114,7 +115,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
|
||||
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);
|
||||
expect(res.body.code).toBe('INVALID_GALLERY_GRANT');
|
||||
});
|
||||
|
||||
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
|
||||
@@ -123,7 +124,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
|
||||
// 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);
|
||||
expect(res.body.code).toBe('INVALID_GALLERY_GRANT');
|
||||
});
|
||||
|
||||
it('lets a token read its own gallery + photo (binding passes)', async () => {
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* A rejected chunk must not cost its own size in memory (#1403).
|
||||
*
|
||||
* The route used to drain the whole request into an array and `Buffer.concat`
|
||||
* it before calling uploadChunk, which is where every check lives — the
|
||||
* per-file cap, the chunk index, and even "does this upload id exist". So a
|
||||
* 300MB body against an unknown upload id was read in full, added ~300MB to
|
||||
* RSS, and was then answered with an error. The size cap was real but only
|
||||
* applied after the damage.
|
||||
*
|
||||
* The contract these tests pin: uploadChunk consumes NOTHING until every check
|
||||
* has passed, and once it does start reading it stops at the remaining
|
||||
* allowance rather than trusting the sender.
|
||||
*/
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs').promises;
|
||||
const { Readable } = require('stream');
|
||||
|
||||
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-stream-test-${process.pid}`);
|
||||
|
||||
const chunkedUpload = require('../../src/services/chunkedUploadService');
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
const init = (overrides = {}) => chunkedUpload.initializeUpload({
|
||||
filename: 'clip.mp4',
|
||||
fileSize: 1,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 2,
|
||||
maxFileSizeBytes: 1 * MB,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A readable that reports how much of it was actually pulled. Bytes are
|
||||
* generated lazily, so "never read" really means the body never materialized.
|
||||
*/
|
||||
function countingSource(totalBytes, sliceSize = 64 * 1024) {
|
||||
let remaining = totalBytes;
|
||||
const source = new Readable({
|
||||
read() {
|
||||
if (remaining <= 0) return this.push(null);
|
||||
const n = Math.min(sliceSize, remaining);
|
||||
remaining -= n;
|
||||
source.bytesRead += n;
|
||||
this.push(Buffer.alloc(n));
|
||||
},
|
||||
});
|
||||
source.bytesRead = 0;
|
||||
return source;
|
||||
}
|
||||
|
||||
describe('chunked upload streams the body under a cap (#1403)', () => {
|
||||
afterAll(async () => {
|
||||
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('refused before the body is read', () => {
|
||||
it('reads nothing for an unknown upload id', async () => {
|
||||
const source = countingSource(8 * MB);
|
||||
await expect(chunkedUpload.uploadChunk('does-not-exist', 0, source, { declaredBytes: 8 * MB }))
|
||||
.rejects.toThrow('Upload not found or expired');
|
||||
expect(source.bytesRead).toBe(0);
|
||||
});
|
||||
|
||||
it('reads nothing for an out-of-range chunk index', async () => {
|
||||
const { uploadId } = await init();
|
||||
const source = countingSource(8 * MB);
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 99, source, { declaredBytes: 8 * MB }))
|
||||
.rejects.toMatchObject({ code: 'INVALID_CHUNK', statusCode: 400 });
|
||||
expect(source.bytesRead).toBe(0);
|
||||
});
|
||||
|
||||
it('reads nothing when Content-Length already exceeds the cap', async () => {
|
||||
const { uploadId } = await init();
|
||||
const source = countingSource(8 * MB);
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, source, { declaredBytes: 8 * MB }))
|
||||
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
|
||||
expect(source.bytesRead).toBe(0);
|
||||
expect(chunkedUpload.getUploadStatus(uploadId)).toBeNull();
|
||||
});
|
||||
|
||||
it('counts what earlier chunks already banked when checking Content-Length', async () => {
|
||||
const { uploadId } = await init();
|
||||
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.75 * MB));
|
||||
const source = countingSource(0.5 * MB);
|
||||
// 0.75MB banked + 0.5MB declared > the 1MB cap.
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 1, source, { declaredBytes: 0.5 * MB }))
|
||||
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
|
||||
expect(source.bytesRead).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a sender that lies, or says nothing', () => {
|
||||
it('stops at the allowance instead of reading the whole body', async () => {
|
||||
const { uploadId } = await init();
|
||||
// No declaredBytes at all — the Transfer-Encoding: chunked case.
|
||||
const source = countingSource(8 * MB);
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, source))
|
||||
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
|
||||
// The overshoot is whatever the readable had already buffered ahead when
|
||||
// the cap tripped — a small constant tied to highWaterMark, NOT a
|
||||
// function of the body size. That is the whole claim: 8MB offered, ~1MB
|
||||
// read. The slack is deliberately loose so this doesn't turn into a
|
||||
// Node-version canary.
|
||||
expect(source.bytesRead).toBeLessThan(2 * MB);
|
||||
});
|
||||
|
||||
it('leaves no partial chunk file behind when it cuts a body off', async () => {
|
||||
const { uploadId } = await init();
|
||||
const meta = chunkedUpload.getUploadStatus(uploadId);
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, countingSource(8 * MB)))
|
||||
.rejects.toMatchObject({ statusCode: 413 });
|
||||
// abortUpload removes the whole directory; assert nothing survived it.
|
||||
await expect(fs.readdir(path.join(process.env.STORAGE_PATH, 'chunks', uploadId)))
|
||||
.rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(meta).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Every case here was found by an external review of the first cut of this
|
||||
// fix. All three are regressions the buffered version did not have: the
|
||||
// async iterator it replaced rejected a dead request on its own, and never
|
||||
// opened the chunk file at all until it had the whole body in hand.
|
||||
describe('failure paths the streaming rewrite introduced', () => {
|
||||
it('rejects an already-destroyed request instead of hanging forever', async () => {
|
||||
const { uploadId } = await init();
|
||||
const source = countingSource(1024);
|
||||
source.destroy();
|
||||
// pipe() on a dead stream emits neither `end` nor `error`, so without an
|
||||
// explicit check this promise never settles and the write fd leaks.
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, source))
|
||||
.rejects.toMatchObject({ code: 'CHUNK_PREMATURE_CLOSE', statusCode: 400 });
|
||||
});
|
||||
|
||||
it('leaves a previously banked chunk intact when a re-send fails', async () => {
|
||||
const { uploadId } = await init();
|
||||
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(1000));
|
||||
const chunkPath = path.join(process.env.STORAGE_PATH, 'chunks', uploadId, 'chunk_000000');
|
||||
expect((await fs.stat(chunkPath)).size).toBe(1000);
|
||||
|
||||
// Re-send the same index, then fail it mid-flight.
|
||||
const source = new Readable({ read() {} });
|
||||
const pending = chunkedUpload.uploadChunk(uploadId, 0, source);
|
||||
source.push(Buffer.alloc(10));
|
||||
source.destroy(new Error('client went away'));
|
||||
await expect(pending).rejects.toThrow();
|
||||
|
||||
// The banked copy must still be there: receivedChunks/chunkSizes still
|
||||
// count it, so a truncated file here means status reports 100% and
|
||||
// completeUpload dies on ENOENT.
|
||||
expect((await fs.stat(chunkPath)).size).toBe(1000);
|
||||
// Still counted as received — which is exactly why the file has to still
|
||||
// be there and be the full 1000 bytes.
|
||||
expect(chunkedUpload.getUploadStatus(uploadId).receivedChunks).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps two in-flight sends of the same chunk off each other\'s staging file', async () => {
|
||||
const { uploadId } = await init();
|
||||
const chunkPath = path.join(process.env.STORAGE_PATH, 'chunks', uploadId, 'chunk_000000');
|
||||
|
||||
// Two requests for the same index, overlapping. A shared .part path let
|
||||
// whichever renamed first publish bytes the other had already truncated.
|
||||
const slow = new Readable({ read() {} });
|
||||
const doomed = new Readable({ read() {} });
|
||||
const slowDone = chunkedUpload.uploadChunk(uploadId, 0, slow);
|
||||
const doomedDone = chunkedUpload.uploadChunk(uploadId, 0, doomed);
|
||||
|
||||
doomed.push(Buffer.alloc(2));
|
||||
doomed.destroy(new Error('retry gave up'));
|
||||
await expect(doomedDone).rejects.toThrow();
|
||||
|
||||
slow.push(Buffer.alloc(10));
|
||||
slow.push(null);
|
||||
await expect(slowDone).resolves.toBeTruthy();
|
||||
|
||||
// The surviving attempt's 10 bytes, not the failed one's 2.
|
||||
expect((await fs.stat(chunkPath)).size).toBe(10);
|
||||
});
|
||||
|
||||
it('leaves no staging files behind after a failure', async () => {
|
||||
const { uploadId } = await init();
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, countingSource(8 * MB)))
|
||||
.rejects.toMatchObject({ statusCode: 413 });
|
||||
// The cap path aborts the whole upload, so the directory is gone; what
|
||||
// must not happen is a .part file reappearing after cleanup because the
|
||||
// write stream's open() was still pending when the unlink ran.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await expect(fs.readdir(path.join(process.env.STORAGE_PATH, 'chunks', uploadId)))
|
||||
.rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('counts chunks that landed while another was still streaming', async () => {
|
||||
const { uploadId } = await init({ totalChunks: 3 });
|
||||
|
||||
// Start a slow 0.75MB chunk. Its allowance is computed now, when nothing
|
||||
// else is banked.
|
||||
const slow = new Readable({ read() {} });
|
||||
const slowDone = chunkedUpload.uploadChunk(uploadId, 0, slow);
|
||||
|
||||
// A second 0.75MB chunk completes in the meantime.
|
||||
await chunkedUpload.uploadChunk(uploadId, 1, Buffer.alloc(0.75 * MB));
|
||||
|
||||
// Finishing the first must not publish: 1.5MB against a 1MB cap.
|
||||
slow.push(Buffer.alloc(0.75 * MB));
|
||||
slow.push(null);
|
||||
await expect(slowDone).rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
|
||||
expect(chunkedUpload.getUploadStatus(uploadId)).toBeNull();
|
||||
});
|
||||
|
||||
it('removes the staging file when publishing it fails', async () => {
|
||||
const { uploadId } = await init();
|
||||
const dir = path.join(process.env.STORAGE_PATH, 'chunks', uploadId);
|
||||
// Make the rename fail by putting a directory where the chunk goes.
|
||||
await fs.mkdir(path.join(dir, 'chunk_000000'), { recursive: true });
|
||||
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, countingSource(1024)))
|
||||
.rejects.toThrow();
|
||||
|
||||
// The fully written .part must not survive a failed publish — its name is
|
||||
// per-attempt, so retries would otherwise pile them up until expiry.
|
||||
const leftovers = (await fs.readdir(dir)).filter((f) => f.endsWith('.part'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not destroy the request stream when it trips the cap', async () => {
|
||||
const { uploadId } = await init();
|
||||
const source = countingSource(8 * MB);
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, source))
|
||||
.rejects.toMatchObject({ statusCode: 413 });
|
||||
// `source` stands in for the IncomingMessage. Destroying it would take
|
||||
// the socket down before the route could send its 413 JSON, so the client
|
||||
// would see a connection reset instead of the error.
|
||||
expect(source.destroyed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// These were plain Errors, so the routes answered 500 for what are plainly
|
||||
// client mistakes — a backend fault in monitoring, and an invitation to
|
||||
// retry something that can never succeed.
|
||||
describe('client-caused states carry their own status code', () => {
|
||||
it('404s an unknown upload id rather than 500', async () => {
|
||||
await expect(chunkedUpload.uploadChunk('does-not-exist', 0, Buffer.alloc(10)))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
// 409 (wrong status) and 410 (expired) share uploadStateError with the two
|
||||
// covered here. Reaching them from the public surface needs either a clock
|
||||
// or a setter the service does not expose, and a test that pretends to
|
||||
// exercise them while actually hitting the 404 path is worse than none.
|
||||
|
||||
it('404s completing an unknown upload rather than 500', async () => {
|
||||
await expect(chunkedUpload.completeUpload('does-not-exist'))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('400s completing an upload that is missing chunks', async () => {
|
||||
const { uploadId } = await init();
|
||||
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(10));
|
||||
await expect(chunkedUpload.completeUpload(uploadId))
|
||||
.rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the happy path still works', () => {
|
||||
it('writes a streamed chunk and reports progress', async () => {
|
||||
const { uploadId } = await init();
|
||||
const result = await chunkedUpload.uploadChunk(uploadId, 0, countingSource(0.25 * MB), {
|
||||
declaredBytes: 0.25 * MB,
|
||||
});
|
||||
expect(result).toMatchObject({ chunkIndex: 0, received: 1, expected: 2, complete: false });
|
||||
|
||||
const chunkPath = path.join(process.env.STORAGE_PATH, 'chunks', uploadId, 'chunk_000000');
|
||||
expect((await fs.stat(chunkPath)).size).toBe(0.25 * MB);
|
||||
});
|
||||
|
||||
it('still accepts a Buffer, the shape the service was written for', async () => {
|
||||
const { uploadId } = await init();
|
||||
const result = await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.25 * MB));
|
||||
expect(result).toMatchObject({ chunkIndex: 0, received: 1 });
|
||||
});
|
||||
|
||||
it('lets a re-sent chunk replace itself without double-counting', async () => {
|
||||
const { uploadId } = await init();
|
||||
await chunkedUpload.uploadChunk(uploadId, 0, countingSource(0.6 * MB), { declaredBytes: 0.6 * MB });
|
||||
// Same index again: the first copy's 0.6MB must not count toward the cap.
|
||||
await expect(
|
||||
chunkedUpload.uploadChunk(uploadId, 0, countingSource(0.6 * MB), { declaredBytes: 0.6 * MB }),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Background zip rebuilds are capped (#1399).
|
||||
*
|
||||
* invalidateAll() invalidates every event holding a cached zip, and each
|
||||
* invalidate() arms its own debounce timer in the same tick — so they all fire
|
||||
* together. Every build opens its own storage reads, so a settings change
|
||||
* across 25 events was enough to exhaust the S3 agent pool and stall uploads,
|
||||
* thumbnails and gallery reads until the burst drained.
|
||||
*
|
||||
* The cap is on the BACKGROUND path only: a guest waiting on a download must
|
||||
* not be queued behind a settings-change burst.
|
||||
*/
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const { db } = require('../../src/database/db');
|
||||
const service = require('../../src/services/downloadZipService');
|
||||
|
||||
const flush = () => new Promise((r) => setImmediate(r));
|
||||
|
||||
describe('downloadZipService background regen concurrency (#1399)', () => {
|
||||
let peak;
|
||||
let inFlight;
|
||||
let release;
|
||||
|
||||
beforeEach(() => {
|
||||
// setImmediate must stay real: the flush() helper below rides on it, and
|
||||
// jest's modern fake timers mock it too.
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate'] });
|
||||
peak = 0;
|
||||
inFlight = 0;
|
||||
release = [];
|
||||
service.stopped = false;
|
||||
service.regenActive = 0;
|
||||
service.regenWaiters = [];
|
||||
service.debounceTimers.clear();
|
||||
service.activeBuilds.clear();
|
||||
|
||||
jest.spyOn(service, 'generateZip').mockImplementation(() => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
return new Promise((resolve) => {
|
||||
release.push(() => { inFlight -= 1; resolve(); });
|
||||
});
|
||||
});
|
||||
jest.spyOn(service, '_cleanup').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('never runs more than two rebuilds at once, however many fire together', async () => {
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
// Every debounce timer was armed in the same tick — fire them all.
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
|
||||
expect(peak).toBe(2);
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('starts the next rebuild as each one finishes', async () => {
|
||||
const rows = Array.from({ length: 5 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(2);
|
||||
|
||||
release.shift()();
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(3);
|
||||
expect(peak).toBe(2);
|
||||
|
||||
while (release.length) { release.shift()(); await flush(); }
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(5);
|
||||
expect(peak).toBe(2);
|
||||
});
|
||||
|
||||
it('does not queue a foreground download behind the burst', async () => {
|
||||
const rows = Array.from({ length: 6 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(2);
|
||||
|
||||
// A guest asking for a zip right now calls generateZip directly. It must
|
||||
// not park behind the two rebuilds already holding the slots.
|
||||
service.generateZip(999);
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledWith(999);
|
||||
expect(inFlight).toBe(3);
|
||||
});
|
||||
|
||||
it('releases anything parked for a slot on shutdown', async () => {
|
||||
const rows = Array.from({ length: 6 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
expect(service.regenWaiters.length).toBeGreaterThan(0);
|
||||
|
||||
// stop() must not hang on a queue that will never drain.
|
||||
const stopping = service.stop();
|
||||
release.forEach((fn) => fn());
|
||||
await expect(stopping).resolves.toBeUndefined();
|
||||
expect(service.regenWaiters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* A failed pre-zip build must not leave storage reads open.
|
||||
*
|
||||
* The builder opened one storage read per photo and handed the raw stream to
|
||||
* archiver. archiver drains its queue one entry at a time, so on an S3 backend
|
||||
* every photo beyond the one being written parked a socket with a full receive
|
||||
* buffer, and the error path (a source stream dying, or a photo upload
|
||||
* invalidating the build) walked away from all of them. archiver's abort()
|
||||
* does not touch the source streams, and the AWS SDK arms its socket timeout
|
||||
* on a 3s delay then clears it once the response headers arrive, so nothing
|
||||
* ever reclaimed those sockets. On a live server 43 of the 50 pooled sockets
|
||||
* ended up stuck for days and photo uploads stopped completing.
|
||||
*/
|
||||
|
||||
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-zipleak-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'zipleak-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-zipleak-storage-'));
|
||||
|
||||
const { Readable } = require('stream');
|
||||
|
||||
const PHOTO_COUNT = 6;
|
||||
const MAX_INFLIGHT_READS = 2;
|
||||
|
||||
// One storage read. It never ends on its own, which is what a large photo
|
||||
// looks like to the builder: the bytes only move while archiver pulls them.
|
||||
class StoredObject extends Readable {
|
||||
constructor(key, failAfterReads, chunks) {
|
||||
super();
|
||||
this.key = key;
|
||||
this.failAfterReads = failAfterReads;
|
||||
this.chunks = chunks;
|
||||
this.reads = 0;
|
||||
}
|
||||
|
||||
_read() {
|
||||
this.reads += 1;
|
||||
if (this.failAfterReads && this.reads > this.failAfterReads) {
|
||||
// What a dropped connection to S3 looks like in Node.
|
||||
this.destroy(new Error('aborted'));
|
||||
return;
|
||||
}
|
||||
this.push(this.reads > this.chunks ? null : Buffer.alloc(4096, 1));
|
||||
}
|
||||
}
|
||||
|
||||
const reads = { opened: [], live: 0, peak: 0 };
|
||||
const failingKey = { value: null };
|
||||
const onOpen = { fn: null };
|
||||
// A read only finishes when the build pulls the whole object. Photos big
|
||||
// enough to matter never finish inside one archiver turn, and a stream that
|
||||
// ends on its own would be auto-destroyed and hide the leak.
|
||||
const objectChunks = { value: Number.POSITIVE_INFINITY };
|
||||
|
||||
function openStoredObject(key) {
|
||||
const stream = new StoredObject(key, key === failingKey.value ? 1 : 0, objectChunks.value);
|
||||
reads.opened.push(stream);
|
||||
reads.live += 1;
|
||||
if (reads.live > reads.peak) reads.peak = reads.live;
|
||||
let settled = false;
|
||||
const settle = () => { if (!settled) { settled = true; reads.live -= 1; } };
|
||||
stream.once('end', settle);
|
||||
stream.once('close', settle);
|
||||
if (onOpen.fn) onOpen.fn(reads.opened.length);
|
||||
return stream;
|
||||
}
|
||||
|
||||
const mockStorage = {
|
||||
kind: () => 's3',
|
||||
get: jest.fn(async (key) => openStoredObject(key)),
|
||||
getToFile: jest.fn(async () => undefined),
|
||||
putFromFile: jest.fn(async () => undefined),
|
||||
stat: jest.fn(async () => ({ size: 1234, mtime: new Date() })),
|
||||
delete: jest.fn(async () => undefined),
|
||||
exists: jest.fn(async () => true),
|
||||
};
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({
|
||||
getStorage: () => mockStorage,
|
||||
initStorage: async () => mockStorage,
|
||||
}));
|
||||
|
||||
// Nothing to resize or watermark, so the builder takes the stream-from-storage
|
||||
// branch, which is the one that holds sockets.
|
||||
jest.mock('../../src/services/downloadRendition', () => ({
|
||||
renderPhotoForDownload: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const downloadZipService = require('../../src/services/downloadZipService');
|
||||
|
||||
describe('pre-zip build releases its storage reads', () => {
|
||||
let db; let cleanup; let eventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const ev = await db('events').insert({
|
||||
slug: 'zipleak',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Zip Leak',
|
||||
event_date: '2026-09-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/zipleak/s',
|
||||
share_token: 'zipleak-share',
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
require_password: 0,
|
||||
allow_downloads: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = ev[0]?.id ?? ev[0];
|
||||
|
||||
for (let i = 0; i < PHOTO_COUNT; i += 1) {
|
||||
await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `photo-${i}.jpg`,
|
||||
path: `zipleak/photo-${i}.jpg`,
|
||||
type: 'individual',
|
||||
source_origin: 'managed',
|
||||
mime_type: 'image/jpeg',
|
||||
visibility: 'visible',
|
||||
uploaded_at: new Date(Date.now() - i * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(() => {
|
||||
reads.opened = [];
|
||||
reads.live = 0;
|
||||
reads.peak = 0;
|
||||
failingKey.value = null;
|
||||
onOpen.fn = null;
|
||||
objectChunks.value = Number.POSITIVE_INFINITY;
|
||||
mockStorage.get.mockClear();
|
||||
downloadZipService.versions.clear();
|
||||
downloadZipService.activeBuilds.clear();
|
||||
});
|
||||
|
||||
it('destroys every open read when a source stream dies mid-build', async () => {
|
||||
// The oldest photo is written first, so failing it strands the rest.
|
||||
failingKey.value = 'events/active/zipleak/photo-0.jpg';
|
||||
|
||||
const result = await downloadZipService.generateZip(eventId);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(reads.opened.length).toBeGreaterThan(1);
|
||||
const stranded = reads.opened.filter((s) => !s.destroyed);
|
||||
expect(stranded.map((s) => s.key)).toEqual([]);
|
||||
});
|
||||
|
||||
it('destroys every open read when an upload invalidates the build', async () => {
|
||||
// What adminPhotos does on every upload, delete and bulk edit, landing
|
||||
// while the archive is half built.
|
||||
onOpen.fn = (count) => {
|
||||
if (count !== 2) return;
|
||||
downloadZipService.invalidate(eventId);
|
||||
// invalidate() also schedules a rebuild; this test is not about that.
|
||||
clearTimeout(downloadZipService.debounceTimers.get(eventId));
|
||||
downloadZipService.debounceTimers.delete(eventId);
|
||||
};
|
||||
|
||||
const result = await downloadZipService.generateZip(eventId);
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Build invalidated' });
|
||||
expect(reads.opened.filter((s) => !s.destroyed).map((s) => s.key)).toEqual([]);
|
||||
});
|
||||
|
||||
it('never holds more storage reads open than the build needs', async () => {
|
||||
objectChunks.value = 8;
|
||||
|
||||
const result = await downloadZipService.generateZip(eventId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockStorage.get).toHaveBeenCalledTimes(PHOTO_COUNT);
|
||||
expect(reads.peak).toBeLessThanOrEqual(MAX_INFLIGHT_READS);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
jest.mock('axios', () => ({ post: jest.fn() }));
|
||||
jest.mock('../../src/utils/networkValidation', () => ({
|
||||
validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok' })),
|
||||
validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] })),
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
@@ -48,7 +48,7 @@ beforeEach(() => {
|
||||
process.env.EMAIL_WEBHOOK_SECRET = SECRET;
|
||||
transport.__testing.setAllowPrivateUrls(false);
|
||||
transport.__testing.resetSecretWarning();
|
||||
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok' });
|
||||
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] });
|
||||
axios.post.mockResolvedValue({ status: 200, data: streamOf('') });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Lazy rendition generation is single-flight per photo and rendition (#1020).
|
||||
*
|
||||
* ensureThumbnail / ensureHeroImage / ensurePreviewImage and the tier
|
||||
* variants are check-then-generate, and the check reads the path off the row
|
||||
* the caller already fetched. N concurrent cold requests for one photo all
|
||||
* missed and all ran the same Sharp pass; worse, the hero and preview
|
||||
* generators deleted the existing object before writing its replacement, so
|
||||
* a reader landing between B's delete and B's write was redirected to the
|
||||
* full original, and a regeneration whose source could not be read left the
|
||||
* old rendition gone with the row still pointing at it.
|
||||
*
|
||||
* Driven against real Sharp output and the real LocalFsStorage, plus an
|
||||
* in-memory backend with the S3 contract (no local paths, download on read),
|
||||
* because the single-flight sits around withLocalCopy and the difference
|
||||
* between one download and eight is the whole point.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const os = require('os');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const EXTERNAL_ROOT = path.join(os.tmpdir(), `picpeak-sf-ext-${process.pid}`);
|
||||
process.env.EXTERNAL_MEDIA_ROOT = EXTERNAL_ROOT;
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const state = { events: {}, updates: [] };
|
||||
const api = (table) => {
|
||||
if (table === 'events') {
|
||||
return { where: (_col, id) => ({ first: async () => state.events[id] || null }) };
|
||||
}
|
||||
if (table === 'photos') {
|
||||
return {
|
||||
where: (criteria) => ({
|
||||
update: async (values) => { state.updates.push({ criteria, values }); return 1; },
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === 'app_settings') {
|
||||
return { whereIn: () => ({ select: async () => [] }) };
|
||||
}
|
||||
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 MANAGED_EVENT = { id: 11, slug: 'managed-ev', source_mode: 'managed' };
|
||||
const EXTERNAL_EVENT = { id: 7, slug: 'nas-ev', source_mode: 'reference', external_path: 'weddings/sf' };
|
||||
|
||||
let nextId = 1000;
|
||||
|
||||
async function writeJpeg(absPath, { width = 2400, height = 1600 } = {}) {
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await sharp({ create: { width, height, channels: 3, background: { r: 30, g: 120, b: 200 } } })
|
||||
.jpeg({ quality: 85 }).toFile(absPath);
|
||||
}
|
||||
|
||||
async function jpegBuffer({ width = 2400, height = 1600 } = {}) {
|
||||
return sharp({ create: { width, height, channels: 3, background: { r: 200, g: 60, b: 30 } } })
|
||||
.jpeg({ quality: 85 }).toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* The S3 contract as imageProcessor sees it: kind() !== 'local', so validity
|
||||
* is a stat only, and withLocalCopy has to download the source through
|
||||
* getToFile before Sharp can open it.
|
||||
*/
|
||||
class MemoryObjectStore {
|
||||
constructor() { this.objects = new Map(); this.puts = []; this.downloads = []; this.failNextPut = false; }
|
||||
kind() { return 's3'; }
|
||||
async init() {}
|
||||
async put(key, body) {
|
||||
if (this.failNextPut) { this.failNextPut = false; throw new Error('simulated upload failure'); }
|
||||
this.puts.push(key);
|
||||
this.objects.set(key, Buffer.from(body));
|
||||
}
|
||||
async stat(key) {
|
||||
const b = this.objects.get(key);
|
||||
return b ? { size: b.length, mtime: new Date() } : null;
|
||||
}
|
||||
async exists(key) { return this.objects.has(key); }
|
||||
async getToFile(key, localPath) {
|
||||
this.downloads.push(key);
|
||||
const b = this.objects.get(key);
|
||||
if (!b) throw new Error(`NoSuchKey: ${key}`);
|
||||
await fs.mkdir(path.dirname(localPath), { recursive: true });
|
||||
await fs.writeFile(localPath, b);
|
||||
}
|
||||
async delete(key) { this.objects.delete(key); }
|
||||
}
|
||||
|
||||
describe('single-flight rendition generation (#1020)', () => {
|
||||
let imageProcessor;
|
||||
|
||||
beforeAll(() => {
|
||||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.__state.events = { [MANAGED_EVENT.id]: MANAGED_EVENT, [EXTERNAL_EVENT.id]: EXTERNAL_EVENT };
|
||||
db.__state.updates = [];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
await fs.rm(EXTERNAL_ROOT, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('local storage', () => {
|
||||
let storage; let storageRoot; let puts;
|
||||
|
||||
beforeAll(async () => {
|
||||
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-sf-store-'));
|
||||
storage = new LocalFsStorage({ root: storageRoot });
|
||||
await storage.init();
|
||||
const origPut = storage.put.bind(storage);
|
||||
storage.put = async (key, ...rest) => {
|
||||
if (storage.failNextPut) { storage.failNextPut = false; throw new Error('simulated write failure'); }
|
||||
if (storage.holdNextPut) { const gate = storage.holdNextPut; storage.holdNextPut = null; await gate; }
|
||||
puts.push(key);
|
||||
return origPut(key, ...rest);
|
||||
};
|
||||
storageModule.setStorageForTesting(storage);
|
||||
}, 30000);
|
||||
|
||||
beforeEach(() => { puts = []; storage.failNextPut = false; storage.holdNextPut = null; });
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function managedPhoto() {
|
||||
const id = nextId++;
|
||||
const name = `managed-${id}.jpg`;
|
||||
const rel = `${MANAGED_EVENT.slug}/${name}`;
|
||||
await writeJpeg(path.join(storageRoot, 'events/active', rel));
|
||||
return { id, event_id: MANAGED_EVENT.id, source_origin: 'managed', path: rel, filename: name };
|
||||
}
|
||||
|
||||
async function externalPhoto({ write = true } = {}) {
|
||||
const id = nextId++;
|
||||
const name = `external-${id}.jpg`;
|
||||
const relpath = path.join(EXTERNAL_EVENT.external_path, name);
|
||||
if (write) await writeJpeg(path.join(EXTERNAL_ROOT, relpath));
|
||||
return { id, event_id: EXTERNAL_EVENT.id, source_origin: 'external', external_relpath: relpath, filename: name };
|
||||
}
|
||||
|
||||
const putsUnder = (prefix) => puts.filter((k) => k.startsWith(prefix));
|
||||
|
||||
it('ensurePreviewImage: eight concurrent cold requests share one generation', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const results = await Promise.all(Array.from({ length: 8 }, () => imageProcessor.ensurePreviewImage(photo)));
|
||||
|
||||
expect(results[0]).toMatch(/^previews\/preview_/);
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(putsUnder('previews/')).toHaveLength(1);
|
||||
// One flight, one row write — not eight identical updates.
|
||||
expect(db.__state.updates).toHaveLength(1);
|
||||
expect(await storage.stat(results[0])).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ensureHeroImage: eight concurrent cold requests share one generation', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const results = await Promise.all(Array.from({ length: 8 }, () => imageProcessor.ensureHeroImage(photo)));
|
||||
|
||||
expect(results[0]).toMatch(/^heroes\/hero_/);
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(putsUnder('heroes/')).toHaveLength(1);
|
||||
expect(db.__state.updates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ensureThumbnail: eight concurrent cold requests for an external photo share one generation', async () => {
|
||||
const photo = await externalPhoto();
|
||||
const results = await Promise.all(Array.from({ length: 8 }, () => imageProcessor.ensureThumbnail(photo)));
|
||||
|
||||
expect(results[0]).toBe(`thumbnails/thumb_ext${photo.id}_${photo.filename}`);
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(putsUnder('thumbnails/')).toHaveLength(1);
|
||||
expect(db.__state.updates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a tier request that resolves to the canonical thumbnail shares the canonical flight', async () => {
|
||||
// ensureThumbnailAtWidth hands the canonical width, and every video, to
|
||||
// ensureThumbnail. That used to be the one unguarded path a guarded
|
||||
// request could fall through into.
|
||||
const photo = await managedPhoto();
|
||||
const canonical = 300; // DEFAULT_THUMBNAIL_WIDTH; the settings mock returns no override
|
||||
const results = await Promise.all([
|
||||
imageProcessor.ensureThumbnailAtWidth(photo, canonical),
|
||||
imageProcessor.ensureThumbnailAtWidth(photo, canonical),
|
||||
imageProcessor.ensureThumbnail(photo),
|
||||
imageProcessor.ensureThumbnail(photo),
|
||||
]);
|
||||
|
||||
expect(results[0]).toMatch(/^thumbnails\/thumb_/);
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(putsUnder('thumbnails/')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ensureThumbnail', 'thumbnail_path', 'thumbnails/'],
|
||||
['ensureHeroImage', 'hero_path', 'heroes/'],
|
||||
['ensurePreviewImage', 'preview_path', 'previews/'],
|
||||
])('%s: a forced rebuild is never satisfied by joining a viewer\'s hot-path check', async (fn, column, prefix) => {
|
||||
// adminThumbnails.js forces a rebuild by passing the row with the path
|
||||
// nulled. If the validity check ran inside the flight, that call could
|
||||
// join a viewer's flight for the same photo — one that was merely
|
||||
// stat-ing an already good rendition — and be handed back the very
|
||||
// file it was asked to replace, with the endpoint counting a success.
|
||||
const photo = await managedPhoto();
|
||||
const existing = await imageProcessor[fn](photo);
|
||||
expect(existing).toMatch(new RegExp(`^${prefix}`));
|
||||
expect(putsUnder(prefix)).toHaveLength(1);
|
||||
|
||||
const [viewer, forced] = await Promise.all([
|
||||
imageProcessor[fn]({ ...photo, [column]: existing }),
|
||||
imageProcessor[fn]({ ...photo, [column]: null }),
|
||||
]);
|
||||
|
||||
expect(viewer).toBe(existing);
|
||||
expect(forced).toBe(existing);
|
||||
// The forced call wrote a fresh rendition; the viewer's did not.
|
||||
expect(putsUnder(prefix)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ensureThumbnail', 'thumbnail_path', 'thumbnails/'],
|
||||
['ensurePreviewImage', 'preview_path', 'previews/'],
|
||||
])('%s: a forced rebuild runs after a lazy generation already in flight instead of adopting it', async (fn, column, prefix) => {
|
||||
// The lazy flight read the settings when it started; after a settings
|
||||
// change it is producing exactly what the admin's regenerate exists to
|
||||
// replace. Joining it would count a success and leave the old size
|
||||
// cached — the validity check only asks whether the file parses.
|
||||
const photo = await managedPhoto();
|
||||
let release;
|
||||
storage.holdNextPut = new Promise((r) => { release = r; });
|
||||
|
||||
const lazy = imageProcessor[fn](photo); // blocks inside put
|
||||
const forced = imageProcessor[fn]({ ...photo, [column]: null }, { force: true });
|
||||
const joiner = imageProcessor[fn](photo); // lazy miss after the forced call
|
||||
let forcedSettled = false;
|
||||
forced.then(() => { forcedSettled = true; });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
expect(putsUnder(prefix)).toHaveLength(0);
|
||||
expect(forcedSettled).toBe(false);
|
||||
|
||||
release();
|
||||
const results = await Promise.all([lazy, forced, joiner]);
|
||||
expect(results.every((k) => k === results[0])).toBe(true);
|
||||
expect(results[0]).toMatch(new RegExp(`^${prefix}`));
|
||||
// Lazy wrote once, the forced rebuild wrote once more after it; the
|
||||
// later lazy miss joined the forced flight rather than starting a third.
|
||||
expect(putsUnder(prefix)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('a replaced photo (same id, new path) does not join a flight still rendering the old source', async () => {
|
||||
// replacePhoto keeps the id and changes path/filename. Keyed by id and
|
||||
// width alone, a request carrying the replacement row would join the
|
||||
// old flight, be handed the old image, and the gallery would cache it.
|
||||
const before = await managedPhoto();
|
||||
const after = await managedPhoto();
|
||||
const replacement = { ...after, id: before.id };
|
||||
|
||||
let release;
|
||||
storage.holdNextPut = new Promise((r) => { release = r; });
|
||||
const stale = imageProcessor.ensureThumbnailAtWidth(before, 600); // blocks inside put
|
||||
const fresh = imageProcessor.ensureThumbnailAtWidth(replacement, 600);
|
||||
release();
|
||||
|
||||
const [oldKey, newKey] = await Promise.all([stale, fresh]);
|
||||
expect(oldKey).toBe(`thumbnails/thumb_w600_p${before.id}_${before.filename}`);
|
||||
expect(newKey).toBe(`thumbnails/thumb_w600_p${before.id}_${after.filename}`);
|
||||
expect(putsUnder('thumbnails/').sort()).toEqual([oldKey, newKey].sort());
|
||||
|
||||
// Same shape for the canonical preview, which had no guard at all on
|
||||
// base and must not gain a cross-source one now.
|
||||
storage.holdNextPut = new Promise((r) => { release = r; });
|
||||
const staleP = imageProcessor.ensurePreviewImage(before);
|
||||
const freshP = imageProcessor.ensurePreviewImage(replacement);
|
||||
release();
|
||||
const [oldP, newP] = await Promise.all([staleP, freshP]);
|
||||
expect(oldP).not.toBe(newP);
|
||||
expect(putsUnder('previews/')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('different photos and different widths are separate flights', async () => {
|
||||
const a = await managedPhoto();
|
||||
const b = await externalPhoto();
|
||||
const calls = [
|
||||
...Array.from({ length: 4 }, () => imageProcessor.ensurePreviewImageAtWidth(a, 640)),
|
||||
...Array.from({ length: 4 }, () => imageProcessor.ensurePreviewImageAtWidth(a, 1280)),
|
||||
...Array.from({ length: 4 }, () => imageProcessor.ensurePreviewImageAtWidth(b, 640)),
|
||||
...Array.from({ length: 4 }, () => imageProcessor.ensureThumbnailAtWidth(a, 600)),
|
||||
...Array.from({ length: 4 }, () => imageProcessor.ensureThumbnailAtWidth(b, 600)),
|
||||
];
|
||||
const results = await Promise.all(calls);
|
||||
|
||||
expect(results.every(Boolean)).toBe(true);
|
||||
expect(new Set(results).size).toBe(5);
|
||||
expect(results.slice(0, 4).every((k) => k === results[0])).toBe(true);
|
||||
expect(results.slice(4, 8).every((k) => k === results[4])).toBe(true);
|
||||
expect(putsUnder('previews/')).toHaveLength(3);
|
||||
expect(putsUnder('thumbnails/')).toHaveLength(2);
|
||||
// Tiers are pure cache: never written to the row.
|
||||
expect(db.__state.updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a warm tier is served from storage without a second generation', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const first = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
||||
const again = await Promise.all([
|
||||
imageProcessor.ensurePreviewImageAtWidth(photo, 640),
|
||||
imageProcessor.ensurePreviewImageAtWidth(photo, 640),
|
||||
]);
|
||||
|
||||
expect(again).toEqual([first, first]);
|
||||
expect(putsUnder('previews/')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a failed flight is cleared so the next request retries instead of adopting the failure', async () => {
|
||||
const photo = await externalPhoto({ write: false });
|
||||
|
||||
const cold = await Promise.all(Array.from({ length: 4 }, () => imageProcessor.ensurePreviewImage(photo)));
|
||||
expect(cold).toEqual([null, null, null, null]);
|
||||
expect(putsUnder('previews/')).toHaveLength(0);
|
||||
|
||||
// The source appears (mount came back, file finished copying).
|
||||
await writeJpeg(path.join(EXTERNAL_ROOT, photo.external_relpath));
|
||||
const warm = await imageProcessor.ensurePreviewImage(photo);
|
||||
expect(warm).toBe(`previews/preview_ext${photo.id}_external-${photo.id}.jpg`);
|
||||
expect(putsUnder('previews/')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a flight that returns null is shared by every waiter and cleared afterwards', async () => {
|
||||
const photo = await managedPhoto();
|
||||
db.__state.events = {}; // the event lookup inside the flight finds nothing
|
||||
const results = await Promise.all(Array.from({ length: 3 }, () => imageProcessor.ensureThumbnail(photo)));
|
||||
expect(results).toEqual([null, null, null]);
|
||||
|
||||
db.__state.events = { [MANAGED_EVENT.id]: MANAGED_EVENT };
|
||||
const key = await imageProcessor.ensureThumbnail(photo);
|
||||
expect(key).toMatch(/^thumbnails\/thumb_/);
|
||||
expect(putsUnder('thumbnails/')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a flight that throws rejects every waiter identically and is cleared afterwards', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const boom = new Error('db down');
|
||||
const realEvents = db.__state.events;
|
||||
db.__state.events = new Proxy({}, { get: () => { throw boom; } });
|
||||
|
||||
// ensureThumbnail's event lookup is not wrapped in try/catch, so this
|
||||
// propagates — to every caller of the shared flight, not just the first.
|
||||
const settled = await Promise.allSettled(Array.from({ length: 3 }, () => imageProcessor.ensureThumbnail(photo)));
|
||||
expect(settled.map((s) => s.status)).toEqual(['rejected', 'rejected', 'rejected']);
|
||||
expect(settled.every((s) => s.reason === boom)).toBe(true);
|
||||
|
||||
db.__state.events = realEvents;
|
||||
const key = await imageProcessor.ensureThumbnail(photo);
|
||||
expect(key).toMatch(/^thumbnails\/thumb_/);
|
||||
});
|
||||
|
||||
it('the existing hero survives a regeneration whose source cannot be read', async () => {
|
||||
// generateHeroImage used to delete the target before Sharp had opened
|
||||
// the source, so a corrupt file or a blipped mount stripped the old
|
||||
// hero and returned null with the row still pointing at it.
|
||||
const src = path.join(storageRoot, 'events/active', MANAGED_EVENT.slug, 'hero-src.jpg');
|
||||
await writeJpeg(src);
|
||||
const key = await imageProcessor.generateHeroImage(src, { outputBasename: 'survive.jpg' });
|
||||
expect(key).toBe('heroes/hero_survive.jpg');
|
||||
const before = await storage.stat(key);
|
||||
|
||||
const junk = path.join(storageRoot, 'events/active', MANAGED_EVENT.slug, 'hero-junk.jpg');
|
||||
await fs.writeFile(junk, Buffer.from('this is not a jpeg'));
|
||||
const result = await imageProcessor.generateHeroImage(junk, { regenerate: true, outputBasename: 'survive.jpg' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
const after = await storage.stat(key);
|
||||
expect(after).toBeTruthy();
|
||||
expect(after.size).toBe(before.size);
|
||||
await expect(sharp(storage.resolveLocalPath(key)).metadata()).resolves.toMatchObject({ width: 1920 });
|
||||
});
|
||||
|
||||
it('the existing preview survives a regeneration whose write fails', async () => {
|
||||
// Probe succeeds, the pipeline runs, the put throws: the catch used to
|
||||
// delete the key, which by then only ever held the PREVIOUS good file.
|
||||
const src = path.join(storageRoot, 'events/active', MANAGED_EVENT.slug, 'preview-src.jpg');
|
||||
await writeJpeg(src);
|
||||
const key = await imageProcessor.generatePreviewImage(src, { outputBasename: 'survive.jpg' });
|
||||
expect(key).toBe('previews/preview_survive.jpg');
|
||||
const before = await storage.stat(key);
|
||||
|
||||
storage.failNextPut = true;
|
||||
const result = await imageProcessor.generatePreviewImage(src, { regenerate: true, outputBasename: 'survive.jpg' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
const after = await storage.stat(key);
|
||||
expect(after).toBeTruthy();
|
||||
expect(after.size).toBe(before.size);
|
||||
});
|
||||
|
||||
it('the existing hero survives a regeneration whose write fails', async () => {
|
||||
const src = path.join(storageRoot, 'events/active', MANAGED_EVENT.slug, 'hero-src2.jpg');
|
||||
await writeJpeg(src);
|
||||
const key = await imageProcessor.generateHeroImage(src, { outputBasename: 'survive2.jpg' });
|
||||
const before = await storage.stat(key);
|
||||
|
||||
storage.failNextPut = true;
|
||||
const result = await imageProcessor.generateHeroImage(src, { regenerate: true, outputBasename: 'survive2.jpg' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect((await storage.stat(key)).size).toBe(before.size);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3 contract', () => {
|
||||
let store;
|
||||
|
||||
beforeAll(() => {
|
||||
store = new MemoryObjectStore();
|
||||
storageModule.setStorageForTesting(store);
|
||||
});
|
||||
|
||||
beforeEach(() => { store.puts = []; store.downloads = []; store.failNextPut = false; });
|
||||
|
||||
async function managedPhoto() {
|
||||
const id = nextId++;
|
||||
const name = `s3-${id}.jpg`;
|
||||
const rel = `${MANAGED_EVENT.slug}/${name}`;
|
||||
store.objects.set(`events/active/${rel}`, await jpegBuffer());
|
||||
return { id, event_id: MANAGED_EVENT.id, source_origin: 'managed', path: rel, filename: name };
|
||||
}
|
||||
|
||||
it('ensurePreviewImage: concurrent cold requests download the source once and upload once', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const results = await Promise.all(Array.from({ length: 6 }, () => imageProcessor.ensurePreviewImage(photo)));
|
||||
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(results[0]).toMatch(/^previews\/preview_/);
|
||||
expect(store.downloads).toEqual([`events/active/${photo.path}`]);
|
||||
expect(store.puts).toHaveLength(1);
|
||||
expect(await store.stat(results[0])).toBeTruthy();
|
||||
expect(db.__state.updates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ensureHeroImage: concurrent cold requests download the source once and upload once', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const results = await Promise.all(Array.from({ length: 6 }, () => imageProcessor.ensureHeroImage(photo)));
|
||||
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(store.downloads).toHaveLength(1);
|
||||
expect(store.puts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ensureThumbnailAtWidth: concurrent cold tier requests download the source once and upload once', async () => {
|
||||
const photo = await managedPhoto();
|
||||
const results = await Promise.all(Array.from({ length: 6 }, () => imageProcessor.ensureThumbnailAtWidth(photo, 600)));
|
||||
|
||||
expect(new Set(results).size).toBe(1);
|
||||
expect(results[0]).toBe(`thumbnails/thumb_w600_p${photo.id}_${photo.filename}`);
|
||||
expect(store.downloads).toHaveLength(1);
|
||||
expect(store.puts).toEqual([results[0]]);
|
||||
});
|
||||
|
||||
it('the existing preview object survives a regeneration whose upload fails', async () => {
|
||||
// Fixed outputBasename, as the external and RAW branches pass: the key
|
||||
// is the same on both runs, so the pre-fix delete would have hit the
|
||||
// good object. (Through withLocalCopy the basename carries a random
|
||||
// temp prefix and the two runs never share a key, which is why this
|
||||
// drives the generator directly.)
|
||||
const srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-sf-s3src-'));
|
||||
const src = path.join(srcDir, 'src.jpg');
|
||||
await writeJpeg(src);
|
||||
try {
|
||||
const key = await imageProcessor.generatePreviewImage(src, { outputBasename: 's3-survive.jpg' });
|
||||
expect(key).toBe('previews/preview_s3-survive.jpg');
|
||||
const before = store.objects.get(key);
|
||||
expect(before).toBeTruthy();
|
||||
|
||||
store.failNextPut = true;
|
||||
const result = await imageProcessor.generatePreviewImage(src, { regenerate: true, outputBasename: 's3-survive.jpg' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(store.objects.get(key)).toBe(before);
|
||||
} finally {
|
||||
await fs.rm(srcDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,7 @@ jest.mock('../../src/utils/logger', () => ({
|
||||
}));
|
||||
|
||||
const invoiceService = require('../../src/services/invoiceService');
|
||||
const emailProcessor = require('../../src/services/emailProcessor');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
@@ -261,7 +262,10 @@ describe('invoiceService.releaseForDelivery', () => {
|
||||
});
|
||||
|
||||
describe('invoiceService.recordPaymentCheckAction', () => {
|
||||
beforeEach(() => resetChains());
|
||||
beforeEach(() => {
|
||||
resetChains();
|
||||
emailProcessor.queueEmail.mockClear();
|
||||
});
|
||||
|
||||
it('rejects invalid actions', async () => {
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
@@ -321,6 +325,71 @@ describe('invoiceService.recordPaymentCheckAction', () => {
|
||||
token: 'a'.repeat(64), action: 'partial', amountMinor: 9999,
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
// GHSA-wg94-f86h-vq68 hardening: every write via this unauthenticated
|
||||
// route notifies the admin. Uses 'paid_full' as the exercised action —
|
||||
// it stays inside markPaid (no workflow-engine / PDF-rendering
|
||||
// dependencies to stub) while still going through the full
|
||||
// recordPaymentCheckAction write path.
|
||||
it('queues an admin notification email after a successful action', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 5, invoice_number: 'INV-0005', status: 'overdue',
|
||||
total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
|
||||
customer_account_id: 7, created_by_admin_id: 42,
|
||||
currency: 'CHF', language: 'de', event_id: null,
|
||||
};
|
||||
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 7, email: 'c@example.com', display_name: 'Test Customer' };
|
||||
|
||||
const result = await invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'paid_full', ip: '203.0.113.7',
|
||||
});
|
||||
expect(result).toEqual({ applied: 'paid_full' });
|
||||
|
||||
expect(emailProcessor.queueEmail).toHaveBeenCalledTimes(1);
|
||||
const [, recipientEmail, templateKey, data] = emailProcessor.queueEmail.mock.calls[0];
|
||||
expect(recipientEmail).toBe('admin@example.com');
|
||||
expect(templateKey).toBe('invoice_payment_check_action_recorded');
|
||||
expect(data.invoice_number).toBe('INV-0005');
|
||||
expect(data.action).toBe('paid_full');
|
||||
expect(data.ip).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('does not fail (or roll back) the ledger write when the admin notification fails to send', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 5, invoice_number: 'INV-0005', status: 'overdue',
|
||||
total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
|
||||
customer_account_id: 7, created_by_admin_id: 42,
|
||||
currency: 'CHF', language: 'de', event_id: null,
|
||||
};
|
||||
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 7, email: 'c@example.com', display_name: 'Test Customer' };
|
||||
emailProcessor.queueEmail.mockRejectedValueOnce(new Error('smtp down'));
|
||||
|
||||
// The write itself (token consumption + markPaid) must still
|
||||
// succeed — the notification is best-effort only.
|
||||
const result = await invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'paid_full', ip: '203.0.113.7',
|
||||
});
|
||||
expect(result).toEqual({ applied: 'paid_full' });
|
||||
|
||||
// Token was actually consumed (the real assertion that the write
|
||||
// committed): the mock chain's .update() ran with used_at set.
|
||||
const tokenChain = pickChainFor('invoice_payment_check_tokens');
|
||||
expect(tokenChain.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ used_at: expect.any(Date), used_action: 'paid_full' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.queuePaymentCheckEmail', () => {
|
||||
@@ -371,4 +440,35 @@ describe('invoiceService.queuePaymentCheckEmail', () => {
|
||||
expect(res.sent).toBe(true);
|
||||
expect(res.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
// GHSA-wg94-f86h-vq68 hardening: token TTL shortened from 30 days to 72h.
|
||||
it('mints a token with a ~72h TTL, not the old 30-day window', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'overdue',
|
||||
customer_account_id: 5,
|
||||
created_by_admin_id: 42,
|
||||
total_amount_minor: 10000,
|
||||
currency: 'CHF',
|
||||
language: 'de',
|
||||
reminder_level: 0,
|
||||
due_date: '2026-05-01',
|
||||
last_payment_check_at: null,
|
||||
event_id: null,
|
||||
};
|
||||
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, email: 'c@example.com', display_name: 'Test' };
|
||||
|
||||
const before = Date.now();
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1);
|
||||
expect(res.sent).toBe(true);
|
||||
|
||||
const tokenChain = pickChainFor('invoice_payment_check_tokens');
|
||||
const insertedRow = tokenChain.insert.mock.calls[0][0];
|
||||
const ttlMs = new Date(insertedRow.expires_at).getTime() - before;
|
||||
expect(ttlMs).toBeGreaterThan(71 * 60 * 60 * 1000);
|
||||
expect(ttlMs).toBeLessThanOrEqual(72 * 60 * 60 * 1000 + 5000);
|
||||
// Well under the old 30-day TTL — the actual regression guard.
|
||||
expect(ttlMs).toBeLessThan(24 * 60 * 60 * 1000 * 30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,52 @@ describe('mfaService — TOTP verification', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — replay protection (GHSA-qcwx-r25m-j869)', () => {
|
||||
it('verifyTotp accepts a code once and rejects the same code as a replay', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
// First use: no lastUsedStep yet, so it's accepted.
|
||||
expect(mfaService.verifyTotp(code, secret)).toBe(true);
|
||||
|
||||
// Simulate persisting the matched step and replaying the same code: the
|
||||
// matched step must strictly advance past lastUsedStep, so this fails.
|
||||
const step = mfaService.currentTotpStep();
|
||||
expect(mfaService.verifyTotp(code, secret, step)).toBe(false);
|
||||
// A lastUsedStep the code hasn't caught up to yet also rejects it.
|
||||
expect(mfaService.verifyTotp(code, secret, step + 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifyTotpEncryptedStep returns the matched step on success and null on replay', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const stored = mfaService.encryptSecret(secret);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const step = mfaService.verifyTotpEncryptedStep(code, stored, null);
|
||||
expect(step).toEqual(expect.any(Number));
|
||||
expect(step).toBeGreaterThan(0);
|
||||
|
||||
// Replaying the same code against the just-persisted step is rejected.
|
||||
expect(mfaService.verifyTotpEncryptedStep(code, stored, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('a freshly generated code for the next TOTP step is accepted after a replay is rejected', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
const step = mfaService.verifyTotpEncryptedStep(code, mfaService.encryptSecret(secret), null)
|
||||
|| mfaService.currentTotpStep();
|
||||
|
||||
// Same-step replay: rejected.
|
||||
expect(mfaService.verifyTotp(code, secret, step)).toBe(false);
|
||||
|
||||
// A code minted for the next step (via a cloned authenticator with a
|
||||
// future epoch, not by mocking Date.now()) advances past last_used_step.
|
||||
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
|
||||
const nextCode = nextStepAuthenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(nextCode, secret, step)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — otpauth URI / QR', () => {
|
||||
it('builds an otpauth:// URI containing issuer, account and secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Preview tier lookup and cleanup must agree with what the generator writes
|
||||
* (#1020 follow-up).
|
||||
*
|
||||
* generatePreviewImage rewrites the extension to match the encoding (`.jpg`,
|
||||
* or `.webp` for alpha / multi-frame sources). ensurePreviewImageAtWidth and
|
||||
* previewTierKeys kept the SOURCE extension, so for a `.png`, `.JPG`, `.heic`
|
||||
* or RAW source the tier was generated on every single request — the stat
|
||||
* never matched — and cleanup never found the files, which piled up in
|
||||
* storage for the life of the install.
|
||||
*
|
||||
* Driven against real Sharp output, because the whole question is which
|
||||
* extension the encoder actually chose.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const os = require('os');
|
||||
const sharp = require('sharp');
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const state = { event: null };
|
||||
const api = (table) => {
|
||||
if (table === 'events') return { where: () => ({ first: async () => state.event }) };
|
||||
if (table === 'photos') return { where: () => ({ update: async () => 1 }) };
|
||||
if (table === 'app_settings') return { whereIn: () => ({ select: async () => [] }) };
|
||||
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: 21, slug: 'fmt-ev', source_mode: 'managed' };
|
||||
let nextId = 5000;
|
||||
|
||||
describe('preview tier keys follow the encoded extension', () => {
|
||||
let storage; let storageRoot; let imageProcessor; let puts;
|
||||
|
||||
beforeAll(async () => {
|
||||
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-tierkeys-'));
|
||||
storage = new LocalFsStorage({ root: storageRoot });
|
||||
await storage.init();
|
||||
const origPut = storage.put.bind(storage);
|
||||
storage.put = async (key, ...rest) => { puts.push(key); return origPut(key, ...rest); };
|
||||
storageModule.setStorageForTesting(storage);
|
||||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
}, 30000);
|
||||
|
||||
beforeEach(() => { puts = []; db.__state.event = EVENT; });
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function photoWith(name, { alpha = false } = {}) {
|
||||
const id = nextId++;
|
||||
const rel = `${EVENT.slug}/${name}`;
|
||||
const abs = path.join(storageRoot, 'events/active', rel);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
const pipeline = sharp({
|
||||
create: {
|
||||
width: 2000, height: 1400, channels: alpha ? 4 : 3,
|
||||
background: alpha ? { r: 10, g: 20, b: 30, alpha: 0.5 } : { r: 10, g: 20, b: 30 },
|
||||
},
|
||||
});
|
||||
if (/\.png$/i.test(name)) await pipeline.png().toFile(abs);
|
||||
else if (/\.webp$/i.test(name)) await pipeline.webp().toFile(abs);
|
||||
else await pipeline.jpeg().toFile(abs);
|
||||
return { id, event_id: EVENT.id, source_origin: 'managed', path: rel, filename: name };
|
||||
}
|
||||
|
||||
it.each([
|
||||
['opaque PNG', 'photo.png', false, '.jpg'],
|
||||
['transparent PNG', 'photo-alpha.png', true, '.webp'],
|
||||
['uppercase JPG', 'IMG_0001.JPG', false, '.jpg'],
|
||||
['jpeg spelled out', 'photo.jpeg', false, '.jpg'],
|
||||
['lowercase jpg', 'photo.jpg', false, '.jpg'],
|
||||
])('%s: the second request is a cache hit, not a second generation', async (_label, name, alpha, ext) => {
|
||||
const photo = await photoWith(name, { alpha });
|
||||
|
||||
const first = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
||||
expect(first).toBe(`previews/preview_w640_p${photo.id}_${name.replace(/\.[^.]+$/, '')}${ext}`);
|
||||
expect(puts).toEqual([first]);
|
||||
|
||||
const second = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
||||
expect(second).toBe(first);
|
||||
expect(puts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['opaque PNG', 'cleanup.png', false],
|
||||
['transparent PNG', 'cleanup-alpha.png', true],
|
||||
['uppercase JPG', 'CLEANUP.JPG', false],
|
||||
])('%s: previewTierKeys covers the generated key, so deletePreviewTiers removes it', async (_label, name, alpha) => {
|
||||
const photo = await photoWith(name, { alpha });
|
||||
const k640 = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
||||
const k1280 = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280);
|
||||
expect(await storage.stat(k640)).toBeTruthy();
|
||||
expect(await storage.stat(k1280)).toBeTruthy();
|
||||
|
||||
const keys = imageProcessor.previewTierKeys(photo);
|
||||
expect(keys).toEqual(expect.arrayContaining([k640, k1280]));
|
||||
|
||||
await imageProcessor.deletePreviewTiers(photo);
|
||||
expect(await storage.stat(k640)).toBeNull();
|
||||
expect(await storage.stat(k1280)).toBeNull();
|
||||
});
|
||||
|
||||
it('a tier written before the extension rewrite is still found, by lookup and by cleanup', async () => {
|
||||
// JPEG bytes under the source's `.png` name — what generatePreviewImage
|
||||
// produced before it started rewriting the extension. Served as JPEG by
|
||||
// the route (the Content-Type comes from the `.webp` suffix only).
|
||||
const photo = await photoWith('legacy.png');
|
||||
const legacyKey = `previews/preview_w640_p${photo.id}_legacy.png`;
|
||||
await storage.put(legacyKey, await sharp({ create: { width: 640, height: 448, channels: 3, background: '#123' } }).jpeg().toBuffer());
|
||||
puts = [];
|
||||
|
||||
expect(await imageProcessor.ensurePreviewImageAtWidth(photo, 640)).toBe(legacyKey);
|
||||
expect(puts).toHaveLength(0);
|
||||
|
||||
expect(imageProcessor.previewTierKeys(photo)).toContain(legacyKey);
|
||||
await imageProcessor.deletePreviewTiers(photo);
|
||||
expect(await storage.stat(legacyKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('never lists the canonical 1920 rendition, which preview_path owns', () => {
|
||||
const keys = imageProcessor.previewTierKeys({ id: 9, path: 'e/a.png', source_origin: 'managed' });
|
||||
expect(keys.some((k) => k.includes('w1920'))).toBe(false);
|
||||
expect(keys.every((k) => k.includes('p9_'))).toBe(true);
|
||||
// Both encodings plus the legacy source-extension key, per width.
|
||||
expect(keys).toEqual([
|
||||
'previews/preview_w640_p9_a.jpg', 'previews/preview_w640_p9_a.webp', 'previews/preview_w640_p9_a.png',
|
||||
'previews/preview_w1280_p9_a.jpg', 'previews/preview_w1280_p9_a.webp', 'previews/preview_w1280_p9_a.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not duplicate the legacy key when the source already is a lowercase .jpg', () => {
|
||||
const keys = imageProcessor.previewTierKeys({ id: 9, path: 'e/a.jpg', source_origin: 'managed' });
|
||||
expect(keys.filter((k) => k.includes('w640'))).toEqual([
|
||||
'previews/preview_w640_p9_a.jpg', 'previews/preview_w640_p9_a.webp',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* GHSA-xfvx-j447-732c: the SQLite restore path let an attacker-influenced
|
||||
* `manifest.database.backup_file` replace the live database.
|
||||
*
|
||||
* Two independent bugs, both fixed here:
|
||||
*
|
||||
* 1. Candidate resolution (restoreService.js's performDatabaseRestore,
|
||||
* ~L1000) tried an absolute `dbBackupFile` and a
|
||||
* `path.join(backupPath, dbBackupFile)` candidate with NO check that
|
||||
* the resolved path actually stayed inside the configured backup
|
||||
* root — a manifest could point `.restore` at any file on disk.
|
||||
*
|
||||
* 2. The resolved path was interpolated unescaped into a
|
||||
* `sqlite3 .restore '<path>'` dot-command string. sqlite3's CLI
|
||||
* parses that string itself (not the shell), so a single quote in
|
||||
* the path breaks out of the quoted argument regardless of
|
||||
* spawn()'s `shell: false` argv separation.
|
||||
*
|
||||
* These tests pin the fix directly against the exported helpers
|
||||
* (`resolveContainedDbBackupCandidates`, `assertSafeSqlitePath`,
|
||||
* `isContainedInRoots`, `getConfiguredBackupRoots`) — the exact functions
|
||||
* `performDatabaseRestore` calls before ever running `sqlite3 .restore` —
|
||||
* rather than driving the full restore (which does a real `db.destroy()` +
|
||||
* live-file swap against the shared app db and isn't worth the added
|
||||
* fragility for what's fundamentally a path-validation contract).
|
||||
*/
|
||||
|
||||
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-restoresvc-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restoresvc-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('restoreService — sqlite restore path safety (GHSA-xfvx)', () => {
|
||||
let db; let cleanup; let _internal;
|
||||
let backupPath;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
// The restore run's resolved local backup root — analogous to
|
||||
// `localBackupPath` in restoreService.restore(). Real directory with a
|
||||
// real database/ subfolder, matching what a genuine backup run leaves
|
||||
// on disk.
|
||||
backupPath = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-xfvx-backuproot-'));
|
||||
fs.mkdirSync(path.join(backupPath, 'database'), { recursive: true });
|
||||
|
||||
({ _internal } = require('../../src/services/restoreService'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('assertSafeSqlitePath — the sqlite3 dot-command injection gate', () => {
|
||||
it.each([
|
||||
['/backup/database/picpeak-db-sqlite-1.sql'],
|
||||
[`${backupPath || '/backup'}/database/picpeak-db-sqlite-2024-01-01.sql.gz`],
|
||||
])('accepts a normal backup path: %s', (p) => {
|
||||
expect(() => _internal.assertSafeSqlitePath(p)).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/backup/database/x\'; DROP TABLE admin_users; --.sql'],
|
||||
['/backup/database/x\' .restore \'/etc/passwd'],
|
||||
['/backup/database/x\n.shell rm -rf /'],
|
||||
['/backup/database/has space.sql'],
|
||||
['/backup/database/semi;colon.sql'],
|
||||
[null],
|
||||
[undefined],
|
||||
[42],
|
||||
])('rejects an unsafe/non-string path: %j', (p) => {
|
||||
expect(() => _internal.assertSafeSqlitePath(p)).toThrow(/unsafe path/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isContainedInRoots', () => {
|
||||
it('accepts a path inside a root', () => {
|
||||
expect(_internal.isContainedInRoots('/backup/database/x.sql', ['/backup'])).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a root path equal to the root itself', () => {
|
||||
expect(_internal.isContainedInRoots('/backup', ['/backup'])).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a path outside every root', () => {
|
||||
expect(_internal.isContainedInRoots('/etc/passwd', ['/backup'])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a sibling directory that merely shares a prefix', () => {
|
||||
// '/backup-evil' starts with the string '/backup' but is NOT inside it.
|
||||
expect(_internal.isContainedInRoots('/backup-evil/x.sql', ['/backup'])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a `..`-traversal path that resolves outside the root', () => {
|
||||
expect(_internal.isContainedInRoots('/backup/../etc/passwd', ['/backup'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfiguredBackupRoots', () => {
|
||||
afterEach(async () => {
|
||||
delete process.env.RESTORE_ALLOWED_ROOTS;
|
||||
await db('app_settings').whereIn('setting_key', ['backup_destination_path', 'backup_manifest_path']).del();
|
||||
});
|
||||
|
||||
it('always includes the trusted root even with nothing else configured', async () => {
|
||||
const roots = await _internal.getConfiguredBackupRoots('/some/trusted/backup-path');
|
||||
expect(roots).toContain(path.resolve('/some/trusted/backup-path'));
|
||||
});
|
||||
|
||||
it('adds configured backup_destination_path / backup_manifest_path and RESTORE_ALLOWED_ROOTS', async () => {
|
||||
await db('app_settings').insert([
|
||||
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify('/backup/dest'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_manifest_path', setting_value: JSON.stringify('/backup/manifests'), setting_type: 'backup' },
|
||||
]);
|
||||
process.env.RESTORE_ALLOWED_ROOTS = '/extra/root';
|
||||
|
||||
const roots = await _internal.getConfiguredBackupRoots('/trusted');
|
||||
expect(roots).toEqual(expect.arrayContaining([
|
||||
path.resolve('/trusted'),
|
||||
path.resolve('/backup/dest'),
|
||||
path.resolve('/backup/manifests'),
|
||||
path.resolve('/extra/root'),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveContainedDbBackupCandidates — the manifest.database.backup_file gate', () => {
|
||||
it('rejects an absolute backup_file outside every configured root, but still offers the safe legacy basename candidate', async () => {
|
||||
const candidates = await _internal.resolveContainedDbBackupCandidates(
|
||||
backupPath, '/etc/passwd', () => {}
|
||||
);
|
||||
// The raw absolute escape must NOT be present.
|
||||
expect(candidates).not.toContain('/etc/passwd');
|
||||
// Candidate (3), the basename-only legacy reconstruct, is inherently
|
||||
// safe (can't escape backupPath) and stays available as a fallback.
|
||||
expect(candidates).toContain(path.join(backupPath, 'database', 'passwd'));
|
||||
});
|
||||
|
||||
it('rejects a `..`-traversal relative backup_file, keeping only the contained legacy candidate', async () => {
|
||||
const candidates = await _internal.resolveContainedDbBackupCandidates(
|
||||
backupPath, '../../../../etc/passwd', () => {}
|
||||
);
|
||||
const escaped = candidates.some((c) => !_internal.isContainedInRoots(c, [path.resolve(backupPath)]));
|
||||
expect(escaped).toBe(false);
|
||||
expect(candidates).toContain(path.join(backupPath, 'database', 'passwd'));
|
||||
});
|
||||
|
||||
it('accepts a legitimate relative backup_file recorded by a real backup run', async () => {
|
||||
const candidates = await _internal.resolveContainedDbBackupCandidates(
|
||||
backupPath, 'database/picpeak-db-sqlite-2024-01-01.sql.gz', () => {}
|
||||
);
|
||||
expect(candidates).toContain(path.join(backupPath, 'database', 'picpeak-db-sqlite-2024-01-01.sql.gz'));
|
||||
// Every returned candidate must actually be safe to use.
|
||||
for (const c of candidates) {
|
||||
expect(_internal.isContainedInRoots(c, [path.resolve(backupPath)])).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a legitimate absolute backup_file that IS inside backupPath (the real dumper shape)', async () => {
|
||||
const absFile = path.join(backupPath, 'database', 'picpeak-db-sqlite-2024-02-02.sql.gz');
|
||||
const candidates = await _internal.resolveContainedDbBackupCandidates(
|
||||
backupPath, absFile, () => {}
|
||||
);
|
||||
expect(candidates).toContain(absFile);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* DNS-rebinding follow-up to the blind-SSRF fix in restoreServiceS3Ssrf.test.js
|
||||
* (GHSA-vm2x-c628-3cx5).
|
||||
*
|
||||
* isHostAllowed()/validateExternalUrlAsync() are check-then-connect on their
|
||||
* own: they resolve the S3 endpoint hostname once to vet it, then hand a
|
||||
* bare hostname to the AWS SDK, which resolves it AGAIN when it actually
|
||||
* connects. An attacker who controls DNS for the endpoint hostname (or an
|
||||
* infra DNS-rebinding condition) can answer the first lookup with a public
|
||||
* IP and the second with a private/metadata one.
|
||||
*
|
||||
* downloadFileFromS3() now builds pinned http/https agents (pinnedRequest.js
|
||||
* — the same primitive webhookDeliveryWorker.js and emailWebhookTransport.js
|
||||
* use for outbound HTTP) from the validated address and passes them into
|
||||
* S3StorageAdapter, which threads them into the S3Client's NodeHttpHandler
|
||||
* requestHandler. This asserts that wiring: the agents S3StorageAdapter
|
||||
* receives resolve the endpoint hostname to ONLY the address vetted during
|
||||
* validation, and never fall through to a second, real DNS lookup that a
|
||||
* rebinding attacker could answer differently.
|
||||
*/
|
||||
|
||||
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-restores3pin-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restores3pin-test-secret';
|
||||
|
||||
jest.mock('dns', () => {
|
||||
const actual = jest.requireActual('dns');
|
||||
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() }, lookup: jest.fn() };
|
||||
});
|
||||
|
||||
let capturedConfig;
|
||||
jest.mock('../../src/services/storage/s3Storage', () =>
|
||||
jest.fn().mockImplementation((config) => {
|
||||
capturedConfig = config;
|
||||
return { download: jest.fn().mockResolvedValue(undefined) };
|
||||
})
|
||||
);
|
||||
|
||||
const dns = require('dns');
|
||||
const promiseLookup = dns.promises.lookup;
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { RestoreService } = require('../../src/services/restoreService');
|
||||
|
||||
describe('downloadFileFromS3 DNS-rebinding pinning', () => {
|
||||
let restoreService;
|
||||
let originalNodeEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreService = new RestoreService();
|
||||
capturedConfig = undefined;
|
||||
promiseLookup.mockReset();
|
||||
dns.lookup.mockReset();
|
||||
S3StorageAdapter.mockClear();
|
||||
originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
capturedConfig?.httpAgent?.destroy();
|
||||
capturedConfig?.httpsAgent?.destroy();
|
||||
});
|
||||
|
||||
it('passes pinned http/https agents into S3StorageAdapter built from the validated address', async () => {
|
||||
promiseLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
|
||||
expect(capturedConfig.httpAgent).toBeInstanceOf(require('http').Agent);
|
||||
expect(capturedConfig.httpsAgent).toBeInstanceOf(require('https').Agent);
|
||||
});
|
||||
|
||||
it('the pinned agent never performs a second DNS lookup — rebinding to a private IP on the real resolver is ignored', async () => {
|
||||
// First (validation) lookup: public IP, passes the preflight.
|
||||
promiseLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
// If the pinned agent ever fell through to a real lookup, this would
|
||||
// hand back a private/metadata address — simulating the rebind.
|
||||
dns.lookup.mockImplementation((_hostname, options, callback) => {
|
||||
if (typeof options === 'function') { callback = options; options = {}; }
|
||||
callback(null, ...(options?.all ? [[{ address: '169.254.169.254', family: 4 }]] : ['169.254.169.254', 4]));
|
||||
});
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
const pinnedLookup = capturedConfig.httpAgent.options.lookup;
|
||||
expect(typeof pinnedLookup).toBe('function');
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
pinnedLookup('rebind.example.com', {}, (err, address, family) => {
|
||||
if (err) return reject(err);
|
||||
resolve({ address, family });
|
||||
});
|
||||
});
|
||||
|
||||
// Only the address vetted during validation is ever handed back —
|
||||
// never the private address the real resolver would now answer with.
|
||||
expect(result).toEqual({ address: '93.184.216.34', family: 4 });
|
||||
expect(dns.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a lookup for any hostname other than the one that was validated', async () => {
|
||||
promiseLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
const pinnedLookup = capturedConfig.httpAgent.options.lookup;
|
||||
|
||||
await expect(new Promise((resolve, reject) => {
|
||||
pinnedLookup('attacker-controlled.example', {}, (err, address) => {
|
||||
if (err) return reject(err);
|
||||
resolve(address);
|
||||
});
|
||||
})).rejects.toThrow(/hostname changed/i);
|
||||
});
|
||||
|
||||
it('does not pin agents when no custom endpoint is configured (default AWS, no rebinding surface)', async () => {
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(promiseLookup).not.toHaveBeenCalled();
|
||||
expect(capturedConfig.httpAgent).toBeUndefined();
|
||||
expect(capturedConfig.httpsAgent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Blind SSRF via the restore S3 download path (GHSA-vm2x-c628-3cx5).
|
||||
*
|
||||
* downloadFileFromS3() built a bare S3StorageAdapter and called .download()
|
||||
* directly, never running the DNS-resolving isHostAllowed() guard that
|
||||
* testConnection() applies elsewhere — so an admin with backup.restore could
|
||||
* point the request-supplied S3 endpoint at an internal/metadata address for
|
||||
* unauthenticated egress via the server. `s3Config` here is fully attacker
|
||||
* controlled (POST /api/admin/restore/validate and /restore/start take it
|
||||
* straight from the request body — see routes/adminRestore.js), unlike the
|
||||
* scheduled-backup S3 endpoint, which is vetted at settings-save time.
|
||||
*/
|
||||
|
||||
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-restores3ssrf-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restores3ssrf-test-secret';
|
||||
|
||||
jest.mock('dns', () => {
|
||||
const actual = jest.requireActual('dns');
|
||||
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() } };
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/storage/s3Storage', () =>
|
||||
jest.fn().mockImplementation(() => ({
|
||||
download: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
);
|
||||
|
||||
const dns = require('dns');
|
||||
const lookup = dns.promises.lookup;
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { RestoreService } = require('../../src/services/restoreService');
|
||||
|
||||
describe('downloadFileFromS3 SSRF guard (GHSA-vm2x-c628-3cx5)', () => {
|
||||
let restoreService;
|
||||
let originalNodeEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreService = new RestoreService();
|
||||
lookup.mockReset();
|
||||
S3StorageAdapter.mockClear();
|
||||
originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
});
|
||||
|
||||
it('rejects an endpoint hostname that resolves to a private/internal address before any network call', async () => {
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
|
||||
|
||||
await expect(
|
||||
restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'evil-rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
)
|
||||
).rejects.toThrow(/private or internal network address/i);
|
||||
|
||||
expect(S3StorageAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an endpoint hostname that resolves to the cloud metadata address', async () => {
|
||||
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
|
||||
|
||||
await expect(
|
||||
restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'metadata-rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
)
|
||||
).rejects.toThrow(/private or internal network address/i);
|
||||
|
||||
expect(S3StorageAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a legitimate public S3 endpoint through to download()', async () => {
|
||||
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 's3.example-cdn.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not require the guard outside production (dev MinIO stays usable), but still downloads', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]); // would be rejected in prod
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'localhost:9000', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
jest.mock('../../src/utils/logger', () => ({ error: jest.fn() }));
|
||||
const { scheduledTask } = require('../../src/services/scheduledTask');
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
it('starts once, skips overlap and drains the accepted run on stop', async () => {
|
||||
let finish;
|
||||
const work = jest.fn(() => new Promise(resolve => { finish = resolve; }));
|
||||
const task = scheduledTask(work, { interval: 100 });
|
||||
task.start(); task.start();
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
await jest.advanceTimersByTimeAsync(300);
|
||||
expect(work).toHaveBeenCalledTimes(1);
|
||||
let stopped = false;
|
||||
const stop = task.stop().then(() => { stopped = true; });
|
||||
await Promise.resolve(); expect(stopped).toBe(false);
|
||||
finish(); await stop; expect(stopped).toBe(true);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
await jest.advanceTimersByTimeAsync(1000); expect(work).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('cancels a delayed first run and can restart cleanly', async () => {
|
||||
const work = jest.fn(); const task = scheduledTask(work, { interval: 100, initialDelay: 10 });
|
||||
task.start(); await task.stop(); await jest.advanceTimersByTimeAsync(200); expect(work).not.toHaveBeenCalled();
|
||||
task.start(); await jest.advanceTimersByTimeAsync(10); expect(work).toHaveBeenCalledTimes(1);
|
||||
await task.stop();
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
const secure = require('../../src/services/secureImageService');
|
||||
beforeEach(() => { jest.useFakeTimers(); secure.dispose(); });
|
||||
afterEach(() => { secure.dispose(); jest.useRealTimers(); });
|
||||
it('owns one timer for many tokens and sweeps expired capabilities', () => {
|
||||
for (let i = 0; i < 100; i++) secure.generateSecureToken(i, 'gallery_public_1_1', { expiresIn: 1 });
|
||||
expect(jest.getTimerCount()).toBe(1); expect(secure.tokenCache.size).toBe(100);
|
||||
jest.advanceTimersByTime(60000); expect(secure.tokenCache.size).toBe(0);
|
||||
secure.dispose(); expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
it('disposes all session/rate caches and restarts on demand', () => {
|
||||
secure.generateSecureToken(1, 'session'); secure.sessionTokens.set('a', 'b'); secure.rateLimitCache.set('a', 'b');
|
||||
secure.dispose(); expect(secure.sessionTokens.size + secure.rateLimitCache.size + secure.tokenCache.size).toBe(0);
|
||||
secure.generateSecureToken(1, 'session'); expect(jest.getTimerCount()).toBe(1);
|
||||
});
|
||||
@@ -42,6 +42,7 @@ async function bootDb() {
|
||||
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||
t.boolean('prompt_shown').notNullable().defaultTo(false);
|
||||
t.string('installation_id', 64);
|
||||
t.string('public_key', 59);
|
||||
t.text('private_key_encrypted');
|
||||
|
||||
@@ -32,6 +32,7 @@ async function bootDb() {
|
||||
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v2');
|
||||
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||
t.boolean('prompt_shown').notNullable().defaultTo(false);
|
||||
t.string('installation_id', 64);
|
||||
t.string('public_key', 59);
|
||||
t.text('private_key_encrypted');
|
||||
|
||||
@@ -26,6 +26,7 @@ async function bootDb() {
|
||||
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||
t.boolean('prompt_shown').notNullable().defaultTo(false);
|
||||
t.string('installation_id', 64);
|
||||
t.string('public_key', 59);
|
||||
t.text('private_key_encrypted');
|
||||
|
||||
@@ -25,6 +25,7 @@ async function bootDb() {
|
||||
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||
t.boolean('prompt_shown').notNullable().defaultTo(false);
|
||||
t.string('installation_id', 64);
|
||||
t.string('public_key', 59);
|
||||
t.text('private_key_encrypted');
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* GHSA-h4w8-57xq-53fx entropy half: resetAdminPassword used to mint the
|
||||
* emailed temp password with generateReadablePassword() — 10 adjectives x
|
||||
* 10 nouns x crypto.randomInt(1000,9999) x 5 specials, ~2^21 possibilities,
|
||||
* brute-forceable. It now uses generateSecurePassword(16) (90-char charset),
|
||||
* same as every other security-sensitive password path in this file.
|
||||
*
|
||||
* Verified against a real SQLite DB (full core-migration set) so the
|
||||
* emailed plaintext, the stored hash, and must_change_password are all
|
||||
* checked end to end rather than against a mock.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// bootCrmDb() sets TEST_DATABASE_PATH itself, but only in time for requires
|
||||
// that happen AFTER it runs (inside beforeAll). userManagementService.js
|
||||
// requires database/db.js at module load — i.e. before beforeAll — so that
|
||||
// connection has to be pointed at a fresh, unused test DB up front, or it
|
||||
// falls back to the shared default path and collides with whatever another
|
||||
// test file already migrated onto it. Same workaround as
|
||||
// userManagementService.activateDelete.test.js.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-reset-pw-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'reset-pw-test-secret';
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
|
||||
const userManagementService = require('../../src/services/userManagementService');
|
||||
|
||||
// The wordlist generateReadablePassword() used to produce:
|
||||
// <Adjective><Noun><4 digits><1 special>, e.g. "SwiftEagle4821!"
|
||||
const READABLE_WORDLIST_PATTERN = /^(Swift|Bright|Strong|Happy|Clever|Brave|Noble|Quick|Sharp|Bold)(Eagle|Mountain|River|Thunder|Forest|Ocean|Falcon|Dragon|Phoenix|Tiger)\d{4}[!@#$%]$/;
|
||||
|
||||
describe('userManagementService.resetAdminPassword (GHSA-h4w8-57xq-53fx)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let actorId;
|
||||
let targetId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId: actorId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, actorId, 'super_admin');
|
||||
|
||||
const editor = await db('roles').where({ name: 'editor' }).first();
|
||||
const targetInsert = await db('admin_users').insert({
|
||||
username: 'reset-target', email: 'reset-target@example.com',
|
||||
password_hash: await bcrypt.hash('old-password', 4),
|
||||
role_id: editor?.id || null,
|
||||
is_active: 1, must_change_password: false, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
targetId = targetInsert[0]?.id ?? targetInsert[0];
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('generates a high-entropy password, not one drawn from the adjective/noun wordlist', async () => {
|
||||
const before = await db('admin_users').where({ id: targetId }).first();
|
||||
|
||||
await userManagementService.resetAdminPassword(targetId, actorId);
|
||||
|
||||
const emailRow = await db('email_queue')
|
||||
.where({ recipient_email: 'reset-target@example.com', email_type: 'admin_password_reset' })
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
expect(emailRow).toBeDefined();
|
||||
const emailData = JSON.parse(emailRow.email_data);
|
||||
const newPassword = emailData.new_password;
|
||||
|
||||
// generateSecurePassword(16): fixed 16-char length, not the wordlist's
|
||||
// variable-length "WordWord####!" shape.
|
||||
expect(newPassword).toHaveLength(16);
|
||||
expect(newPassword).not.toMatch(READABLE_WORDLIST_PATTERN);
|
||||
// generateSecurePassword guarantees at least one of each character class.
|
||||
expect(newPassword).toMatch(/[a-z]/);
|
||||
expect(newPassword).toMatch(/[A-Z]/);
|
||||
expect(newPassword).toMatch(/[0-9]/);
|
||||
expect(newPassword).toMatch(/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/);
|
||||
|
||||
// The emailed plaintext actually matches what got persisted.
|
||||
const after = await db('admin_users').where({ id: targetId }).first();
|
||||
expect(after.password_hash).not.toBe(before.password_hash);
|
||||
await expect(bcrypt.compare(newPassword, after.password_hash)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('sets must_change_password so the enforcement backstop kicks in on next login', async () => {
|
||||
await db('admin_users').where({ id: targetId }).update({ must_change_password: false });
|
||||
|
||||
await userManagementService.resetAdminPassword(targetId, actorId);
|
||||
|
||||
const after = await db('admin_users').where({ id: targetId }).first();
|
||||
expect(after.must_change_password === true || after.must_change_password === 1).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Privilege-escalation guard for PUT /api/admin/users/:id and
|
||||
* POST /api/admin/users/invite (GHSA-rv8w-m6mx-7j4q).
|
||||
*
|
||||
* updateAdminUser's role-change path previously enforced only:
|
||||
* (a) non-super_admin actors can't grant the super_admin role
|
||||
* (b) no self-role-update / demoting the last super_admin
|
||||
* It never checked whether the ACTOR's own permission set covers the
|
||||
* permissions carried by the role being granted — so an admin holding
|
||||
* only `users.edit` could hand any other admin a role (including the
|
||||
* built-in `admin` role) carrying far more permissions than the actor
|
||||
* itself held.
|
||||
*
|
||||
* createInvitation() had the identical gap: it only ever blocked
|
||||
* granting super_admin, so an admin holding only `users.create` could
|
||||
* invite a brand-new admin into any other role — including one carrying
|
||||
* far more permissions than the inviter itself held — via
|
||||
* POST /admin/users/invite.
|
||||
*
|
||||
* The fix reuses assertActorMayGrant() — the same containment already
|
||||
* applied to roles.manage (see adminRolesGuards.test.js) — inside both
|
||||
* updateAdminUser's role_id branch and createInvitation().
|
||||
*
|
||||
* Both describe blocks below share a single bootCrmDb() call: the
|
||||
* `db` module (`src/database/db.js`) is a singleton keyed off
|
||||
* TEST_DATABASE_PATH at first require, and bootCrmDb's own comment
|
||||
* warns that a second call after the first's cleanup() destroys the
|
||||
* pool, leaving "Unable to acquire a connection" for every later query.
|
||||
*/
|
||||
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-rolegrantguard-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'rolegrantguard-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-rolegrantguard-storage-'));
|
||||
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
|
||||
const svc = require('../../src/services/userManagementService');
|
||||
const { clearPermissionCache } = require('../../src/middleware/permissions');
|
||||
|
||||
let db; let cleanup;
|
||||
let superId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId: superId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, superId, 'super_admin');
|
||||
clearPermissionCache();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('updateAdminUser — role-grant privilege-escalation guard (GHSA-rv8w-m6mx-7j4q)', () => {
|
||||
let limitedRoleId; let limitedId; // holds only users.edit + events.view
|
||||
let powerfulRoleId; // carries settings.banking, which limitedId does NOT hold
|
||||
let modestRoleId; // carries only events.view, a subset of what limitedId holds
|
||||
let targetId; // account whose role limitedId will try to change
|
||||
|
||||
beforeAll(async () => {
|
||||
// The attacker in GHSA-rv8w-m6mx-7j4q: users.edit only, nothing else.
|
||||
const limitedRole = await svc.createRole(
|
||||
{ name: 'limited_user_editor', permissions: ['users.edit', 'events.view'] },
|
||||
superId,
|
||||
);
|
||||
limitedRoleId = limitedRole.id;
|
||||
const limitedIns = await db('admin_users').insert({
|
||||
username: 'limited', email: 'limited@example.com', password_hash: 'x',
|
||||
role_id: limitedRoleId, must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
limitedId = limitedIns[0]?.id ?? limitedIns[0];
|
||||
|
||||
// A role carrying a permission the limited actor does not hold.
|
||||
const powerfulRole = await svc.createRole(
|
||||
{ name: 'powerful_role', permissions: ['users.edit', 'settings.banking'] },
|
||||
superId,
|
||||
);
|
||||
powerfulRoleId = powerfulRole.id;
|
||||
|
||||
// A role whose permissions ARE a subset of what the limited actor holds.
|
||||
const modestRole = await svc.createRole(
|
||||
{ name: 'modest_role', permissions: ['events.view'] },
|
||||
superId,
|
||||
);
|
||||
modestRoleId = modestRole.id;
|
||||
|
||||
clearPermissionCache();
|
||||
}, 120000);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Fresh target for every test, role reset to modestRole so role-change
|
||||
// assertions always start from a known baseline.
|
||||
const existing = await db('admin_users').where({ username: 'target' }).first();
|
||||
if (existing) {
|
||||
targetId = existing.id;
|
||||
await db('admin_users').where({ id: targetId }).update({ role_id: modestRoleId });
|
||||
} else {
|
||||
const ins = await db('admin_users').insert({
|
||||
username: 'target', email: 'target@example.com', password_hash: 'x',
|
||||
role_id: modestRoleId, must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
targetId = ins[0]?.id ?? ins[0];
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to let an admin grant a role carrying permissions the admin lacks', async () => {
|
||||
await expect(
|
||||
svc.updateAdminUser(
|
||||
targetId,
|
||||
{ role_id: powerfulRoleId },
|
||||
limitedId,
|
||||
{ roleName: 'limited_user_editor' },
|
||||
),
|
||||
).rejects.toThrow(/only grant permissions your own role/i);
|
||||
|
||||
// Target's role must be unchanged.
|
||||
const row = await db('admin_users').where({ id: targetId }).first();
|
||||
expect(row.role_id).toBe(modestRoleId);
|
||||
});
|
||||
|
||||
it('refuses to let an admin grant the built-in admin role beyond its own permissions', async () => {
|
||||
const adminRole = await db('roles').where({ name: 'admin' }).first();
|
||||
await expect(
|
||||
svc.updateAdminUser(
|
||||
targetId,
|
||||
{ role_id: adminRole.id },
|
||||
limitedId,
|
||||
{ roleName: 'limited_user_editor' },
|
||||
),
|
||||
).rejects.toThrow(/only grant permissions your own role/i);
|
||||
});
|
||||
|
||||
it('allows an admin to grant a role whose permissions it already holds', async () => {
|
||||
const updated = await svc.updateAdminUser(
|
||||
targetId,
|
||||
{ role_id: limitedRoleId },
|
||||
limitedId,
|
||||
{ roleName: 'limited_user_editor' },
|
||||
);
|
||||
expect(updated.role_id).toBe(limitedRoleId);
|
||||
});
|
||||
|
||||
it('super_admin can still grant any role, including one carrying more permissions than a limited actor holds', async () => {
|
||||
const updated = await svc.updateAdminUser(
|
||||
targetId,
|
||||
{ role_id: powerfulRoleId },
|
||||
superId,
|
||||
{ roleName: 'super_admin' },
|
||||
);
|
||||
expect(updated.role_id).toBe(powerfulRoleId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInvitation — role-grant privilege-escalation guard (GHSA-rv8w-m6mx-7j4q)', () => {
|
||||
let limitedRoleId; let limitedId; // holds only users.create + events.view
|
||||
let powerfulRoleId; // carries settings.banking, which limitedId does NOT hold
|
||||
let modestRoleId; // carries only events.view, a subset of what limitedId holds
|
||||
let inviteCounter = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
const limitedRole = await svc.createRole(
|
||||
{ name: 'limited_inviter', permissions: ['users.create', 'events.view'] },
|
||||
superId,
|
||||
);
|
||||
limitedRoleId = limitedRole.id;
|
||||
const limitedIns = await db('admin_users').insert({
|
||||
username: 'limited_inviter', email: 'limited_inviter@example.com', password_hash: 'x',
|
||||
role_id: limitedRoleId, must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
limitedId = limitedIns[0]?.id ?? limitedIns[0];
|
||||
|
||||
const powerfulRole = await svc.createRole(
|
||||
{ name: 'powerful_invite_role', permissions: ['users.create', 'settings.banking'] },
|
||||
superId,
|
||||
);
|
||||
powerfulRoleId = powerfulRole.id;
|
||||
|
||||
const modestRole = await svc.createRole(
|
||||
{ name: 'modest_invite_role', permissions: ['events.view'] },
|
||||
superId,
|
||||
);
|
||||
modestRoleId = modestRole.id;
|
||||
|
||||
clearPermissionCache();
|
||||
}, 120000);
|
||||
|
||||
function nextEmail() {
|
||||
inviteCounter += 1;
|
||||
return `invitee-${inviteCounter}@example.com`;
|
||||
}
|
||||
|
||||
it('refuses to let an admin invite someone into a role carrying permissions the admin lacks', async () => {
|
||||
await expect(
|
||||
svc.createInvitation({
|
||||
email: nextEmail(),
|
||||
roleId: powerfulRoleId,
|
||||
invitedById: limitedId,
|
||||
inviterRoleName: 'limited_inviter',
|
||||
}),
|
||||
).rejects.toThrow(/only grant permissions your own role/i);
|
||||
});
|
||||
|
||||
it('allows an admin to invite someone into a role whose permissions it already holds', async () => {
|
||||
const invitation = await svc.createInvitation({
|
||||
email: nextEmail(),
|
||||
roleId: limitedRoleId,
|
||||
invitedById: limitedId,
|
||||
inviterRoleName: 'limited_inviter',
|
||||
});
|
||||
expect(invitation.role).toBeTruthy();
|
||||
});
|
||||
|
||||
it('allows an admin to invite someone into a role that is a subset of its own permissions', async () => {
|
||||
const invitation = await svc.createInvitation({
|
||||
email: nextEmail(),
|
||||
roleId: modestRoleId,
|
||||
invitedById: limitedId,
|
||||
inviterRoleName: 'limited_inviter',
|
||||
});
|
||||
expect(invitation.role).toBeTruthy();
|
||||
});
|
||||
|
||||
it('super_admin can still invite into any role, including one carrying more permissions than a limited actor holds', async () => {
|
||||
const invitation = await svc.createInvitation({
|
||||
email: nextEmail(),
|
||||
roleId: powerfulRoleId,
|
||||
invitedById: superId,
|
||||
inviterRoleName: 'super_admin',
|
||||
});
|
||||
expect(invitation.role).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* generateVideoPlaceholder() must not touch the database when the caller
|
||||
* already supplies width/height (videoProcessor.js's thumbnail-generation
|
||||
* fallback does exactly this).
|
||||
*
|
||||
* Why it matters: processUploadedPhotos() (chunked video upload) holds a
|
||||
* per-file SQLite transaction open across thumbnail generation. SQLite's
|
||||
* knex pool defaults to a single connection, so any second, un-transacted
|
||||
* db() query made while that transaction is open blocks until
|
||||
* acquireConnectionTimeout (60s in production) — verified directly against
|
||||
* an isolated SQLite db (codex review of #1371/#1372). Passing explicit
|
||||
* dimensions must skip getThumbnailSettings()'s db() call entirely, not
|
||||
* just tolerate its failure.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const os = require('os');
|
||||
|
||||
const mockDbSpy = jest.fn(() => {
|
||||
throw new Error('db() must not be called when width/height are supplied');
|
||||
});
|
||||
jest.mock('../../src/database/db', () => ({ db: (...args) => mockDbSpy(...args) }));
|
||||
|
||||
const storageModule = require('../../src/services/storage');
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
|
||||
describe('generateVideoPlaceholder skips the settings DB lookup given explicit dimensions', () => {
|
||||
let storage;
|
||||
let root;
|
||||
let imageProcessor;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-vidplaceholder-'));
|
||||
storage = new LocalFsStorage({ root });
|
||||
await storage.init();
|
||||
storageModule.setStorageForTesting(storage);
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => mockDbSpy.mockClear());
|
||||
|
||||
it('never calls db() when width/height are provided', async () => {
|
||||
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4', { width: 300, height: 300 });
|
||||
|
||||
expect(key).toBe('thumbnails/thumb_demo.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
expect(mockDbSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through to defaults (not a throw) when db() fails and no dimensions were given', async () => {
|
||||
const key = await imageProcessor.generateVideoPlaceholder('demo2.mp4');
|
||||
|
||||
expect(key).toBe('thumbnails/thumb_demo2.jpg');
|
||||
expect(mockDbSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
jest.mock('../../src/utils/logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }));
|
||||
|
||||
describe.each(['backgroundProcessor', 'faceQueue'])('%s shutdown', name => {
|
||||
let worker, db, processPhoto, featureEnabled, janitorUpdate, releases;
|
||||
const prefix = name === 'faceQueue' ? 'FACE_PROCESSOR' : 'UPLOAD_PROCESSOR';
|
||||
let previousEnv;
|
||||
class SidecarUnavailableError extends Error {}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(done => { resolve = done; });
|
||||
releases.push(resolve);
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.useFakeTimers();
|
||||
releases = [];
|
||||
previousEnv = { ...process.env };
|
||||
delete process.env[`${prefix}_DISABLED`];
|
||||
process.env[`${prefix}_CONCURRENCY`] = '2';
|
||||
process.env[`${prefix}_POLL_MS`] = '2000';
|
||||
process.env.FACE_PROCESSOR_BACKOFF_MS = '30000';
|
||||
processPhoto = jest.fn().mockResolvedValue({ status: 'skipped' });
|
||||
featureEnabled = jest.fn().mockResolvedValue(true);
|
||||
janitorUpdate = jest.fn().mockResolvedValue(0);
|
||||
const chain = { where: jest.fn().mockReturnThis(), update: janitorUpdate };
|
||||
db = jest.fn(() => chain);
|
||||
db.client = { config: { client: 'pg' } };
|
||||
// Claims and processing are controlled at the I/O boundary; the real
|
||||
// workers, janitors, idle sleeps and stopServices run in every test.
|
||||
db.transaction = jest.fn().mockResolvedValue(null);
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
jest.doMock('../../src/services/photoProcessor', () => ({ processPhoto }));
|
||||
jest.doMock('../../src/services/faceProcessor', () => ({
|
||||
processPhotoFaces: processPhoto, TransientSourceError: class extends Error {},
|
||||
}));
|
||||
jest.doMock('../../src/services/faceClient', () => ({ SidecarUnavailableError }));
|
||||
jest.doMock('../../src/services/faceSettings', () => ({
|
||||
isFeatureEnabled: featureEnabled, isEnabledForEvent: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
worker = require(`../../src/services/${name}`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
releases.forEach(resolve => resolve());
|
||||
const stopped = worker.stop();
|
||||
// Also cleans up the original, non-interruptible implementation when a
|
||||
// regression assertion fails; the test never needs to wait a real minute.
|
||||
await jest.advanceTimersByTimeAsync(60000);
|
||||
await stopped;
|
||||
jest.useRealTimers();
|
||||
process.env = previousEnv;
|
||||
});
|
||||
|
||||
async function expectPromptStop(stop = () => worker.stop()) {
|
||||
let done = false;
|
||||
const stopped = stop().then(() => { done = true; });
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(done).toBe(true);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
await stopped;
|
||||
}
|
||||
|
||||
it('wakes all idle workers and the minute-long janitor through stopServices', async () => {
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(jest.getTimerCount()).toBe(3);
|
||||
// Confirm normal polling still runs before shutdown.
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
expect(db.transaction).toHaveBeenCalledTimes(4);
|
||||
await expectPromptStop(() => require('../../src/services/serviceShutdown').stopServices());
|
||||
});
|
||||
|
||||
it('wakes claim-error backoff and can start a fresh run after stopping', async () => {
|
||||
db.transaction.mockRejectedValue(new Error('database unavailable'));
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
await expectPromptStop();
|
||||
db.transaction.mockResolvedValue(null);
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(jest.getTimerCount()).toBe(3);
|
||||
await expectPromptStop();
|
||||
});
|
||||
|
||||
it('drains active processing for every stop caller and prevents overlapping restarts', async () => {
|
||||
const processing = deferred();
|
||||
processPhoto.mockReturnValue(processing.promise);
|
||||
db.transaction.mockResolvedValueOnce({ id: 1 });
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(processPhoto).toHaveBeenCalledWith(1);
|
||||
let done = false;
|
||||
const first = worker.stop();
|
||||
expect(worker.stop()).toBe(first);
|
||||
first.then(() => { done = true; });
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(done).toBe(false);
|
||||
expect(db.transaction).toHaveBeenCalledTimes(2);
|
||||
processing.resolve({ status: 'skipped' });
|
||||
await expectPromptStop();
|
||||
expect(done).toBe(true);
|
||||
expect(db.transaction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('drains a claim already in flight without starting another poll', async () => {
|
||||
const claim = deferred();
|
||||
db.transaction.mockReturnValueOnce(claim.promise);
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const stopped = worker.stop();
|
||||
claim.resolve({ id: 2 });
|
||||
await expectPromptStop(() => stopped);
|
||||
expect(processPhoto).toHaveBeenCalledWith(2);
|
||||
expect(db.transaction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not schedule new waits when pending database work finishes after stop', async () => {
|
||||
const claim = deferred();
|
||||
const janitor = deferred();
|
||||
db.transaction.mockReturnValue(claim.promise);
|
||||
janitorUpdate.mockReturnValue(janitor.promise);
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
let done = false;
|
||||
const stopped = worker.stop().then(() => { done = true; });
|
||||
claim.resolve(null);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(done).toBe(false);
|
||||
janitor.resolve(0);
|
||||
await expectPromptStop(() => stopped);
|
||||
});
|
||||
|
||||
if (name === 'faceQueue') {
|
||||
it('interrupts the ten-second sleep with faces disabled by default', async () => {
|
||||
featureEnabled.mockResolvedValue(false);
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(3);
|
||||
await expectPromptStop();
|
||||
});
|
||||
|
||||
it('releases a claimed photo and interrupts the sidecar outage backoff', async () => {
|
||||
db.transaction.mockResolvedValueOnce({ id: 3, event_id: 5 });
|
||||
processPhoto.mockRejectedValue(new SidecarUnavailableError('offline'));
|
||||
worker.start();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(janitorUpdate).toHaveBeenCalledWith({ face_status: 'pending', face_started_at: null });
|
||||
await expectPromptStop();
|
||||
expect(worker.inFlightByEvent.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not claim new work after a pending feature check resolves during shutdown', async () => {
|
||||
const feature = deferred();
|
||||
featureEnabled.mockReturnValue(feature.promise);
|
||||
worker.start();
|
||||
const stopped = worker.stop();
|
||||
feature.resolve(true);
|
||||
await expectPromptStop(() => stopped);
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Bounded, reclaimable storage reads for archiver downloads (#1399 follow-up).
|
||||
*
|
||||
* archiver drains the sources it is handed one at a time, so appending a
|
||||
* storage read per photo opens N and drains one. Every other read parks its
|
||||
* socket holding unread bytes, and nothing reclaims them: archiver's abort()
|
||||
* does not touch source streams, and the S3 SDK clears its socket timeout as
|
||||
* soon as response headers land. That is the mechanism behind the incident in
|
||||
* PR #1402 — 43 of 50 pooled sockets held, uploads starved, restart required.
|
||||
*
|
||||
* #1402 fixes the cached-zip builder. These are the guarantees the same guard
|
||||
* has to give the three remaining call sites, two of which need no admin
|
||||
* credentials to reach.
|
||||
*/
|
||||
const { Readable } = require('stream');
|
||||
const { createArchiveStreamGuard } = require('../../src/utils/archiveStreamGuard');
|
||||
|
||||
const makeStream = () => new Readable({ read() {} });
|
||||
|
||||
describe('archiveStreamGuard (#1399 follow-up)', () => {
|
||||
it('lets the configured number of reads run at once', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 2 });
|
||||
expect(await guard.acquire()).toBe(true);
|
||||
guard.track(makeStream());
|
||||
expect(await guard.acquire()).toBe(true);
|
||||
guard.track(makeStream());
|
||||
expect(guard.openCount).toBe(2);
|
||||
});
|
||||
|
||||
it('parks the next acquire until a read finishes', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
|
||||
await guard.acquire();
|
||||
const first = guard.track(makeStream());
|
||||
|
||||
let resumed = false;
|
||||
const pending = guard.acquire().then((ok) => { resumed = ok; });
|
||||
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(resumed).toBe(false); // still parked — this is the cap doing its job
|
||||
|
||||
first.push(null);
|
||||
first.resume();
|
||||
await pending;
|
||||
expect(resumed).toBe(true);
|
||||
});
|
||||
|
||||
it('releases a slot when a read errors, not just when it ends', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
|
||||
await guard.acquire();
|
||||
const stream = guard.track(makeStream());
|
||||
stream.on('error', () => {});
|
||||
stream.destroy(new Error('socket died'));
|
||||
// Without the error listener the slot would never come back and the next
|
||||
// photo would park forever.
|
||||
expect(await guard.acquire()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a failed read so the caller can abort the archive', async () => {
|
||||
// A stream that errors while still QUEUED has no archiver listener on it
|
||||
// yet. Releasing its slot and saying nothing leaves a dead stream in the
|
||||
// queue, and the archive hangs when it reaches it.
|
||||
const seen = [];
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 2, onFatalError: (e) => seen.push(e) });
|
||||
await guard.acquire();
|
||||
const queued = guard.track(makeStream());
|
||||
queued.on('error', () => {});
|
||||
queued.destroy(new Error('socket died'));
|
||||
await new Promise((r) => setImmediate(r)); // 'error' lands on the next tick
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].message).toBe('socket died');
|
||||
});
|
||||
|
||||
it('stays quiet about reads it destroyed itself', async () => {
|
||||
// destroyAll is the caller's own teardown; reporting those back as fatal
|
||||
// would re-enter the abort path it is already running.
|
||||
const seen = [];
|
||||
const guard = createArchiveStreamGuard({ onFatalError: (e) => seen.push(e) });
|
||||
await guard.acquire();
|
||||
const s1 = guard.track(makeStream());
|
||||
s1.on('error', () => {});
|
||||
guard.destroyAll();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('destroys every read still holding bytes', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 5 });
|
||||
const streams = [makeStream(), makeStream(), makeStream()];
|
||||
for (const s of streams) { await guard.acquire(); guard.track(s); }
|
||||
expect(guard.openCount).toBe(3);
|
||||
|
||||
guard.destroyAll();
|
||||
expect(streams.every((s) => s.destroyed)).toBe(true);
|
||||
expect(guard.openCount).toBe(0);
|
||||
});
|
||||
|
||||
it('wakes a parked acquire on destroyAll so the loop can exit', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
|
||||
await guard.acquire();
|
||||
guard.track(makeStream());
|
||||
|
||||
const pending = guard.acquire();
|
||||
guard.destroyAll();
|
||||
// false, so the caller breaks out instead of appending to a dead archive.
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
|
||||
it('destroys a stream tracked after shutdown rather than leaking it', () => {
|
||||
const guard = createArchiveStreamGuard();
|
||||
guard.destroyAll();
|
||||
const late = guard.track(makeStream());
|
||||
expect(late.destroyed).toBe(true);
|
||||
expect(guard.openCount).toBe(0);
|
||||
});
|
||||
|
||||
it('tolerates destroyAll twice — exit paths overlap', () => {
|
||||
const guard = createArchiveStreamGuard();
|
||||
guard.track(makeStream());
|
||||
guard.destroyAll();
|
||||
expect(() => guard.destroyAll()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ describe('resolvePhotoContentType', () => {
|
||||
});
|
||||
|
||||
describe('serving routes use the resolver', () => {
|
||||
const routes = ['gallery.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
|
||||
const routes = ['gallery/media.js', 'gallery/downloads.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
|
||||
it.each(routes)('%s sets no Content-Type from photo.mime_type directly', (name) => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '../../src/routes', name), 'utf8');
|
||||
expect(src).not.toMatch(/'Content-Type':\s*photo\.mime_type/);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
const dns = require('dns');
|
||||
const http = require('http');
|
||||
const axios = require('axios');
|
||||
const { validateExternalUrlAsync } = require('../../src/utils/networkValidation');
|
||||
const { pinnedRequestOptions } = require('../../src/utils/pinnedRequest');
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
it('never performs a second DNS lookup that could reach a private listener', async () => {
|
||||
const received = jest.fn();
|
||||
const server = http.createServer((req, res) => { received(); res.end('private'); });
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const url = `http://rebind.example:${server.address().port}/hook`;
|
||||
const preflight = jest.spyOn(dns.promises, 'lookup').mockResolvedValue([{ address: '192.0.2.1', family: 4 }]);
|
||||
const unsafeLookup = jest.spyOn(dns, 'lookup').mockImplementation((_host, opts, cb) => {
|
||||
if (typeof opts === 'function') { cb = opts; opts = {}; }
|
||||
cb(null, ...(opts.all ? [[{ address: '127.0.0.1', family: 4 }]] : ['127.0.0.1', 4]));
|
||||
});
|
||||
let options;
|
||||
try {
|
||||
const check = await validateExternalUrlAsync(url);
|
||||
options = pinnedRequestOptions(check);
|
||||
await expect(axios.post(url, 'private-data', { ...options, timeout: 200 })).rejects.toThrow();
|
||||
expect(preflight).toHaveBeenCalledTimes(1);
|
||||
expect(unsafeLookup).not.toHaveBeenCalled(); expect(received).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
options?.httpAgent.destroy(); options?.httpsAgent.destroy();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
it('preserves the original Host and refuses redirects while using the pinned address', async () => {
|
||||
const hosts = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
hosts.push(req.headers.host); res.writeHead(302, { Location: 'http://localhost/private' }); res.end();
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const host = `pinned.example:${server.address().port}`;
|
||||
// Test the transport in isolation: production checks reject this private IP.
|
||||
const options = pinnedRequestOptions({ valid: true, hostname: 'pinned.example', addresses: [{ address: '127.0.0.1', family: 4 }] });
|
||||
try {
|
||||
const res = await axios.post(`http://${host}/hook`, 'body', { ...options, validateStatus: () => true });
|
||||
expect(res.status).toBe(302); expect(hosts).toEqual([host]); expect(options.proxy).toBe(false);
|
||||
} finally { options.httpAgent.destroy(); options.httpsAgent.destroy(); await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
it('fails closed for missing DNS results', () => {
|
||||
expect(() => pinnedRequestOptions({ valid: true })).toThrow('validated destination');
|
||||
});
|
||||
|
||||
it('preserves TLS SNI and certificate hostname verification for a pinned connection', async () => {
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const dir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'picpeak-tls-pin-'));
|
||||
const key = path.join(dir, 'key.pem'), cert = path.join(dir, 'cert.pem');
|
||||
require('child_process').execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes',
|
||||
'-keyout', key, '-out', cert, '-days', '1', '-subj', '/CN=pinned.example',
|
||||
'-addext', 'subjectAltName=DNS:pinned.example'], { stdio: 'ignore' });
|
||||
const certificate = await fs.readFile(cert);
|
||||
const seen = [];
|
||||
const server = require('https').createServer({ key: await fs.readFile(key), cert: certificate }, (req, res) => {
|
||||
seen.push({ host: req.headers.host, servername: req.socket.servername }); res.end('ok');
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const makeOptions = hostname => {
|
||||
const options = pinnedRequestOptions({ valid: true, hostname, addresses: [{ address: '127.0.0.1', family: 4 }] });
|
||||
options.httpsAgent.options.ca = certificate;
|
||||
return options;
|
||||
};
|
||||
const allowed = makeOptions('pinned.example'), wrong = makeOptions('wrong.example');
|
||||
try {
|
||||
const host = `pinned.example:${server.address().port}`;
|
||||
expect((await axios.post(`https://${host}/hook`, 'data', { ...allowed, timeout: 2000 })).status).toBe(200);
|
||||
expect(seen).toEqual([{ host, servername: 'pinned.example' }]);
|
||||
await expect(axios.post(`https://wrong.example:${server.address().port}/hook`, 'data', { ...wrong, timeout: 2000 }))
|
||||
.rejects.toMatchObject({ code: 'ERR_TLS_CERT_ALTNAME_INVALID' });
|
||||
expect(seen).toHaveLength(1);
|
||||
} finally {
|
||||
for (const options of [allowed, wrong]) { options.httpAgent.destroy(); options.httpsAgent.destroy(); }
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const EventEmitter = require('events');
|
||||
jest.mock('../../src/utils/logger', () => ({ info: jest.fn() }));
|
||||
const logger = require('../../src/utils/logger');
|
||||
const middleware = require('../../src/middleware/apiRequestLogger');
|
||||
const { requestLogPath } = require('../../src/utils/requestLogPath');
|
||||
const marker = 'SECRET_TEST_CAPABILITY';
|
||||
it.each([
|
||||
`/api/gallery/g/photos?token=${marker}&password=${marker}`,
|
||||
`/api/gallery/g/verify-token/${marker}`,
|
||||
`/api/gallery/g/show/${marker}/state`,
|
||||
`/api/images/g/photo/1/signed/${marker}`,
|
||||
`/api/secure-images/g/secure/1/${marker}`,
|
||||
`/api/secure-images/g/secure-download/1/${marker}`,
|
||||
`/api/public/contracts/${marker}/sign`,
|
||||
`/api/customer/auth/password-reset/${marker}`,
|
||||
`/api/public/newsletter/unsubscribe/${marker}`,
|
||||
])('does not log capabilities on request or response: %s', (originalUrl) => {
|
||||
logger.info.mockClear();
|
||||
const res = new EventEmitter(); res.statusCode = 200;
|
||||
middleware({ originalUrl, method: 'GET' }, res, jest.fn());
|
||||
res.emit('finish');
|
||||
expect(logger.info).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(marker);
|
||||
});
|
||||
it('retains useful non-secret routes and removes control characters', () => {
|
||||
expect(requestLogPath('/api/admin/events/12?search=private')).toBe('/api/admin/events/12');
|
||||
expect(requestLogPath('/api/admin/events\nforged')).not.toContain('\n');
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
const http = require('http');
|
||||
it('directs Supertest to the actual IPv6 listener instead of an unrelated IPv4 port', async () => {
|
||||
jest.resetModules();
|
||||
const request = require('supertest');
|
||||
const server = http.createServer((_req, res) => { res.end('actual test listener'); });
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '::1', resolve); });
|
||||
try {
|
||||
const response = await request(server).get('/');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('actual test listener');
|
||||
expect(response.request.url).toContain('://[::1]:');
|
||||
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
@@ -20,7 +20,7 @@ jest.mock('../../src/database/db', () => {
|
||||
const dbFn = () => ({
|
||||
insert(row) {
|
||||
inserted.push(row);
|
||||
return { onConflict: () => ({ ignore: async () => undefined }) };
|
||||
return { onConflict: () => ({ ignore: async () => undefined, merge: async () => undefined }) };
|
||||
},
|
||||
});
|
||||
return { db: dbFn };
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
// Supertest 6 binds an IPv6 wildcard listener but hardcodes an IPv4 URL.
|
||||
// macOS can allocate that IPv6 port while a different IPv4 service owns it.
|
||||
// Address the listener's actual family so a test cannot reach that service.
|
||||
jest.mock('supertest/lib/test', () => {
|
||||
const Test = jest.requireActual('supertest/lib/test');
|
||||
const serverAddress = Test.prototype.serverAddress;
|
||||
Test.prototype.serverAddress = function(app, path) {
|
||||
const url = serverAddress.call(this, app, path);
|
||||
return app.address()?.family === 'IPv6'
|
||||
? url.replace('://127.0.0.1:', '://[::1]:')
|
||||
: url;
|
||||
};
|
||||
return Test;
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
@@ -8,3 +23,11 @@ beforeAll(() => {
|
||||
process.env.STORAGE_PATH = '/storage';
|
||||
}
|
||||
});
|
||||
|
||||
// Dispose resources loaded by this suite using the application's draining
|
||||
// shutdown. Individual fixtures still own temporary files and other DB pools.
|
||||
afterAll(async () => {
|
||||
await require('./src/services/serviceShutdown').stopServices();
|
||||
const loadedDb = require.cache[require.resolve('./src/database/db')];
|
||||
if (typeof loadedDb?.exports.db?.destroy === 'function') await loadedDb.exports.db.destroy();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Fresh installs and upgraded databases expose the same event timestamp. */
|
||||
exports.up = async function (knex) {
|
||||
if (!await knex.schema.hasTable('events')) return;
|
||||
if (!await knex.schema.hasColumn('events', 'updated_at')) {
|
||||
await knex.schema.alterTable('events', table => {
|
||||
table.timestamp('updated_at');
|
||||
});
|
||||
}
|
||||
// Do not replace existing modification times on a repeated migration.
|
||||
await knex('events').whereNull('updated_at').update({ updated_at: knex.ref('created_at') });
|
||||
};
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasTable('events') && await knex.schema.hasColumn('events', 'updated_at')) {
|
||||
await knex.schema.alterTable('events', table => table.dropColumn('updated_at'));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Non-expiring JWTs need revocation records that cleanup never removes. */
|
||||
exports.up = async function (knex) {
|
||||
if (!await knex.schema.hasTable('revoked_tokens')) return;
|
||||
if (!await knex.schema.hasColumn('revoked_tokens', 'expires_at')) return;
|
||||
const column = await knex('revoked_tokens').columnInfo('expires_at');
|
||||
if (!column.nullable) {
|
||||
await knex.schema.alterTable('revoked_tokens', table => {
|
||||
table.timestamp('expires_at').nullable().alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!await knex.schema.hasTable('revoked_tokens')) return;
|
||||
if (!await knex.schema.hasColumn('revoked_tokens', 'expires_at')) return;
|
||||
// Refuse to discard permanent revocations or silently give them a TTL.
|
||||
if (await knex('revoked_tokens').whereNull('expires_at').first()) {
|
||||
throw new Error('Cannot roll back while permanent token revocations exist');
|
||||
}
|
||||
const column = await knex('revoked_tokens').columnInfo('expires_at');
|
||||
if (column.nullable) {
|
||||
await knex.schema.alterTable('revoked_tokens', table => {
|
||||
table.timestamp('expires_at').notNullable().alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// Tracks whether this installation has ever been offered the one-time
|
||||
// usage-reporting opt-in prompt shown to an existing admin on their first
|
||||
// login after an update (see UsageService.markPromptShown()). A fresh
|
||||
// install that went through the setup wizard's own opt-in step sets this
|
||||
// too, so upgraded and brand-new installs share one "already asked" marker
|
||||
// and neither gets asked twice. Separate from `notice_dismissed`, which
|
||||
// governs the persistent, re-visitable dashboard banner instead.
|
||||
const { formatBoolean } = require('../../src/utils/dbCompat');
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||
if (!(await knex.schema.hasColumn('product_usage_state', 'prompt_shown'))) {
|
||||
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||
t.boolean('prompt_shown').notNullable().defaultTo(false);
|
||||
});
|
||||
}
|
||||
// Existing participants already made their choice before this marker
|
||||
// existed. Preserve it through withdrawal, pending delivery and identity
|
||||
// recovery; none of those transitions should produce a fresh invitation.
|
||||
await knex('product_usage_state')
|
||||
.whereNot('status', 'disabled')
|
||||
.update({ prompt_shown: formatBoolean(true) });
|
||||
};
|
||||
exports.down = async function (knex) {
|
||||
if (
|
||||
(await knex.schema.hasTable('product_usage_state')) &&
|
||||
(await knex.schema.hasColumn('product_usage_state', 'prompt_shown'))
|
||||
) {
|
||||
await knex.schema.alterTable('product_usage_state', (t) => t.dropColumn('prompt_shown'));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Migration 213: TOTP replay protection for admin MFA (GHSA-qcwx-r25m-j869).
|
||||
*
|
||||
* verifyTotp()/verifyTotpEncrypted() were stateless: otplib's window:1
|
||||
* tolerance means a captured 6-digit code stays valid across several real
|
||||
* time-steps (~90s), so the same code could complete two independent admin
|
||||
* logins. `two_factor_last_used_step` tracks, per admin, the absolute TOTP
|
||||
* time-step (Math.floor(Date.now() / 30000)) that their last successfully
|
||||
* consumed code matched; mfaService now rejects a code whose matched step
|
||||
* doesn't advance past it.
|
||||
*
|
||||
* Additive and idempotent: only adds a column, guarded by hasColumn, so it
|
||||
* is safe to re-run and touches no existing data.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('admin_users', 'two_factor_last_used_step'))) {
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
t.integer('two_factor_last_used_step').nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasColumn('admin_users', 'two_factor_last_used_step')) {
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
t.dropColumn('two_factor_last_used_step');
|
||||
});
|
||||
}
|
||||
};
|
||||
Generated
+135
-135
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.123.0-beta.0",
|
||||
"version": "3.131.3-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.123.0-beta.0",
|
||||
"version": "3.131.3-beta.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -39,7 +39,7 @@
|
||||
"knex": "^2.4.2",
|
||||
"mailparser": "^3.9.9",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "2.2.0",
|
||||
"multer": "2.3.0",
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
@@ -53,7 +53,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "2.17.7",
|
||||
"sharp": "0.35.3",
|
||||
"sharp": "0.35.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
@@ -1716,9 +1716,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1734,13 +1734,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1756,20 +1756,20 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -1779,9 +1779,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1795,9 +1795,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1811,9 +1811,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1827,9 +1827,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1843,9 +1843,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1859,9 +1859,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1875,9 +1875,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1891,9 +1891,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1907,9 +1907,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1923,9 +1923,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1939,9 +1939,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1957,13 +1957,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1979,13 +1979,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2001,13 +2001,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2023,13 +2023,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2045,13 +2045,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2067,13 +2067,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2089,13 +2089,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2111,17 +2111,17 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
"@emnapi/runtime": "^1.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -2131,16 +2131,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -2150,9 +2150,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2169,9 +2169,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2188,9 +2188,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8084,9 +8084,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "17.13.4",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz",
|
||||
"integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==",
|
||||
"version": "17.13.7",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.7.tgz",
|
||||
"integrity": "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^9.3.0",
|
||||
@@ -8120,9 +8120,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9195,9 +9195,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
|
||||
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -9398,9 +9398,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
||||
"version": "9.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
|
||||
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -11258,9 +11258,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
@@ -11274,31 +11274,31 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
"@img/sharp-darwin-arm64": "0.35.4",
|
||||
"@img/sharp-darwin-x64": "0.35.4",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||
"@img/sharp-linux-arm": "0.35.4",
|
||||
"@img/sharp-linux-arm64": "0.35.4",
|
||||
"@img/sharp-linux-ppc64": "0.35.4",
|
||||
"@img/sharp-linux-riscv64": "0.35.4",
|
||||
"@img/sharp-linux-s390x": "0.35.4",
|
||||
"@img/sharp-linux-x64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||
"@img/sharp-win32-arm64": "0.35.4",
|
||||
"@img/sharp-win32-ia32": "0.35.4",
|
||||
"@img/sharp-win32-x64": "0.35.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.130.0-beta.0",
|
||||
"version": "3.131.7-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
@@ -49,7 +49,7 @@
|
||||
"knex": "^2.4.2",
|
||||
"mailparser": "^3.9.9",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "2.2.0",
|
||||
"multer": "2.3.0",
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
@@ -63,7 +63,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "2.17.7",
|
||||
"sharp": "0.35.3",
|
||||
"sharp": "0.35.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
|
||||
@@ -36,6 +36,7 @@ const MFA_CLEAR = {
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
two_factor_last_used_step: null,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
|
||||
+59
-48
@@ -218,7 +218,7 @@ app.use((req, res, next) => {
|
||||
});
|
||||
|
||||
// CORS configuration (apply only to API routes)
|
||||
const { isAllowedOrigin, multipartOriginAllowed } = require('./src/utils/requestOrigin');
|
||||
const { isAllowedOrigin } = require('./src/utils/requestOrigin');
|
||||
|
||||
const corsOptions = {
|
||||
origin: function (origin, callback) {
|
||||
@@ -528,43 +528,10 @@ app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||||
|
||||
// CSRF protection: require JSON Content-Type on mutating API requests
|
||||
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
||||
// Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls
|
||||
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
|
||||
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
|
||||
}
|
||||
// multipart is exactly what a cross-site <form> can send without a
|
||||
// preflight, and in a split-origin deployment (SameSite=None) the admin
|
||||
// cookie rides along to the upload routes. Browsers label such a
|
||||
// submission Sec-Fetch-Site: cross-site (and always send Origin on a
|
||||
// cross-origin POST); non-browser clients send neither header and pass.
|
||||
if (contentType.includes('multipart/form-data') && !multipartOriginAllowed(req)) {
|
||||
return res.status(403).json({ error: 'Cross-site multipart request rejected' });
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
// Validate the origin independently of body length/content type.
|
||||
app.use('/api', require('./src/middleware/csrf'));
|
||||
|
||||
// Request logging for API routes (with timestamps)
|
||||
const apiRequestLogger = (req, res, next) => {
|
||||
try {
|
||||
const started = Date.now();
|
||||
const ts = new Date().toISOString();
|
||||
logger.info(`[${ts}] ${req.method} ${req.originalUrl}`);
|
||||
res.on('finish', () => {
|
||||
const ms = Date.now() - started;
|
||||
const tsDone = new Date().toISOString();
|
||||
logger.info(`[${tsDone}] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
|
||||
});
|
||||
} catch (_) {}
|
||||
next();
|
||||
};
|
||||
app.use('/api', apiRequestLogger);
|
||||
app.use('/api', require('./src/middleware/apiRequestLogger'));
|
||||
|
||||
// Maintenance mode middleware - add after body parsing but before routes
|
||||
app.use(maintenanceMiddleware);
|
||||
@@ -1098,6 +1065,30 @@ if (spaCatchAll) {
|
||||
// Global error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
// App construction is side-effect free with respect to listening and workers.
|
||||
let httpServer;
|
||||
let shutdownPromise;
|
||||
// Docker stops a container 10 s after SIGTERM by default (compose sets no
|
||||
// stop_grace_period), so the drain must finish inside that window.
|
||||
const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 8000;
|
||||
async function stopServer() {
|
||||
if (shutdownPromise) return shutdownPromise;
|
||||
shutdownPromise = (async () => {
|
||||
const close = httpServer ? new Promise((resolve, reject) => httpServer.close(err => err ? reject(err) : resolve())) : Promise.resolve();
|
||||
const timeout = setTimeout(() => httpServer?.closeAllConnections(), Math.floor(SHUTDOWN_TIMEOUT_MS / 2));
|
||||
timeout.unref();
|
||||
try {
|
||||
await Promise.all([close, require('./src/services/serviceShutdown').stopServices()]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
// Always release the pool: a rejected service stop must not leave
|
||||
// ref'd sockets keeping the process alive until SIGKILL.
|
||||
await db.destroy();
|
||||
}
|
||||
})();
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
async function startServer() {
|
||||
try {
|
||||
@@ -1122,14 +1113,8 @@ async function startServer() {
|
||||
const { initializeCleanupJob } = require('./src/utils/authSecurity');
|
||||
initializeCleanupJob();
|
||||
|
||||
// Initialize temp upload cleanup job
|
||||
const { cleanupTempUploads } = require('./src/utils/cleanupTempUploads');
|
||||
// Run cleanup on startup
|
||||
cleanupTempUploads();
|
||||
// Schedule periodic cleanup every hour
|
||||
setInterval(cleanupTempUploads, 60 * 60 * 1000);
|
||||
logger.info('Temp upload cleanup scheduled');
|
||||
|
||||
require('./src/utils/cleanupTempUploads').startTempUploadCleanup();
|
||||
|
||||
// Start file watcher
|
||||
startFileWatcher();
|
||||
// External-media folder watcher (issue 1187): imports new files into
|
||||
@@ -1149,6 +1134,10 @@ async function startServer() {
|
||||
startTransferCleanup();
|
||||
// Custom-resolution download archives (#858) are disposable renditions —
|
||||
// sweep them once their TTL passes so .download-cache doesn't grow forever.
|
||||
// Best-effort, as before the scheduler refactor: a transient DB error on
|
||||
// this one UPDATE must not abort the whole server start.
|
||||
await require('./src/services/downloadJobService').recoverOrphanedJobs()
|
||||
.catch((err) => logger.error('Download job recovery failed', { error: err.message }));
|
||||
startDownloadJobCleanup();
|
||||
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
|
||||
startRevealScheduler();
|
||||
@@ -1306,7 +1295,7 @@ async function startServer() {
|
||||
// lazy means they don't pay for a module graph they never use.
|
||||
require('./src/services/faceQueue').start();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
httpServer = app.listen(PORT, () => {
|
||||
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'}`);
|
||||
@@ -1325,10 +1314,32 @@ async function startServer() {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
await stopServer();
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
if (require.main === module) {
|
||||
let stopping = false;
|
||||
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||
process.on(signal, () => {
|
||||
if (stopping) {
|
||||
logger.warn(`Received ${signal} again during shutdown, exiting immediately`);
|
||||
process.exit(1);
|
||||
}
|
||||
stopping = true;
|
||||
// The drain itself has no deadline; a hung worker must not keep the
|
||||
// process alive past the container's stop grace period.
|
||||
setTimeout(() => {
|
||||
logger.error(`Shutdown exceeded ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
|
||||
process.exit(1);
|
||||
}, SHUTDOWN_TIMEOUT_MS).unref();
|
||||
stopServer().catch(error => { logger.error('Shutdown failed', { error: error.message }); process.exitCode = 1; });
|
||||
});
|
||||
}
|
||||
startServer();
|
||||
}
|
||||
app.startServer = startServer;
|
||||
app.stopServer = stopServer;
|
||||
|
||||
module.exports = app; // For testing
|
||||
|
||||
@@ -26,6 +26,8 @@ jest.mock('../database/db', () => {
|
||||
return { db: mockDb, withRetry };
|
||||
});
|
||||
|
||||
jest.mock('../utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
|
||||
jest.mock('../utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn().mockResolvedValue(false) }));
|
||||
jest.mock('../utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
@@ -83,8 +85,12 @@ function mockEventAndAssignment({ event, assignment }) {
|
||||
assignChain.where = jest.fn().mockReturnValue(assignChain);
|
||||
assignChain.first = jest.fn().mockResolvedValue(assignment);
|
||||
|
||||
db.mockImplementationOnce(() => eventsChain)
|
||||
.mockImplementationOnce(() => assignChain);
|
||||
db.mockImplementation((table) => {
|
||||
if (table === 'events') return eventsChain;
|
||||
if (table === 'customer_accounts') return { ...eventsChain, first: jest.fn().mockResolvedValue({ id: 7 }) };
|
||||
if (table === 'event_customer_assignments') return assignChain;
|
||||
throw new Error('Unexpected table: ' + table);
|
||||
});
|
||||
|
||||
return { eventsChain, assignChain };
|
||||
}
|
||||
@@ -101,7 +107,7 @@ describe('verifyGalleryAccess — customer-minted JWT with active assignment', (
|
||||
it('allows access when the event_customer_assignments row exists', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
type: 'gallery', iat: Math.floor(Date.now() / 1000),
|
||||
eventId: 42,
|
||||
via: 'customer',
|
||||
customerId: 7,
|
||||
@@ -132,7 +138,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
|
||||
it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
type: 'gallery', iat: Math.floor(Date.now() / 1000),
|
||||
eventId: 42,
|
||||
via: 'customer',
|
||||
customerId: 7,
|
||||
@@ -162,7 +168,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
|
||||
// and start 403'ing per-event-password sessions.
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
type: 'gallery', iat: Math.floor(Date.now() / 1000),
|
||||
eventId: 42,
|
||||
customerId: 7,
|
||||
// intentionally no `via` claim
|
||||
@@ -194,7 +200,7 @@ describe('verifyGalleryAccess — per-event-password JWT', () => {
|
||||
it('does NOT touch event_customer_assignments and passes through', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
type: 'gallery', iat: Math.floor(Date.now() / 1000),
|
||||
eventId: 42,
|
||||
// No via, no customerId — this is the legacy per-event-password
|
||||
// flow where every guest mints their own JWT after entering the
|
||||
|
||||
@@ -120,7 +120,14 @@ const createPhotoUploader = (options = {}) => {
|
||||
files: options.maxFiles || 2000,
|
||||
fieldSize: 10 * 1024 * 1024,
|
||||
parts: 10000,
|
||||
headerPairs: 2000
|
||||
headerPairs: 2000,
|
||||
// CVE-2026-82333: no preset in this factory is currently wired up to
|
||||
// a route (nothing imports createPhotoUploader et al. — routes build
|
||||
// their own multer instances directly), but every preset gets the
|
||||
// limit anyway so it can't be adopted later without it. None of the
|
||||
// uploaders this factory builds have a legitimate use for
|
||||
// array-indexed field names.
|
||||
fieldArrayIndexLimit: 0
|
||||
},
|
||||
fileFilter: createFileFilter(ALLOWED_TYPES.media, {
|
||||
validateMagicNumbers: true
|
||||
@@ -146,7 +153,8 @@ const createLogoUploader = (options = {}) => {
|
||||
}
|
||||
}),
|
||||
limits: {
|
||||
fileSize: options.maxSize || SIZE_LIMITS.medium
|
||||
fileSize: options.maxSize || SIZE_LIMITS.medium,
|
||||
fieldArrayIndexLimit: 0 // CVE-2026-82333 — see createPhotoUploader comment
|
||||
},
|
||||
fileFilter: createFileFilter(ALLOWED_TYPES.logos, {
|
||||
skipMagicValidation: ['image/svg+xml']
|
||||
@@ -172,7 +180,8 @@ const createFaviconUploader = (options = {}) => {
|
||||
}
|
||||
}),
|
||||
limits: {
|
||||
fileSize: options.maxSize || SIZE_LIMITS.small
|
||||
fileSize: options.maxSize || SIZE_LIMITS.small,
|
||||
fieldArrayIndexLimit: 0 // CVE-2026-82333 — see createPhotoUploader comment
|
||||
},
|
||||
fileFilter: createFileFilter(ALLOWED_TYPES.favicons, {
|
||||
skipMagicValidation: ['image/x-icon', 'image/vnd.microsoft.icon']
|
||||
@@ -194,7 +203,8 @@ const createGalleryUploader = (destDir, options = {}) => {
|
||||
dest: destDir,
|
||||
limits: {
|
||||
fileSize: options.maxSize || SIZE_LIMITS.large,
|
||||
files: options.maxFiles || 10
|
||||
files: options.maxFiles || 10,
|
||||
fieldArrayIndexLimit: 0 // CVE-2026-82333 — see createPhotoUploader comment
|
||||
},
|
||||
fileFilter: createFileFilter(ALLOWED_TYPES.photos)
|
||||
};
|
||||
|
||||
@@ -421,7 +421,7 @@ async function initializeDatabase() {
|
||||
table.integer('user_id').nullable(); // User who owned the token
|
||||
table.string('token_type', 20); // admin, gallery, etc.
|
||||
table.timestamp('revoked_at').defaultTo(db.fn.now());
|
||||
table.timestamp('expires_at').notNullable(); // When token would have expired
|
||||
table.timestamp('expires_at').nullable(); // NULL retains tokens without a known expiry
|
||||
table.string('reason', 100); // password_change, logout, compromised, etc.
|
||||
table.text('metadata'); // Additional JSON data
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const logger = require('../utils/logger');
|
||||
const { requestLogPath } = require('../utils/requestLogPath');
|
||||
module.exports = function apiRequestLogger(req, res, next) {
|
||||
const started = Date.now();
|
||||
const path = requestLogPath(req.originalUrl);
|
||||
logger.info(`${req.method} ${path}`);
|
||||
res.once('finish', () => {
|
||||
logger.info(`${req.method} ${path} -> ${res.statusCode} (${Date.now() - started}ms)`);
|
||||
});
|
||||
next();
|
||||
};
|
||||
+33
-100
@@ -1,12 +1,21 @@
|
||||
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 { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
|
||||
const sessionAccess = require('../services/sessionAccessService');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
// GHSA-h4w8-57xq-53fx: must_change_password was written on reset (and on
|
||||
// invitation/OIDC-bypass paths) but nothing server-side ever checked it — a
|
||||
// forced-reset admin could keep using the old/weak password indefinitely
|
||||
// because the flag only ever reached the frontend as a response field. The
|
||||
// frontend already renders a blocking modal for it (MandatoryPasswordChangeModal),
|
||||
// this is the backstop for callers that skip the UI entirely. Every route
|
||||
// gated by adminAuth() is blocked except the ones a flagged admin needs to
|
||||
// clear the flag or leave: change their password, and log out.
|
||||
const MUST_CHANGE_PASSWORD_EXEMPT_PATHS = new Set([
|
||||
'/api/admin/auth/change-password',
|
||||
'/api/admin/auth/logout',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Enhanced admin authentication middleware with revocation checking
|
||||
*/
|
||||
@@ -16,7 +25,7 @@ async function adminAuth(req, res, next) {
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
@@ -31,101 +40,21 @@ async function adminAuth(req, res, next) {
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Check if token is revoked
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
logger.warn('Revoked token used', {
|
||||
userId: decoded.id,
|
||||
tokenType: decoded.type
|
||||
});
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
|
||||
// includeProfile: true — need must_change_password for the enforcement
|
||||
// check below on every request, not just the profile/session-check routes.
|
||||
const admin = await sessionAccess.admin(decoded, { includeProfile: true });
|
||||
const requestIp = req.ip || req.connection?.remoteAddress;
|
||||
if (decoded.ip && requestIp && decoded.ip !== requestIp) {
|
||||
logger.info('admin session IP changed', { accountId: admin.id, tokenIp: decoded.ip, requestIp });
|
||||
}
|
||||
|
||||
// Reject any session issued before the global cutoff (set by a .picpeak
|
||||
// restore, which can reassign admin ids). Forces every pre-restore admin
|
||||
// session to re-authenticate against the restored data.
|
||||
if (await isTokenBeforeCutoff(decoded)) {
|
||||
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
|
||||
}
|
||||
|
||||
// Verify token type
|
||||
if (decoded.type !== 'admin') {
|
||||
logger.warn('Non-admin token used for admin endpoint', {
|
||||
userId: decoded.id,
|
||||
tokenType: decoded.type
|
||||
if (admin.must_change_password
|
||||
&& !MUST_CHANGE_PASSWORD_EXEMPT_PATHS.has(req.originalUrl.split('?')[0])) {
|
||||
return res.status(403).json({
|
||||
error: 'Password change required before continuing',
|
||||
code: 'MUST_CHANGE_PASSWORD'
|
||||
});
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
// IP validation (optional - can be strict or just log)
|
||||
const currentIp = req.ip || req.connection.remoteAddress;
|
||||
if (decoded.ip && decoded.ip !== currentIp) {
|
||||
logger.warn('Token used from different IP', {
|
||||
userId: decoded.id,
|
||||
tokenIp: decoded.ip,
|
||||
currentIp: currentIp
|
||||
});
|
||||
}
|
||||
|
||||
// Check if admin still exists and is active, including role info
|
||||
// Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario)
|
||||
let admin;
|
||||
try {
|
||||
admin = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
|
||||
.select(
|
||||
'admin_users.id',
|
||||
'admin_users.username',
|
||||
'admin_users.email',
|
||||
'admin_users.password_changed_at',
|
||||
'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 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 every other transient DB fault
|
||||
// in this try block behaves (isTokenRevoked / isTokenBeforeCutoff both
|
||||
// hit 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 });
|
||||
admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.select('id', 'username', 'email', 'password_changed_at')
|
||||
.first();
|
||||
if (admin) {
|
||||
admin.role_id = null;
|
||||
admin.role_name = 'super_admin'; // Assume super_admin for existing users during upgrade
|
||||
}
|
||||
}
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Check if password was changed after token was issued. JWT `iat` has
|
||||
// 1-second resolution; `password_changed_at` is sub-second. Floor the
|
||||
// comparison so a token issued in the *same* second as the password
|
||||
// change isn't incorrectly rejected — that race used to bite anyone
|
||||
// logging in immediately after a password reset/change.
|
||||
if (admin.password_changed_at) {
|
||||
const passwordChangedSeconds = Math.floor(
|
||||
new Date(admin.password_changed_at).getTime() / 1000
|
||||
);
|
||||
if (decoded.iat < passwordChangedSeconds) {
|
||||
logger.warn('Token used after password change', { userId: decoded.id });
|
||||
return res.status(401).json({
|
||||
error: 'Token invalid due to password change',
|
||||
code: 'PASSWORD_CHANGED'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add user info to request (enhanced with role)
|
||||
@@ -135,6 +64,7 @@ async function adminAuth(req, res, next) {
|
||||
email: admin.email,
|
||||
roleId: admin.role_id,
|
||||
roleName: admin.role_name,
|
||||
mustChangePassword: !!admin.must_change_password,
|
||||
// From the token, not the database: it is a property of this session
|
||||
// rather than of the account (#1186). Carried so a route that reissues
|
||||
// the token — change-password — can preserve the choice instead of
|
||||
@@ -142,11 +72,14 @@ async function adminAuth(req, res, next) {
|
||||
rememberMe: decoded.rememberMe === true
|
||||
};
|
||||
req.token = token; // Store token for potential revocation
|
||||
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
res.status(error.statusCode || 401).json({
|
||||
error: error.isOperational ? error.message : 'Authentication failed',
|
||||
...(error.isOperational && { code: error.code }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const { mutationOriginAllowed } = require('../utils/requestOrigin');
|
||||
|
||||
// The origin check is the CSRF defence. This list only has to keep out what
|
||||
// a cross-site page can send without a preflight: a form cannot produce JSON
|
||||
// or octet-stream, and fetch() with either is not CORS-safelisted.
|
||||
// octet-stream is how the chunked upload route receives its raw body
|
||||
// (#1377); express.json leaves it unread for everything else.
|
||||
const ALLOWED_CONTENT_TYPES = ['application/json', 'multipart/form-data', 'application/octet-stream'];
|
||||
|
||||
module.exports = function csrfProtection(req, res, next) {
|
||||
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return next();
|
||||
if (!mutationOriginAllowed(req)) {
|
||||
return res.status(403).json({ error: 'Cross-site request rejected' });
|
||||
}
|
||||
const contentType = (req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
||||
const hasBody = Number(req.headers['content-length']) > 0 || !!req.headers['transfer-encoding'];
|
||||
const allowed = ALLOWED_CONTENT_TYPES.includes(contentType) || contentType.endsWith('+json');
|
||||
if (hasBody && !allowed) {
|
||||
return res.status(415).json({ error: `Unsupported Content-Type. Use ${ALLOWED_CONTENT_TYPES.join(', ')}.` });
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
const { requestLogPath } = require('../utils/requestLogPath');
|
||||
/**
|
||||
* Customer Authentication Middleware
|
||||
*
|
||||
@@ -10,10 +11,7 @@
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
|
||||
const sessionAccess = require('../services/sessionAccessService');
|
||||
const logger = require('../utils/logger');
|
||||
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
@@ -25,7 +23,7 @@ async function customerAuth(req, res, next) {
|
||||
// normal (page polling, pre-login session probes). Bump to debug
|
||||
// for noisy investigations only.
|
||||
logger.debug('[customerAuth] no token on request', {
|
||||
url: req.originalUrl,
|
||||
url: requestLogPath(req.originalUrl),
|
||||
hasCookieHeader: !!req.headers?.cookie,
|
||||
cookieKeys: Object.keys(req.cookies || {}),
|
||||
});
|
||||
@@ -42,7 +40,7 @@ async function customerAuth(req, res, next) {
|
||||
decoded = verified.payload;
|
||||
} catch (err) {
|
||||
logger.warn('[customerAuth] jwt verification failed', {
|
||||
url: req.originalUrl,
|
||||
url: requestLogPath(req.originalUrl),
|
||||
errorName: err.name,
|
||||
errorMessage: err.message,
|
||||
});
|
||||
@@ -52,71 +50,10 @@ async function customerAuth(req, res, next) {
|
||||
return res.status(401).json({ error: 'Invalid token', code: 'JWT_INVALID' });
|
||||
}
|
||||
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
logger.warn('[customerAuth] token revoked', {
|
||||
url: req.originalUrl,
|
||||
customerId: decoded.customerId,
|
||||
tokenType: decoded.type,
|
||||
iat: decoded.iat,
|
||||
});
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// Reject sessions issued before the global restore cutoff.
|
||||
if (await isTokenBeforeCutoff(decoded)) {
|
||||
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
|
||||
}
|
||||
|
||||
if (decoded.type !== 'customer') {
|
||||
logger.warn('[customerAuth] wrong token type', {
|
||||
url: req.originalUrl,
|
||||
tokenType: decoded.type,
|
||||
});
|
||||
return res.status(403).json({ error: 'Insufficient permissions', code: 'WRONG_TOKEN_TYPE' });
|
||||
}
|
||||
|
||||
// IP drift gets logged but doesn't reject — same lenient policy as
|
||||
// adminAuth. Customers may roam between mobile networks frequently.
|
||||
const currentIp = req.ip || req.connection.remoteAddress;
|
||||
if (decoded.ip && decoded.ip !== currentIp) {
|
||||
logger.info('Customer token used from different IP', {
|
||||
customerId: decoded.customerId,
|
||||
tokenIp: decoded.ip,
|
||||
currentIp,
|
||||
});
|
||||
}
|
||||
|
||||
const customer = await db('customer_accounts')
|
||||
.where({ id: decoded.customerId, is_active: formatBoolean(true) })
|
||||
.select('id', 'email', 'display_name', 'first_name', 'last_name', 'password_changed_at', 'preferred_language')
|
||||
.first();
|
||||
|
||||
if (!customer) {
|
||||
// Either deleted, deactivated, or the id was forged. 401 across the
|
||||
// board so the frontend session-expiry handler kicks in.
|
||||
logger.warn('[customerAuth] customer row not found / inactive', {
|
||||
url: req.originalUrl,
|
||||
customerId: decoded.customerId,
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid token', code: 'CUSTOMER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
if (customer.password_changed_at) {
|
||||
const passwordChangedSeconds = Math.floor(
|
||||
new Date(customer.password_changed_at).getTime() / 1000
|
||||
);
|
||||
if (decoded.iat < passwordChangedSeconds) {
|
||||
logger.warn('[customerAuth] token rejected: password_changed_at', {
|
||||
url: req.originalUrl,
|
||||
customerId: decoded.customerId,
|
||||
iat: decoded.iat,
|
||||
passwordChangedSeconds,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: 'Token invalid due to password change',
|
||||
code: 'PASSWORD_CHANGED',
|
||||
});
|
||||
}
|
||||
const customer = await sessionAccess.customer(decoded);
|
||||
const requestIp = req.ip || req.connection?.remoteAddress;
|
||||
if (decoded.ip && requestIp && decoded.ip !== requestIp) {
|
||||
logger.info('customer session IP changed', { accountId: customer.id, tokenIp: decoded.ip, requestIp });
|
||||
}
|
||||
|
||||
req.customer = {
|
||||
@@ -131,7 +68,10 @@ async function customerAuth(req, res, next) {
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Customer auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
res.status(error.statusCode || 401).json({
|
||||
error: error.isOperational ? error.message : 'Authentication failed',
|
||||
...(error.isOperational && { code: error.code }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { requestLogPath } = require('../utils/requestLogPath');
|
||||
/**
|
||||
* Global error handler middleware.
|
||||
* Catches all errors and returns standardized responses.
|
||||
@@ -92,6 +93,16 @@ const handleKnownErrors = (err) => {
|
||||
return new ValidationError('Unexpected file field');
|
||||
}
|
||||
|
||||
// CVE-2026-82333: multer 2.3.0's fieldArrayIndexLimit rejects multipart
|
||||
// field names with an oversized bracket array index (e.g. `a[99999999]`)
|
||||
// before the DoS-prone field parser runs. Without this mapping the
|
||||
// resulting MulterError has no .statusCode/.status and falls through to
|
||||
// a 500 here, so map it to a proper 400 like the other multer limits.
|
||||
if (err.code === 'LIMIT_FIELD_ARRAY_INDEX') {
|
||||
const { ValidationError } = require('../utils/errors');
|
||||
return new ValidationError('Field name array index too large');
|
||||
}
|
||||
|
||||
return err;
|
||||
};
|
||||
|
||||
@@ -119,7 +130,7 @@ const errorHandler = (err, req, res, next) => {
|
||||
|
||||
// Log the error
|
||||
const logContext = {
|
||||
url: req.originalUrl,
|
||||
url: requestLogPath(req.originalUrl),
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
statusCode,
|
||||
@@ -161,7 +172,7 @@ const errorHandler = (err, req, res, next) => {
|
||||
*/
|
||||
const notFoundHandler = (req, res, next) => {
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
next(new NotFoundError('Route', req.originalUrl));
|
||||
next(new NotFoundError('Route', requestLogPath(req.originalUrl)));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const cleanupTimers = new Set();
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -211,7 +212,7 @@ function strictRateLimit(options = {}) {
|
||||
const store = new Map();
|
||||
|
||||
// Clean up old entries periodically
|
||||
setInterval(() => {
|
||||
const cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, data] of store.entries()) {
|
||||
if (data.resetTime < now) {
|
||||
@@ -219,6 +220,8 @@ function strictRateLimit(options = {}) {
|
||||
}
|
||||
}
|
||||
}, windowMs);
|
||||
cleanupTimer.unref();
|
||||
cleanupTimers.add(cleanupTimer);
|
||||
|
||||
return (req, res, next) => {
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
@@ -255,6 +258,7 @@ function strictRateLimit(options = {}) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dispose() { cleanupTimers.forEach(clearInterval); cleanupTimers.clear(); },
|
||||
feedbackRateLimit,
|
||||
strictRateLimit,
|
||||
generateGuestIdentifier,
|
||||
|
||||
@@ -1,267 +1,104 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
|
||||
const access = require('../services/galleryAccessService');
|
||||
|
||||
/**
|
||||
* True when a logged-in admin is explicitly previewing this gallery (#868).
|
||||
*
|
||||
* Two conditions, both required:
|
||||
* 1. The explicit intent flag `?admin_preview=1` is present. The plain share
|
||||
* link stays byte-identical to a guest's, so the password gate is still
|
||||
* testable as a guest while logged in as admin — and the bypass is visible
|
||||
* in the URL without being reusable (it carries no secret).
|
||||
* 2. A VERIFIED admin session — the httpOnly `admin_token` cookie (rides along
|
||||
* on same-origin API calls) or an Authorization: Bearer header, never the
|
||||
* URL. Must decode as `type: 'admin'`, issuer `picpeak-auth`.
|
||||
*
|
||||
* The cookie is tried FIRST and the Bearer is accepted only when it is itself an
|
||||
* admin token (#981 review): the frontend attaches a gallery Bearer to gallery
|
||||
* endpoints, and a header-first, type-blind read would let a coexisting gallery
|
||||
* session shadow the admin cookie and wrongly disable the preview.
|
||||
*
|
||||
* Fails closed on any verification error. Replaces the old `?preview=<raw-JWT>`
|
||||
* scheme, which leaked a 24h admin token into the address bar.
|
||||
*/
|
||||
// Cookie first: a coexisting gallery Bearer must not shadow an admin preview.
|
||||
function decodeAdminPreview(req) {
|
||||
if (req.query?.admin_preview !== '1') return null;
|
||||
// Cookie first, then a Bearer — but only an admin-typed token satisfies it.
|
||||
const candidates = [];
|
||||
if (req.cookies?.admin_token) candidates.push(req.cookies.admin_token);
|
||||
const candidates = [req.cookies?.admin_token];
|
||||
const header = req.headers?.authorization;
|
||||
if (header && header.startsWith('Bearer ')) candidates.push(header.slice(7));
|
||||
for (const token of candidates) {
|
||||
if (header?.startsWith('Bearer ')) candidates.push(header.slice(7));
|
||||
for (const token of candidates.filter(Boolean)) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth', algorithms: ['HS256'],
|
||||
});
|
||||
if (decoded.type === 'admin') return decoded;
|
||||
} catch { /* try the next candidate */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Signature-only predicate retained for UI-intent callers. It never authorizes.
|
||||
function isAdminPreview(req) {
|
||||
return decodeAdminPreview(req) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full session check behind the preview bypass. A verified signature is
|
||||
* not a live session: adminAuth also rejects revoked tokens, tokens issued
|
||||
* before the restore cutoff, deactivated admins and tokens minted before the
|
||||
* admin's last password change. Without those a logged-out or deactivated
|
||||
* admin token kept unlocking every draft and password gallery until `exp`
|
||||
* (30 days with remember-me). Sets req.isAdminPreview on success so the
|
||||
* downstream reveal-mode and logging checks read one verified flag.
|
||||
*/
|
||||
async function verifyAdminPreview(req) {
|
||||
if (req.isAdminPreview === true) return true;
|
||||
function attachAccess(req, event, grant) {
|
||||
req.event = event;
|
||||
req.galleryAccess = grant;
|
||||
req.isAdminPreview = grant.kind === 'admin';
|
||||
req.accessLevel = grant.session?.accessLevel || 'guest';
|
||||
req.viaCustomer = grant.session?.via === 'customer';
|
||||
req.sessionID = req.isAdminPreview ? `gallery_admin_preview_${event.id}`
|
||||
: `gallery_${grant.kind === 'public' ? 'public_' : ''}${event.id}_${Date.now()}`;
|
||||
const ip = req.ip || req.connection?.remoteAddress || 'unknown';
|
||||
const userAgent = req.get?.('User-Agent') || 'unknown';
|
||||
req.clientInfo = {
|
||||
ip, userAgent, fingerprint: `${ip}-${userAgent}`.substring(0, 32), timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyAdminPreview(req, event) {
|
||||
if (req.isAdminPreview && req.galleryAccess && (!event || event.id === req.event?.id)) return true;
|
||||
const decoded = decodeAdminPreview(req);
|
||||
if (!decoded) return false;
|
||||
try {
|
||||
if (await isTokenRevoked(decoded) || await isTokenBeforeCutoff(decoded)) return false;
|
||||
const admin = await withRetry(async () => db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.select('id', 'password_changed_at')
|
||||
.first());
|
||||
if (!admin) return false;
|
||||
if (admin.password_changed_at) {
|
||||
const changedSeconds = Math.floor(new Date(admin.password_changed_at).getTime() / 1000);
|
||||
if (decoded.iat < changedSeconds) return false;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Admin preview session check failed', { error: err.message });
|
||||
const slug = req.params?.slug || req.requestedSlug;
|
||||
if (!event && !slug) return false;
|
||||
event = event || await db('events').where({ slug }).select('*').first();
|
||||
if (!event) return false;
|
||||
const grant = access.grant(event, 'admin', decoded);
|
||||
await access.authorize(event, grant);
|
||||
attachAccess(req, event, grant);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug('Admin gallery preview denied', { code: error.code });
|
||||
req.adminPreviewDenied = error;
|
||||
return false;
|
||||
}
|
||||
req.isAdminPreview = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Middleware to verify gallery access
|
||||
function decodeGalleryToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'], issuer: 'picpeak-auth' });
|
||||
} catch (error) {
|
||||
// Legacy gallery tokens lacked an issuer, but still need the same type,
|
||||
// lifecycle and session checks as current tokens.
|
||||
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
|
||||
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
try {
|
||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
||||
|
||||
// Admin preview (#868) is resolved BEFORE any gallery credential (#981
|
||||
// review): a coexisting gallery token/Bearer must not shadow it, and the
|
||||
// admin session must never fall into the `type !== 'gallery'` reject path
|
||||
// below. Per-request bypass — draft + password relaxed, NO gallery JWT
|
||||
// minted (a lingering guest cookie would muddy the coexisting-cookies case).
|
||||
// req.isAdminPreview flags downstream logging to keep it out of guest stats.
|
||||
if (await verifyAdminPreview(req)) {
|
||||
if (!requestedSlug) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
const previewEvent = await withRetry(async () => db('events')
|
||||
.where({ slug: requestedSlug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.select('*').first());
|
||||
if (!previewEvent) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
req.event = previewEvent;
|
||||
req.isAdminPreview = true;
|
||||
req.sessionID = `gallery_admin_preview_${previewEvent.id}`;
|
||||
req.clientInfo = {
|
||||
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||
userAgent: req.get('User-Agent') || 'unknown',
|
||||
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = getGalleryTokenFromRequest(req, requestedSlug);
|
||||
let event;
|
||||
|
||||
if (!token) {
|
||||
if (!requestedSlug) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
event = await withRetry(async () => db('events')
|
||||
.where({
|
||||
slug: requestedSlug,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false),
|
||||
is_draft: formatBoolean(false)
|
||||
})
|
||||
.select('*').first());
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
if (!requiresPassword) {
|
||||
req.event = event;
|
||||
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
|
||||
req.clientInfo = {
|
||||
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||
userAgent: req.get('User-Agent') || 'unknown',
|
||||
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (error) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
|
||||
|
||||
// Only gallery-scoped tokens grant gallery access. Every legitimate
|
||||
// path (password login, share link, client access, customer-minted,
|
||||
// slideshow) mints type:'gallery'. Reject anything else — e.g. a guest
|
||||
// identity token (type:'guest', for feedback attribution) that carries a
|
||||
// matching eventId — instead of relying on other token types incidentally
|
||||
// lacking an eventId to fail the id match below.
|
||||
if (decoded.type !== 'gallery') {
|
||||
if (await verifyAdminPreview(req)) return next();
|
||||
// The caller asked for a preview explicitly: report why it was refused
|
||||
// instead of falling through to a misleading guest-token error.
|
||||
if (req.adminPreviewDenied?.isOperational) throw req.adminPreviewDenied;
|
||||
const slug = req.params.slug || req.requestedSlug;
|
||||
const token = getGalleryTokenFromRequest(req, slug);
|
||||
const decoded = token ? decodeGalleryToken(token) : null;
|
||||
if (decoded && decoded.type !== 'gallery') {
|
||||
return res.status(403).json({ error: 'Invalid token type for gallery access' });
|
||||
}
|
||||
|
||||
// If we have a slug in the URL params or from pre-middleware, verify it matches.
|
||||
// (Admin preview never reaches here — it returns above — so drafts stay
|
||||
// filtered for every real gallery-token request.)
|
||||
if (requestedSlug) {
|
||||
// Verify by slug and ensure it matches the token's event
|
||||
event = await withRetry(async () => db('events')
|
||||
.where({
|
||||
slug: requestedSlug,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false),
|
||||
is_draft: formatBoolean(false)
|
||||
})
|
||||
.select('*').first());
|
||||
|
||||
// Verify the token's eventId matches
|
||||
if (event && event.id !== decoded.eventId) {
|
||||
return res.status(403).json({ error: 'Token does not match requested gallery' });
|
||||
}
|
||||
} else {
|
||||
// Fallback to using eventId from token
|
||||
event = await withRetry(async () => db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false),
|
||||
is_draft: formatBoolean(false)
|
||||
})
|
||||
.select('*').first());
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// Customer-minted gallery JWTs (#354): when the customer obtained
|
||||
// this token via /api/customer/events/:slug/access-token, the
|
||||
// payload carries `via:'customer'` and `customerId`. The admin
|
||||
// can revoke the customer's access at any time by removing the
|
||||
// event_customer_assignments row from the "Manage galleries"
|
||||
// dialog on the customer detail page. Re-check that row here so
|
||||
// the revocation takes effect on the customer's very next
|
||||
// request — no token-blacklisting machinery required.
|
||||
if (decoded.via === 'customer' && decoded.customerId) {
|
||||
const assignment = await withRetry(async () => {
|
||||
return await db('event_customer_assignments')
|
||||
.where({
|
||||
event_id: event.id,
|
||||
customer_account_id: decoded.customerId,
|
||||
})
|
||||
.first();
|
||||
});
|
||||
if (!assignment) {
|
||||
logger.info('[verifyGalleryAccess] Customer assignment revoked, rejecting token', {
|
||||
customerId: decoded.customerId,
|
||||
eventId: event.id,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: 'Access to this gallery has been revoked',
|
||||
code: 'CUSTOMER_ASSIGNMENT_REVOKED',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||
req.event = event;
|
||||
req.accessLevel = decoded.accessLevel || 'guest';
|
||||
// Customer-portal provenance (#746/#849): portal-minted tokens carry
|
||||
// via:'customer' but NO accessLevel (they default to guest), while
|
||||
// PIN-client logins carry accessLevel:'client' without `via`. Activity
|
||||
// attribution/dedup needs the distinction, so surface it explicitly.
|
||||
req.viaCustomer = decoded.via === 'customer';
|
||||
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
||||
|
||||
// Create client info for logging (similar to secureImageMiddleware but simpler)
|
||||
req.clientInfo = {
|
||||
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||
userAgent: req.get('User-Agent') || 'unknown',
|
||||
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32), // Limit to 32 chars for DB column
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
|
||||
next();
|
||||
if (!slug && !decoded?.eventId) return res.status(401).json({ error: 'No token provided' });
|
||||
const event = await withRetry(() => db('events').where(slug ? { slug } : { id: decoded.eventId }).select('*').first());
|
||||
if (!event) return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
const grant = access.grant(event, decoded ? 'gallery' : 'public', decoded);
|
||||
await access.authorize(event, grant);
|
||||
attachAccess(req, event, grant);
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
if (!error.isOperational) logger.error('Error verifying gallery access', { error: error.message });
|
||||
return res.status(error.statusCode || 401).json({
|
||||
error: error.isOperational ? error.message : 'Invalid token',
|
||||
...(error.code && error.isOperational && { code: error.code }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
function canAccessEvent(admin, event) {
|
||||
return Boolean(admin && event && (admin.roleName === 'super_admin'
|
||||
|| event.created_by == null || Number(event.created_by) === Number(admin.id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to enforce event ownership for non-super_admin users.
|
||||
* Super admins bypass the check. Other admins can only access events they created.
|
||||
@@ -23,7 +28,7 @@ function requireEventOwnership(req, res, next) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
// Allow access if: event has no owner (legacy/system), or admin owns it
|
||||
if (event.created_by && event.created_by !== req.admin.id) {
|
||||
if (!canAccessEvent(req.admin, event)) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
next();
|
||||
@@ -165,6 +170,7 @@ function requireProjectOwnership(req, res, next) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canAccessEvent,
|
||||
requireEventOwnership,
|
||||
filterOwnedEventIds,
|
||||
scopeEventsQuery,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { requestLogPath } = require('../utils/requestLogPath');
|
||||
/**
|
||||
* Permission Checking Middleware for RBAC
|
||||
* Provides role-based access control with caching for performance
|
||||
@@ -143,7 +144,7 @@ function requirePermission(permissions, options = { requireAll: false }) {
|
||||
userId: req.admin.id,
|
||||
username: req.admin.username,
|
||||
requiredPermissions: permArray,
|
||||
path: req.path,
|
||||
path: requestLogPath(req.originalUrl || req.path),
|
||||
method: req.method
|
||||
});
|
||||
throw new ForbiddenError('Insufficient permissions');
|
||||
@@ -180,7 +181,7 @@ function requireSuperAdmin() {
|
||||
logger.warn('Super admin access denied', {
|
||||
userId: req.admin.id,
|
||||
username: req.admin.username,
|
||||
path: req.path,
|
||||
path: requestLogPath(req.originalUrl || req.path),
|
||||
method: req.method
|
||||
});
|
||||
throw new ForbiddenError('Super Admin access required');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { requestLogPath } = require('../utils/requestLogPath');
|
||||
const { db } = require('../database/db');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -10,12 +11,24 @@ class SecureImageMiddleware {
|
||||
this.suspiciousIPs = new Set();
|
||||
this.blockedFingerprints = new Set();
|
||||
this.rateLimitViolations = new Map();
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.cleanupTimer) return;
|
||||
this.cleanupTimer = setInterval(() => this.cleanup(), 300000);
|
||||
this.cleanupTimer.unref();
|
||||
}
|
||||
dispose() {
|
||||
clearInterval(this.cleanupTimer); this.cleanupTimer = null;
|
||||
this.suspiciousIPs.clear(); this.blockedFingerprints.clear(); this.rateLimitViolations.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main security middleware for image access
|
||||
*/
|
||||
secureImageAccess = async (req, res, next) => {
|
||||
this.start();
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const clientIP = this.getClientIP(req);
|
||||
@@ -56,7 +69,7 @@ class SecureImageMiddleware {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
path: requestLogPath(req.originalUrl || req.path)
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
@@ -328,7 +341,7 @@ class SecureImageMiddleware {
|
||||
client_ip: req.clientInfo?.ip || req.ip,
|
||||
client_fingerprint: req.clientInfo?.fingerprint,
|
||||
user_agent: req.get('User-Agent')?.substring(0, 255),
|
||||
request_path: req.path,
|
||||
request_path: requestLogPath(req.originalUrl || req.path),
|
||||
request_method: req.method,
|
||||
details: JSON.stringify(details),
|
||||
timestamp: new Date().toISOString()
|
||||
@@ -409,9 +422,4 @@ class SecureImageMiddleware {
|
||||
// Create singleton instance
|
||||
const secureImageMiddleware = new SecureImageMiddleware();
|
||||
|
||||
// Setup cleanup interval
|
||||
setInterval(() => {
|
||||
secureImageMiddleware.cleanup();
|
||||
}, 300000); // Every 5 minutes
|
||||
|
||||
module.exports = secureImageMiddleware;
|
||||
@@ -26,7 +26,7 @@ const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries
|
||||
// behaviour is unchanged: the timer fires every 5 min as long as
|
||||
// the server has anything else keeping the loop alive (HTTP server,
|
||||
// other intervals), which is always.
|
||||
setInterval(() => {
|
||||
const cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [token, lastActivity] of sessions.entries()) {
|
||||
if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) {
|
||||
@@ -208,6 +208,7 @@ function getActiveSessions() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dispose: () => { clearInterval(cleanupTimer); sessions.clear(); cachedTimeout = null; cacheExpiry = 0; },
|
||||
sessionTimeoutMiddleware,
|
||||
isSessionExpired,
|
||||
endSession,
|
||||
|
||||
@@ -182,16 +182,17 @@ router.post('/logout', adminAuth, handleAsync(async (req, res) => {
|
||||
// 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;
|
||||
clearAdminAuthCookie(res);
|
||||
if (token) {
|
||||
// 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');
|
||||
if (!await revokeToken(token, 'logout')) {
|
||||
throw new Error('Token revocation failed');
|
||||
}
|
||||
}
|
||||
// Clear the auth cookie so the browser stops sending the (now revoked) JWT.
|
||||
clearAdminAuthCookie(res);
|
||||
|
||||
// Log activity
|
||||
await logActivity('admin_logout',
|
||||
@@ -263,6 +264,11 @@ router.post('/mfa/setup', adminAuth, handleAsync(async (req, res) => {
|
||||
|
||||
// Complete enrollment: verify a code against the provisional secret, enable
|
||||
// MFA, and return one-time recovery codes (shown exactly once).
|
||||
//
|
||||
// No replay tracking here: this confirms an already-authenticated session
|
||||
// still holds the authenticator (no new session is granted), and starting
|
||||
// the last-used-step counter here would reject the very next login if it
|
||||
// lands in the same 30s TOTP step as this call.
|
||||
router.post('/mfa/enable', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('Verification code is required')
|
||||
@@ -313,7 +319,17 @@ router.post('/mfa/disable', [
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const totpOk = mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret);
|
||||
// Persist the matched step atomically right here (see mfaService.persistTotpStep):
|
||||
// two concurrent requests carrying the same captured code can't both read the
|
||||
// same last-used step and both win — only the first writer's UPDATE affects a
|
||||
// row, so a losing concurrent request is correctly treated as invalid below.
|
||||
const totpStep = mfaService.verifyTotpEncryptedStep(
|
||||
req.body.code, admin.two_factor_secret, admin.two_factor_last_used_step
|
||||
);
|
||||
let totpOk = false;
|
||||
if (totpStep !== null) {
|
||||
totpOk = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
|
||||
}
|
||||
let recoveryOk = false;
|
||||
if (!totpOk) {
|
||||
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
|
||||
@@ -328,6 +344,7 @@ router.post('/mfa/disable', [
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
two_factor_last_used_step: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
@@ -352,7 +369,16 @@ router.post('/mfa/recovery-codes', [
|
||||
if (!isMfaEnabled(admin)) {
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
|
||||
// Persist the matched step atomically right here (see mfaService.persistTotpStep):
|
||||
// two concurrent requests carrying the same captured code can't both read the
|
||||
// same last-used step and both win — only the first writer's UPDATE affects a
|
||||
// row, so a losing concurrent request is correctly treated as invalid below.
|
||||
const totpStep = mfaService.verifyTotpEncryptedStep(
|
||||
req.body.code, admin.two_factor_secret, admin.two_factor_last_used_step
|
||||
);
|
||||
const totpOk = totpStep !== null
|
||||
&& await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
|
||||
if (!totpOk) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,11 @@ const picpeakUpload = multer({
|
||||
destination: (req, file, cb) => cb(null, os.tmpdir()),
|
||||
filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`),
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large
|
||||
// CVE-2026-82333: this route only ever consumes a single unnamed file
|
||||
// field (`backup`) — no legitimate bracket-indexed field name (e.g.
|
||||
// `a[0]`) exists in its form. fieldArrayIndexLimit: 0 rejects any field
|
||||
// name using array-index syntax at all, closing multer's field-parser DoS.
|
||||
limits: { fileSize: 5 * 1024 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5 GB — .picpeak with photos can be large
|
||||
});
|
||||
|
||||
// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of
|
||||
|
||||
@@ -23,6 +23,7 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { uploadedPdfLogoPath } = require('../utils/safePath');
|
||||
const { validateFileType, validateFileContent, ALLOWED_MEDIA_TYPES } = require('../utils/fileSecurityUtils');
|
||||
const businessProfileService = require('../services/businessProfileService');
|
||||
const { db } = require('../database/db');
|
||||
const { validateIban } = require('../utils/iban');
|
||||
@@ -95,6 +96,19 @@ const router = express.Router();
|
||||
// but accepts SVG in addition to PNG / JPEG — the PDF renderer
|
||||
// rasterises SVGs to PNG on the fly via resolveLogoFile() so the
|
||||
// admin can drop a vector logo here and have it work in print.
|
||||
//
|
||||
// GHSA-6wrv-9pr4-hhmw: this route used to take the stored extension
|
||||
// straight from `file.originalname` and only checked `file.mimetype`
|
||||
// against an allowlist — a file could declare an image MIME type
|
||||
// while carrying a `.html`/`.js` extension and arbitrary content, get
|
||||
// served same-origin from /uploads/logos with that extension, and
|
||||
// execute as script in the browser. Fixed the same way every sibling
|
||||
// upload route (adminSettings.js, adminCMS.js) already does it:
|
||||
// `validateFileType()` pairs the claimed MIME type against the
|
||||
// extension, and the extension actually written to disk is looked up
|
||||
// from the validated MIME type — never taken from client input.
|
||||
const PDF_LOGO_ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml'];
|
||||
|
||||
const pdfLogoStorage = multer.diskStorage({
|
||||
destination: async (_req, _file, cb) => {
|
||||
const dir = path.join(getStoragePath(), 'uploads/logos');
|
||||
@@ -102,18 +116,26 @@ const pdfLogoStorage = multer.diskStorage({
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || '.png';
|
||||
// fileFilter (below) runs before this and already rejected any
|
||||
// mimetype outside PDF_LOGO_ALLOWED_MIME_TYPES, so the lookup below
|
||||
// always hits. The extension is derived from the validated MIME
|
||||
// type, never from file.originalname.
|
||||
const ext = ALLOWED_MEDIA_TYPES[file.mimetype]?.extensions[0] || '.png';
|
||||
cb(null, `pdf-logo-${Date.now()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const pdfLogoUpload = multer({
|
||||
storage: pdfLogoStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['image/png', 'image/jpeg', 'image/svg+xml'];
|
||||
if (allowed.includes(file.mimetype)) cb(null, true);
|
||||
else cb(new Error('Only PNG, JPEG and SVG logos are allowed'));
|
||||
if (validateFileType(file.originalname, file.mimetype, PDF_LOGO_ALLOWED_MIME_TYPES)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only PNG, JPEG and SVG logos are allowed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -359,6 +381,19 @@ router.post(
|
||||
return res.status(400).json({ error: 'No logo file uploaded' });
|
||||
}
|
||||
|
||||
// fileFilter above only pairs the claimed MIME type against the
|
||||
// extension — it runs on the in-flight stream, before any bytes are
|
||||
// written, so it can't inspect content. Content-sniff the bytes multer
|
||||
// just wrote to disk (magic numbers) before trusting them; SVG has no
|
||||
// magic-number check (validateFileContent returns true for it), it's
|
||||
// protected by the CSP header instead. Matches the cleanup-then-reject
|
||||
// pattern createFileUploadValidator() uses for other upload routes.
|
||||
const contentIsValid = await validateFileContent(req.file.path, req.file.mimetype);
|
||||
if (!contentIsValid) {
|
||||
try { await fs.unlink(req.file.path); } catch (_) { /* ignore */ }
|
||||
return res.status(400).json({ error: 'File content does not match its declared type' });
|
||||
}
|
||||
|
||||
// Clean up the previous PDF logo on disk if it was uploaded via
|
||||
// this same endpoint (matches the pdf-logo-* prefix). We leave
|
||||
// anything else untouched — the admin may have set logo_path to
|
||||
@@ -432,7 +467,19 @@ router.put(
|
||||
body('defaultLocale').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||
body('defaultQrFormat').optional({ values: 'falsy' }).isIn(['swiss', 'epc', 'none']),
|
||||
body('footerLine').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||
body('logoPath').optional({ values: 'falsy' }).isString().isLength({ max: 512 }),
|
||||
// GHSA-6wrv-9pr4-hhmw: logoPath is mass-assignable here, so it must
|
||||
// only ever be settable to a path the POST /logo upload route itself
|
||||
// produced (or '' to clear it, allowed by `values: 'falsy'` above) —
|
||||
// not an arbitrary string chaining in a file uploaded elsewhere.
|
||||
// uploadedPdfLogoPath() is the same pattern check the delete/replace
|
||||
// cleanup path already trusts to name a file this route wrote.
|
||||
body('logoPath').optional({ values: 'falsy' }).isString().isLength({ max: 512 })
|
||||
.custom((value) => {
|
||||
if (!uploadedPdfLogoPath(value, getStoragePath())) {
|
||||
throw new Error('logoPath must be a path produced by the logo upload endpoint');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
// Bundled-fonts dropdown (migration 121). Free-text upload field
|
||||
// (pdfFontTtfPath, migration 103) was retired from the UI in
|
||||
// favour of this dropdown; the column stays in the DB so any
|
||||
|
||||
@@ -32,7 +32,9 @@ const pageLogoStorage = multer.diskStorage({
|
||||
|
||||
const pageLogoUpload = multer({
|
||||
storage: pageLogoStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
if (validateFileType(file.originalname, file.mimetype, allowed)) cb(null, true);
|
||||
|
||||
@@ -66,14 +66,20 @@ const signedPdfStorage = multer.diskStorage({
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const contractId = Number(req.params.id);
|
||||
if (!Number.isInteger(contractId) || contractId <= 0) {
|
||||
return cb(new Error('Invalid contract id'));
|
||||
}
|
||||
const ext = path.extname(file.originalname) || '.pdf';
|
||||
cb(null, `contract-${req.params.id}-${Date.now()}${ext}`);
|
||||
cb(null, `contract-${contractId}-${Date.now()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const signedPdfUpload = multer({
|
||||
storage: signedPdfStorage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
||||
// CVE-2026-82333: single unnamed `file` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 10 MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = ['application/pdf'];
|
||||
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
|
||||
|
||||
@@ -2,7 +2,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { databaseBackupService } = require('../services/databaseBackup');
|
||||
const { databaseBackupService, isUnderPubliclyServableRoot } = require('../services/databaseBackup');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
@@ -60,7 +60,28 @@ router.put('/config', requirePermission('backup.create'), async (req, res) => {
|
||||
'database_backup_email_on_failure',
|
||||
'database_backup_email_on_success'
|
||||
];
|
||||
|
||||
|
||||
// A backup.create holder (the built-in `admin` role has it without
|
||||
// settings.edit or backup.restore) could otherwise point backups at a
|
||||
// public static mount and fetch the dump unauthenticated — see
|
||||
// isUnderPubliclyServableRoot's comment (GHSA-jw8m-43r2-jqrm class).
|
||||
if (
|
||||
typeof req.body.database_backup_destination_path === 'string'
|
||||
&& isUnderPubliclyServableRoot(req.body.database_backup_destination_path)
|
||||
) {
|
||||
return res.status(400).json({ error: 'Destination path must not be inside a publicly served directory' });
|
||||
}
|
||||
|
||||
// A retention of 0 or less pushes cleanupOldBackups' cutoff to today or
|
||||
// the future, deleting every completed backup on the next scheduled run
|
||||
// — a backup.create holder achieving what backup.delete gates on /cleanup.
|
||||
if (
|
||||
req.body.database_backup_retention_days !== undefined
|
||||
&& (!Number.isFinite(req.body.database_backup_retention_days) || req.body.database_backup_retention_days < 1)
|
||||
) {
|
||||
return res.status(400).json({ error: 'database_backup_retention_days must be a positive number' });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
|
||||
for (const [key, value] of Object.entries(req.body)) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { slugify } = require('../../utils/slug');
|
||||
|
||||
const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission, userHasAllPermissions } = require('../../middleware/permissions');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../../utils/emailNormalization');
|
||||
@@ -25,14 +25,13 @@ const eventTypeService = require('../../services/eventTypeService');
|
||||
const { normaliseEventTimeTriple } = require('../../services/eventService');
|
||||
const { hasColumnCached } = require('../../utils/schemaCache');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
|
||||
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
|
||||
const { clampIntOrUndefined } = require('../../utils/numericHelpers');
|
||||
|
||||
const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl');
|
||||
const downloadZipService = require('../../services/downloadZipService');
|
||||
const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults');
|
||||
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults,
|
||||
getImageSecurityDefaults, resolveImageSecurityColumns, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
|
||||
const { KEYBIND_MODES } = require('../../services/feedbackDefaults');
|
||||
const { validateHeroImageAnchor, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade } = require('./helpers');
|
||||
|
||||
/**
|
||||
* `events.slug` is UNIQUE, and both routes that mint one do a read-then-insert
|
||||
@@ -174,7 +173,6 @@ async function queueGalleryCreatedEmail(event, { password, requirePassword } = {
|
||||
|
||||
module.exports = (router) => {
|
||||
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||
@@ -240,7 +238,6 @@ module.exports = (router) => {
|
||||
body('watermark_text').optional().trim(),
|
||||
// #328 follow-up: per-event opt-in for presigned-URL "Download All".
|
||||
// Bypasses watermarks; admin must enable knowingly.
|
||||
body('allow_presigned_download').optional().isBoolean(),
|
||||
// Feedback sub-toggles (#1044). Optional: omitting them inherits the
|
||||
// global Settings > Events defaults.
|
||||
body('allow_ratings').optional().isBoolean(),
|
||||
@@ -298,571 +295,13 @@ module.exports = (router) => {
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
// Get field requirements from settings
|
||||
const fieldRequirements = await getEventFieldRequirements();
|
||||
|
||||
const {
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
// Migration 137 — calendar time fields. is_full_day defaults to
|
||||
// true at the service layer when undefined (legacy form payloads).
|
||||
event_time_start,
|
||||
event_time_end,
|
||||
is_full_day,
|
||||
admin_email,
|
||||
password,
|
||||
welcome_message = '',
|
||||
color_theme = null,
|
||||
expiration_days = 30,
|
||||
allow_user_uploads = false,
|
||||
upload_category_id = null,
|
||||
allow_downloads = true,
|
||||
disable_right_click = false,
|
||||
enable_devtools_protection: enableDevtoolsProtectionInput,
|
||||
watermark_downloads = false,
|
||||
watermark_text = null,
|
||||
allow_presigned_download = false,
|
||||
require_password: requirePasswordInput,
|
||||
// Feedback settings. The allow_* sub-toggles deliberately have NO
|
||||
// destructuring defaults: `undefined` means "the caller didn't say",
|
||||
// which inherits the global Settings > Events default (#1044). The
|
||||
// admin create form posts explicit values (it seeds its own panel
|
||||
// from the same globals), so inheritance here is what covers the v1
|
||||
// API and any other caller that omits them.
|
||||
feedback_enabled: feedbackEnabledInput,
|
||||
allow_ratings: allowRatingsInput,
|
||||
allow_likes: allowLikesInput,
|
||||
allow_comments: allowCommentsInput,
|
||||
allow_favorites: allowFavoritesInput,
|
||||
allow_reactions: allowReactionsInput,
|
||||
allow_color_labels: allowColorLabelsInput,
|
||||
keybind_mode: keybindModeInput,
|
||||
require_name_email = false,
|
||||
moderate_comments = true,
|
||||
show_feedback_to_guests = true,
|
||||
// The create form has always shown the identity-mode chooser and this
|
||||
// route has never read it, so a gallery created as 'guest' quietly came
|
||||
// out 'simple' and the photographer had to set it again on the event.
|
||||
// Surfaced by adding a third mode (#1197); the fix is the same for all
|
||||
// three. Unknown values fall back rather than reaching the column,
|
||||
// which on Postgres is guarded by a CHECK constraint.
|
||||
identity_mode: identityModeInput,
|
||||
// CSS Template
|
||||
css_template_id = null,
|
||||
// Hero logo settings
|
||||
hero_logo_visible = true,
|
||||
// Header style settings
|
||||
header_style = 'standard',
|
||||
hero_divider_style = 'wave',
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor = 'center',
|
||||
// Photo cap
|
||||
photo_cap = null,
|
||||
// Client access settings (#172)
|
||||
client_access_enabled = false,
|
||||
client_password = null,
|
||||
// Draft mode
|
||||
is_draft = true,
|
||||
// Default photo sort
|
||||
default_photo_sort = 'upload_date_desc',
|
||||
// Banner overrides (#440 / #932) — see the insert below.
|
||||
promo_mode = 'inherit',
|
||||
promo_markdown = null,
|
||||
info_mode = 'inherit',
|
||||
info_markdown = null
|
||||
} = req.body;
|
||||
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
const customerEmail = getCustomerEmailFromPayload(req.body);
|
||||
// Phone field is opt-in via the global setting (#322). If disabled,
|
||||
// ignore whatever the client posted — defence in depth against form
|
||||
// bypass.
|
||||
const phoneEnabled = await isPhoneFieldEnabled();
|
||||
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
|
||||
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
// Conditional validation based on settings
|
||||
const validationErrors = [];
|
||||
if (fieldRequirements.require_customer_name && !customerName) {
|
||||
validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' });
|
||||
}
|
||||
if (fieldRequirements.require_customer_email && !customerEmail) {
|
||||
validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' });
|
||||
}
|
||||
if (fieldRequirements.require_admin_email && !admin_email) {
|
||||
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
|
||||
}
|
||||
if (fieldRequirements.require_event_date && !event_date) {
|
||||
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(400).json({ errors: validationErrors });
|
||||
}
|
||||
|
||||
// Default require_password from global "event_default_require_password"
|
||||
// setting when the body omits it (#317 — admins want to flip the default).
|
||||
let requirePasswordFallback = true;
|
||||
if (requirePasswordInput === undefined) {
|
||||
const setting = await readBooleanSetting('event_default_require_password');
|
||||
if (setting !== undefined) requirePasswordFallback = setting;
|
||||
}
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
|
||||
|
||||
// Default feedback_enabled from global "event_default_feedback_enabled"
|
||||
// setting when the body omits it (#520 — same pattern as require_password
|
||||
// above, lets admins make Guest Feedback ON the out-of-box default for
|
||||
// new events instead of toggling it on every time).
|
||||
let feedbackEnabledFallback = false;
|
||||
if (feedbackEnabledInput === undefined) {
|
||||
const setting = await readBooleanSetting('event_default_feedback_enabled');
|
||||
if (setting !== undefined) feedbackEnabledFallback = setting;
|
||||
}
|
||||
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
|
||||
|
||||
// Sub-toggle defaults from the global Settings > Events values (#1044).
|
||||
// One batched read; an explicitly-sent body value still wins.
|
||||
const feedbackDefaults = applyFeedbackDefaults({
|
||||
allow_ratings: allowRatingsInput,
|
||||
allow_likes: allowLikesInput,
|
||||
allow_comments: allowCommentsInput,
|
||||
allow_favorites: allowFavoritesInput,
|
||||
allow_reactions: allowReactionsInput,
|
||||
allow_color_labels: allowColorLabelsInput,
|
||||
keybind_mode: keybindModeInput,
|
||||
}, await resolveEventFeedbackDefaults());
|
||||
|
||||
// Debug logging
|
||||
logger.debug('Download control values', {
|
||||
allow_downloads,
|
||||
disable_right_click,
|
||||
watermark_downloads,
|
||||
watermark_text,
|
||||
require_password: requirePassword,
|
||||
types: {
|
||||
allow_downloads: typeof allow_downloads,
|
||||
disable_right_click: typeof disable_right_click,
|
||||
watermark_downloads: typeof watermark_downloads
|
||||
}
|
||||
});
|
||||
|
||||
let passwordValidation = null;
|
||||
|
||||
if (requirePassword) {
|
||||
passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique slug. Uses the shared util so accented names
|
||||
// (Família, Decoração, etc.) get transliterated instead of dropped
|
||||
// — see backend/src/utils/slug.js for the why (#525).
|
||||
const processedEventName = slugify(event_name);
|
||||
|
||||
// Use event_date in slug if provided, otherwise use random suffix
|
||||
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
|
||||
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link respecting configured format
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds (random placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
? await bcrypt.hash(password, getBcryptRounds())
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
// If expiration is not required, expires_at will be null (never expires)
|
||||
// If event_date is not provided, use current date as base for expiration
|
||||
let expires_at = null;
|
||||
if (fieldRequirements.require_expiration) {
|
||||
const baseDate = event_date || new Date().toISOString().split('T')[0];
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
|
||||
expires_at = new Date(year, month - 1, day);
|
||||
} else {
|
||||
expires_at = new Date(baseDate);
|
||||
}
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
}
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
|
||||
const eventPath = path.join(storagePath, 'events/active', slug);
|
||||
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
|
||||
// Sync header_style / hero_divider_style from color_theme JSON when not
|
||||
// explicitly provided in the request body (#158).
|
||||
let effectiveHeaderStyle = header_style;
|
||||
let effectiveDividerStyle = hero_divider_style;
|
||||
if (color_theme && (!req.body.header_style || !req.body.hero_divider_style)) {
|
||||
try {
|
||||
if (typeof color_theme === 'string' && color_theme.startsWith('{')) {
|
||||
const parsed = JSON.parse(color_theme);
|
||||
if (!req.body.header_style && parsed.headerStyle) {
|
||||
effectiveHeaderStyle = parsed.headerStyle;
|
||||
}
|
||||
if (!req.body.hero_divider_style && parsed.heroDividerStyle) {
|
||||
effectiveDividerStyle = parsed.heroDividerStyle;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// color_theme is not JSON – nothing to extract
|
||||
}
|
||||
}
|
||||
|
||||
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
|
||||
const brandingDefaults = await getBrandingDefaults();
|
||||
// hero_logo_visible: store NULL ("inherit") unless the admin explicitly
|
||||
// set it, so the global branding_logo_display_hero toggle keeps
|
||||
// controlling this gallery afterwards (#756). Only an explicit per-event
|
||||
// choice overrides the global. `!= null` treats an explicit null the same
|
||||
// as omitted (both → inherit); otherwise formatBoolean(null) would coerce
|
||||
// to 0/false on SQLite instead of NULL (the PUT handler already does this).
|
||||
const effectiveHeroLogoVisible = req.body.hero_logo_visible != null
|
||||
? formatBoolean(hero_logo_visible)
|
||||
: null;
|
||||
// NULL = inherit the global branding_logo_size (#756), resolved at read
|
||||
// time. Only an explicit per-event size overrides it.
|
||||
const effectiveHeroLogoSize = req.body.hero_logo_size || null;
|
||||
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
|
||||
|
||||
// Inherit "Detect dev tools" from the global Image Security setting unless
|
||||
// the request explicitly overrides it (#317 — admin disabled it globally
|
||||
// but new events still got it ON because the column default is true).
|
||||
const protectionDefaults = await getDownloadProtectionDefaults();
|
||||
// #1296 — the other four Image-security settings, which were written,
|
||||
// rendered as controls, and read by nothing. Same inheritance rule as
|
||||
// the devtools setting below. Creation-time only; see
|
||||
// getImageSecurityDefaults for why existing events are left alone.
|
||||
const imageSecurityColumns = resolveImageSecurityColumns(
|
||||
req.body,
|
||||
await getImageSecurityDefaults(),
|
||||
);
|
||||
const effectiveEnableDevtoolsProtection =
|
||||
enableDevtoolsProtectionInput !== undefined
|
||||
? enableDevtoolsProtectionInput
|
||||
: protectionDefaults.enable_devtools_protection !== undefined
|
||||
? protectionDefaults.enable_devtools_protection
|
||||
: true;
|
||||
|
||||
// Migration 137 — normalise calendar time triple. Throws AppError
|
||||
// 400 when is_full_day=false but times are malformed/inverted.
|
||||
const calendarTriple = normaliseEventTimeTriple({
|
||||
event_time_start, event_time_end, is_full_day,
|
||||
});
|
||||
const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
|
||||
|
||||
// Insert into database
|
||||
// Seed the new event's Live Slideshow display style from the PICPEAK-WIDE
|
||||
// preset (app_settings, Settings → Slideshow). New events inherit it and the
|
||||
// admin can still override per event. Watermark is left NULL = inherit the
|
||||
// global watermark; the share token is minted on demand, not seeded. Guarded
|
||||
// so un-migrated installs (mid-branch) don't reference missing columns.
|
||||
let slideshowSeed = {};
|
||||
if (await hasColumnCached('events', 'show_interval_ms')) {
|
||||
try {
|
||||
// parseInt-first: the previous `Number.isFinite(+v)` pre-check let
|
||||
// NaN through for null/''/true (+null is 0, parseInt(null) is NaN),
|
||||
// producing show_interval_ms=NaN in the INSERT — PG rejects that
|
||||
// with "invalid input syntax for type integer" while SQLite
|
||||
// silently stores NULL, so event creation 500'd on PG whenever the
|
||||
// slideshow app_settings rows were absent.
|
||||
const intP = (v, min, max) => clampIntOrUndefined(v, min, max);
|
||||
const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined);
|
||||
const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000);
|
||||
const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS);
|
||||
const tms = intP(await getAppSetting('slideshow_transition_ms', undefined), 100, 5000);
|
||||
const cf = oneOf(await getAppSetting('slideshow_colorfilter', undefined), SLIDESHOW_COLORFILTERS);
|
||||
if (i !== undefined) slideshowSeed.show_interval_ms = i;
|
||||
if (tr) slideshowSeed.show_transition = tr;
|
||||
if (tms !== undefined) slideshowSeed.show_transition_ms = tms;
|
||||
if (cf) slideshowSeed.show_colorfilter = cf;
|
||||
} catch (e) {
|
||||
logger.warn('Failed to seed slideshow settings from global preset', { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
...slideshowSeed,
|
||||
event_date: event_date || null,
|
||||
...(calendarColumnsExist ? {
|
||||
event_time_start: calendarTriple.event_time_start,
|
||||
event_time_end: calendarTriple.event_time_end,
|
||||
is_full_day: formatBoolean(calendarTriple.is_full_day),
|
||||
} : {}),
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
...(customerPhone ? { customer_phone: customerPhone } : {}),
|
||||
host_name: customerName || null,
|
||||
host_email: customerEmail || null,
|
||||
admin_email: admin_email || null,
|
||||
password_hash,
|
||||
// Opt-in recoverable copy (#1271), written with the hash so the two
|
||||
// can never disagree. Empty unless the security setting is on.
|
||||
...(await galleryPasswordColumns({
|
||||
...(requirePassword && password ? { password } : {}),
|
||||
...(client_access_enabled && client_password ? { clientPassword: client_password } : {}),
|
||||
})),
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: req.admin.id,
|
||||
allow_user_uploads,
|
||||
upload_category_id,
|
||||
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
||||
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
||||
enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection),
|
||||
// Request value, else the global default, else the column default —
|
||||
// a key absent here is one the database fills in (#1296).
|
||||
...imageSecurityColumns,
|
||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||
watermark_text,
|
||||
allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'),
|
||||
require_password: formatBoolean(requirePassword),
|
||||
css_template_id: css_template_id || null,
|
||||
// Already formatBoolean-coerced above, or null = inherit global (#756).
|
||||
hero_logo_visible: effectiveHeroLogoVisible,
|
||||
hero_logo_size: effectiveHeroLogoSize,
|
||||
hero_logo_position: effectiveHeroLogoPosition,
|
||||
// Banner overrides. Both were accepted by the validators above and
|
||||
// then dropped here, so an API client could POST info_mode:'off' or a
|
||||
// custom banner, get 201, and find the row still on 'inherit'.
|
||||
// Markdown is only stored for 'custom' — same rule the PUT applies.
|
||||
promo_mode: ['inherit', 'custom', 'off'].includes(promo_mode) ? promo_mode : 'inherit',
|
||||
promo_markdown: promo_mode === 'custom' && typeof promo_markdown === 'string' && promo_markdown.trim()
|
||||
? promo_markdown.trim() : null,
|
||||
info_mode: ['inherit', 'custom', 'off'].includes(info_mode) ? info_mode : 'inherit',
|
||||
info_markdown: info_mode === 'custom' && typeof info_markdown === 'string' && info_markdown.trim()
|
||||
? info_markdown.trim() : null,
|
||||
header_style: effectiveHeaderStyle || 'standard',
|
||||
hero_divider_style: effectiveDividerStyle || 'wave',
|
||||
hero_image_anchor: hero_image_anchor || 'center',
|
||||
photo_cap: photo_cap || null,
|
||||
is_draft: formatBoolean(parseBooleanInput(is_draft, true)),
|
||||
default_photo_sort: default_photo_sort || 'upload_date_desc',
|
||||
// Client access (#172)
|
||||
client_access_enabled: formatBoolean(client_access_enabled),
|
||||
...(client_access_enabled && client_password ? {
|
||||
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
|
||||
client_share_token: crypto.randomBytes(32).toString('hex')
|
||||
} : {}),
|
||||
// Per-event opt-in for hero-photo OG share image (#474). Defaults
|
||||
// false on create — admin opts in from the event detail page once
|
||||
// they've picked a hero they're comfortable surfacing publicly.
|
||||
og_image_share_enabled: formatBoolean(req.body.og_image_share_enabled === true),
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
// #1271 — the setting was read before the hashes; re-check after the write
|
||||
await dropCopiesIfStorageOff(eventId);
|
||||
|
||||
// Apply customer-account assignments (#354). Skip when the customer
|
||||
// portal flag is off — the frontend hides the picker in that case,
|
||||
// but a stale tab could still POST customer_account_ids; we ignore
|
||||
// them rather than 403 the entire create.
|
||||
if (Array.isArray(req.body.customer_account_ids)) {
|
||||
try {
|
||||
const customerAccountsService = require('../../services/customerAccountsService');
|
||||
if (await customerAccountsService.isCustomerPortalEnabled()) {
|
||||
await customerAccountsService.setAssignmentsForEvent(
|
||||
eventId,
|
||||
req.body.customer_account_ids,
|
||||
req.admin.id
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to set customer assignments on event create', {
|
||||
eventId, error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Insert feedback settings if feedback is enabled
|
||||
if (feedback_enabled) {
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId,
|
||||
feedback_enabled: formatBoolean(feedback_enabled),
|
||||
allow_ratings: formatBoolean(feedbackDefaults.allow_ratings),
|
||||
allow_likes: formatBoolean(feedbackDefaults.allow_likes),
|
||||
allow_comments: formatBoolean(feedbackDefaults.allow_comments),
|
||||
allow_favorites: formatBoolean(feedbackDefaults.allow_favorites),
|
||||
allow_reactions: formatBoolean(feedbackDefaults.allow_reactions),
|
||||
allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels),
|
||||
keybind_mode: feedbackDefaults.keybind_mode,
|
||||
require_name_email: formatBoolean(require_name_email),
|
||||
moderate_comments: formatBoolean(moderate_comments),
|
||||
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
|
||||
identity_mode: ['simple', 'guest', 'shared'].includes(identityModeInput)
|
||||
? identityModeInput
|
||||
: 'simple',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_created',
|
||||
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
// Fire event.created webhook (#327). If the event is being published
|
||||
// immediately (not a draft), event.published also fires below.
|
||||
// Payload uses canonical event subject (#341) so receivers always see
|
||||
// the same shape (id/slug/event_name + customer contact + share_*).
|
||||
try {
|
||||
const webhookService = require('../../services/webhookService');
|
||||
await webhookService.fire('event.created', {
|
||||
event: {
|
||||
...webhookService.buildEventSubject({
|
||||
id: eventId,
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
event_date,
|
||||
share_url: shareUrl,
|
||||
share_token: shareToken,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
customer_phone: customerPhone,
|
||||
}),
|
||||
is_draft: parseBooleanInput(is_draft, true),
|
||||
},
|
||||
});
|
||||
} catch (e) { /* webhookService.fire never throws but be defensive */ }
|
||||
|
||||
// Queue creation email (only if there is a recipient and event is not a draft)
|
||||
// Language detection is handled by email processor
|
||||
const isDraft = parseBooleanInput(is_draft, true);
|
||||
|
||||
if (customerEmail && !isDraft) {
|
||||
// Build email data with optional client access info
|
||||
const emailData = {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
};
|
||||
|
||||
// Include client access info in email when enabled (#172)
|
||||
if (client_access_enabled && client_password) {
|
||||
const createdEvent = await db('events').where('id', eventId).first();
|
||||
// Same FRONTEND_URL-before-APP_URL order as before: APP_URL is
|
||||
// passed as the override so it still outranks the general_site_url
|
||||
// setting and the request origin. Chaining it after the resolver
|
||||
// would make it dead code, because the resolver only returns falsy
|
||||
// when NOTHING is configured (#1104).
|
||||
const frontendUrl = await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL });
|
||||
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
|
||||
emailData.client_password = client_password;
|
||||
}
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: customerEmail,
|
||||
email_type: 'gallery_created',
|
||||
email_data: JSON.stringify(emailData),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
// scheduled_at will use default value
|
||||
});
|
||||
}
|
||||
|
||||
// WhatsApp gallery_ready notification (#640D). Fires when the event is
|
||||
// created NOT as a draft, the `whatsapp` flag is on, a config exists, and
|
||||
// the customer supplied a phone number. Non-fatal: a queue failure should
|
||||
// never block gallery creation.
|
||||
if (!isDraft && customerPhone) {
|
||||
try {
|
||||
const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor');
|
||||
const waConfig = await getWhatsAppConfig();
|
||||
if (waConfig && waConfig.enabled) {
|
||||
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
|
||||
customer_name: customerName || '',
|
||||
event_name,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : '',
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null,
|
||||
language: null, // resolved by processor via general_default_language
|
||||
});
|
||||
}
|
||||
} catch (waError) {
|
||||
logger.warn('Failed to queue WhatsApp notification on create', { error: waError.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Fire event.published when the event is created NOT as a draft. The
|
||||
// separate /publish endpoint fires it for the draft → live transition;
|
||||
// this covers the "create-and-publish in one shot" path.
|
||||
if (!isDraft) {
|
||||
try {
|
||||
const webhookService = require('../../services/webhookService');
|
||||
await webhookService.fire('event.published', {
|
||||
event: webhookService.buildEventSubject({
|
||||
id: eventId,
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
event_date,
|
||||
share_url: shareUrl,
|
||||
share_token: shareToken,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
customer_phone: customerPhone,
|
||||
}),
|
||||
});
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: eventId,
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
require_password: requirePassword,
|
||||
photo_cap: photo_cap || null,
|
||||
is_draft: isDraft,
|
||||
share_link: shareUrl,
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
created_at: new Date().toISOString()
|
||||
const created = await require('../../services/eventCreationService').createEvent(req.body, {
|
||||
actor: req.admin,
|
||||
frontendUrl: await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL }),
|
||||
});
|
||||
res.json(created);
|
||||
} catch (error) {
|
||||
if (error.isOperational) return res.status(error.statusCode).json(error.responseBody || { error: error.message, code: error.code });
|
||||
if (isDuplicateSlugError(error)) {
|
||||
logger.warn('Event creation lost the slug race', { error: error.message });
|
||||
return res.status(409).json(DUPLICATE_SLUG_RESPONSE);
|
||||
@@ -1425,6 +864,7 @@ module.exports = (router) => {
|
||||
share_token: shareToken,
|
||||
expires_at: newExpiresAt ? newExpiresAt.toISOString() : null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
created_by: req.admin.id,
|
||||
allow_user_uploads: source.allow_user_uploads,
|
||||
upload_category_id: source.upload_category_id,
|
||||
@@ -1441,7 +881,6 @@ module.exports = (router) => {
|
||||
use_canvas_rendering: source.use_canvas_rendering,
|
||||
watermark_downloads: source.watermark_downloads,
|
||||
watermark_text: source.watermark_text,
|
||||
allow_presigned_download: source.allow_presigned_download,
|
||||
require_password: source.require_password,
|
||||
css_template_id: source.css_template_id || null,
|
||||
hero_logo_visible: source.hero_logo_visible,
|
||||
@@ -1598,7 +1037,6 @@ module.exports = (router) => {
|
||||
body('disable_right_click').optional().isBoolean(),
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim(),
|
||||
body('allow_presigned_download').optional().isBoolean(),
|
||||
body('source_mode').optional().isIn(['managed', 'reference']),
|
||||
body('external_path').optional({ nullable: true }).isString().trim(),
|
||||
body('external_watch').optional().isBoolean(),
|
||||
@@ -2029,8 +1467,6 @@ module.exports = (router) => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Sync header_style / hero_divider_style from color_theme JSON when not
|
||||
// explicitly provided in the request body (#158). This ensures the
|
||||
// database columns stay in sync even if the frontend only sends the
|
||||
@@ -2232,12 +1668,12 @@ module.exports = (router) => {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const newStatus = !event.is_active;
|
||||
const newStatus = !parseBooleanInput(event.is_active, false);
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
is_active: newStatus,
|
||||
updated_at: new Date()
|
||||
is_active: formatBoolean(newStatus),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Log activity
|
||||
|
||||
@@ -1,392 +1,9 @@
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Shared helpers + module-level caches used across the adminEvents sub-routers.
|
||||
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const logger = require('../../utils/logger');
|
||||
const { parseStringInput } = require('../../utils/parsers');
|
||||
const settings = require('../../services/eventSettings');
|
||||
|
||||
// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point
|
||||
const validateHeroImageAnchor = (value) => {
|
||||
if (['top', 'center', 'bottom'].includes(value)) return true;
|
||||
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
|
||||
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
|
||||
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
|
||||
}
|
||||
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
|
||||
};
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
|
||||
|
||||
// Helper to get event field requirements from settings
|
||||
const getEventFieldRequirements = async () => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'event_require_customer_name',
|
||||
'event_require_customer_email',
|
||||
'event_require_admin_email',
|
||||
'event_require_event_date',
|
||||
'event_require_expiration'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const requirements = {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
value = value === 'true';
|
||||
}
|
||||
}
|
||||
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
|
||||
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
|
||||
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
|
||||
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
|
||||
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
|
||||
});
|
||||
|
||||
return requirements;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get event field requirements', { error: error.message });
|
||||
return {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to read app_settings booleans by key, used to inherit per-setting
|
||||
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
|
||||
// so callers can fall back to a legacy default.
|
||||
/**
|
||||
* Decode an app_settings value into the JS value it represents.
|
||||
*
|
||||
* setting_value is JSON text on SQLite and may already be decoded by the
|
||||
* driver on a PG json column, so one parse does not normalise both. On top
|
||||
* of that, the Image Security tab used to PUT back values it had read
|
||||
* undecoded, wrapping another layer of quoting around each one on every
|
||||
* save — the GET handler decodes now, but installs carry however many
|
||||
* layers they accumulated before that.
|
||||
*
|
||||
* Every reader of app_settings has to agree about this, or the admin UI
|
||||
* shows one thing while event creation does another.
|
||||
*
|
||||
* Terminates: each parse of a string is strictly shorter than its input.
|
||||
*/
|
||||
const decodeSettingValue = (raw) => {
|
||||
let value = raw;
|
||||
while (typeof value === 'string') {
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(value); } catch { break; }
|
||||
if (parsed === value) break;
|
||||
value = parsed;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const readBooleanSetting = async (key) => {
|
||||
try {
|
||||
const setting = await db('app_settings').where('setting_key', key).first();
|
||||
if (!setting) return undefined;
|
||||
const value = decodeSettingValue(setting.setting_value);
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
} catch (error) {
|
||||
logger.error('Failed to read app setting', { key, error: error.message });
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to read the global "enable_devtools_protection" admin setting so
|
||||
// new events inherit it instead of always falling back to the DB column default
|
||||
// (#317 — admin disabled it globally but new events still got it ON).
|
||||
const getDownloadProtectionDefaults = async () => {
|
||||
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
|
||||
};
|
||||
|
||||
/**
|
||||
* The rest of Settings → Image security, as creation defaults (#1296).
|
||||
*
|
||||
* Four settings in that panel were written, reloaded and rendered as
|
||||
* controls, and read by nothing:
|
||||
*
|
||||
* default_protection_level → events.protection_level
|
||||
* default_image_quality → events.image_quality
|
||||
* enable_canvas_rendering → events.use_canvas_rendering
|
||||
*
|
||||
* Each maps onto a column migration 038 already created, and each is
|
||||
* labelled "… by default", so applying them at creation is what the panel
|
||||
* has always claimed to do. `enable_devtools_protection` above is the only
|
||||
* one of the five that was ever wired.
|
||||
*
|
||||
* Creation-time only, deliberately. Applying them to EXISTING events would
|
||||
* silently change live galleries on upgrade — an install with
|
||||
* enable_canvas_rendering already on would switch every grid to canvas
|
||||
* rendering, which is memory-expensive at scale and is the profile under
|
||||
* investigation in #1287. New events only; existing rows untouched.
|
||||
*
|
||||
* Any value that is missing or malformed comes back undefined so the caller
|
||||
* falls through to the column default, exactly as before this existed.
|
||||
*/
|
||||
const PROTECTION_LEVELS = ['basic', 'standard', 'enhanced', 'maximum'];
|
||||
|
||||
// parseInt would rescue malformed settings instead of rejecting them:
|
||||
// parseInt('72oops') is 72, parseInt(72.5) is 72, parseInt([72]) is 72.
|
||||
// That matters because the settings PUT stores whatever JSON it is handed
|
||||
// without validating the value (adminImageSecurity.js writes
|
||||
// JSON.stringify(value) for any allow-listed key), so those shapes really
|
||||
// can be sitting in app_settings. Accept only a genuine integer, or a
|
||||
// string that is exactly one.
|
||||
const toInteger = (value) => {
|
||||
if (typeof value === 'number') return Number.isInteger(value) ? value : undefined;
|
||||
if (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim())) return Number(value.trim());
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getImageSecurityDefaults = async (trx = null) => {
|
||||
const defaults = {};
|
||||
try {
|
||||
// Accepts a transaction the way getAppSetting does. It matters on
|
||||
// sqlite3, whose pool holds a single connection: a caller already inside
|
||||
// db.transaction() that read through the global `db` would block on the
|
||||
// connection its own transaction holds until the acquire timeout, and
|
||||
// the catch below would then quietly swallow it and drop the defaults.
|
||||
const query = trx || db;
|
||||
const rows = await query('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'default_protection_level',
|
||||
'default_image_quality',
|
||||
'enable_canvas_rendering',
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
// app_settings holds JSON text on SQLite, while a PG json column comes
|
||||
// back already decoded — so one parse is not enough to normalise both.
|
||||
// Worse, GET /api/admin/image-security/settings returns setting_value
|
||||
// without decoding it and the settings tab PUTs the whole fetched object
|
||||
// straight back through JSON.stringify, so opening the tab and saving
|
||||
// re-encodes every value it read as text. After one such round trip
|
||||
// `true` is stored as "\"true\"" and a single parse yields the string
|
||||
// 'true', which the type checks below reject — the settings would go
|
||||
// quietly dead again, which is the bug this whole change exists to fix.
|
||||
// The GET handler now decodes, so this stops accumulating — but installs
|
||||
// that already stacked N layers have to keep working, and N is however
|
||||
// many times someone opened that tab. So unwrap until it stops being a
|
||||
// JSON string rather than to a fixed depth; this terminates because each
|
||||
// parse of a string is strictly shorter than its input.
|
||||
const read = (key) => {
|
||||
const row = rows.find((r) => r.setting_key === key);
|
||||
if (!row) return undefined;
|
||||
return decodeSettingValue(row.setting_value);
|
||||
};
|
||||
|
||||
const level = read('default_protection_level');
|
||||
if (typeof level === 'string' && PROTECTION_LEVELS.includes(level)) {
|
||||
defaults.protection_level = level;
|
||||
}
|
||||
|
||||
// The column is an integer percentage; anything outside 1..100 is a
|
||||
// misconfiguration and falls through rather than being clamped into
|
||||
// something the operator did not choose.
|
||||
const quality = toInteger(read('default_image_quality'));
|
||||
if (quality !== undefined && quality >= 1 && quality <= 100) {
|
||||
defaults.image_quality = quality;
|
||||
}
|
||||
|
||||
const canvas = read('enable_canvas_rendering');
|
||||
if (typeof canvas === 'boolean') {
|
||||
defaults.use_canvas_rendering = canvas;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// A settings read must never block event creation; the column defaults
|
||||
// are a correct fallback.
|
||||
logger.error('Failed to read image-security defaults', { error: error.message });
|
||||
}
|
||||
return defaults;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the image-security columns for a NEW event: an explicit request
|
||||
* value wins, then the global default, then the column default (the key is
|
||||
* omitted entirely so the database supplies it).
|
||||
*
|
||||
* Shared by the admin create route and POST /api/v1/events so the configured
|
||||
* security level cannot depend on which entry point created the gallery —
|
||||
* the same split that made #592 (devtools) a separate bug from #317.
|
||||
*
|
||||
* `body` values are already validated by the route's express-validator
|
||||
* chain; `defaults` come from getImageSecurityDefaults(), which validates
|
||||
* them itself.
|
||||
*/
|
||||
const resolveImageSecurityColumns = (body = {}, defaults = {}) => {
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const columns = {};
|
||||
// express-validator runs isInt/isIn/isBoolean element-wise on arrays, so a
|
||||
// single-element array like `image_quality: [72]` passes the route's chain
|
||||
// and arrives here still an array. The routes reject those with
|
||||
// .not().isArray(); this guard means any future caller cannot write one
|
||||
// into a scalar column (a PG insert error, or `[false]` coerced to true).
|
||||
const scalar = (v) => (v !== null && typeof v === 'object' ? undefined : v);
|
||||
const pick = (key) => {
|
||||
const fromBody = scalar(body[key]);
|
||||
return fromBody !== undefined ? fromBody : defaults[key];
|
||||
};
|
||||
|
||||
const level = pick('protection_level');
|
||||
if (level !== undefined) columns.protection_level = level;
|
||||
|
||||
const quality = pick('image_quality');
|
||||
if (quality !== undefined) columns.image_quality = quality;
|
||||
|
||||
const canvas = pick('use_canvas_rendering');
|
||||
if (canvas !== undefined) columns.use_canvas_rendering = formatBoolean(canvas);
|
||||
|
||||
|
||||
return columns;
|
||||
};
|
||||
|
||||
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
|
||||
//
|
||||
// Note: `branding_logo_position` (header bar — left/center/right) is a
|
||||
// different concept from `hero_logo_position` (hero block — top/center/
|
||||
// bottom) and must NOT be mapped here. A previous version copied the
|
||||
// branding value over, which wrote 'left'/'right' into per-event
|
||||
// hero_logo_position columns and broke any subsequent PUT validation
|
||||
// (#357). Migration 084 heals existing rows.
|
||||
const getBrandingDefaults = async () => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_logo_display_hero',
|
||||
'branding_logo_size'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const defaults = {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top'
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_display_hero') {
|
||||
defaults.hero_logo_visible = value !== false;
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_size' && value) {
|
||||
defaults.hero_logo_size = value;
|
||||
}
|
||||
});
|
||||
|
||||
return defaults;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get branding defaults', { error: error.message });
|
||||
return {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
|
||||
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
|
||||
|
||||
// Whether the global "phone field" toggle (#322) is enabled. Cached for
|
||||
// the request via a module-level read; drift is acceptable since this
|
||||
// only governs whether to persist the field, not security boundaries.
|
||||
const isPhoneFieldEnabled = async () => {
|
||||
try {
|
||||
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
|
||||
if (!row) return false;
|
||||
let value = row.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch { /* keep raw */ }
|
||||
}
|
||||
return value === true;
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read event_phone_field_enabled', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const RECOVERABLE_PASSWORD_COLUMNS = ['password_recoverable', 'client_password_recoverable'];
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const {
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
customer_phone,
|
||||
// Bound only to exclude the secrets from `...rest` — never read.
|
||||
password_hash: _ph, client_password_hash: _cph,
|
||||
...rest
|
||||
} = event;
|
||||
// #1271 — the encrypted copies never leave the server except via
|
||||
// /:id/password. Removed by name (not destructured) so a secret scanner
|
||||
// does not read the binding as a hard-coded password.
|
||||
for (const column of RECOVERABLE_PASSWORD_COLUMNS) delete rest[column];
|
||||
|
||||
return {
|
||||
...rest,
|
||||
customer_name: customer_name ?? host_name ?? null,
|
||||
customer_email: customer_email ?? host_email ?? null,
|
||||
customer_phone: customer_phone ?? null
|
||||
};
|
||||
};
|
||||
|
||||
let customerColumnCache = null;
|
||||
const hasCustomerContactColumns = async () => {
|
||||
if (customerColumnCache === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
logger.debug('Failed to detect customer_email column', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Cascade-delete a single event: photos, audit/access logs, queued emails,
|
||||
// the event row itself (in one transaction), then the on-disk folder /
|
||||
// archive zip / hero logo (best-effort — file failures don't unwind the DB
|
||||
// changes since the source of truth is the database). Used by both the
|
||||
// per-event DELETE /:id route and the bulk-delete route to avoid drift.
|
||||
//
|
||||
// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the
|
||||
// bulk-delete loop can report it as a per-id failure without aborting the
|
||||
// whole batch. Any other error propagates and is the caller's problem.
|
||||
async function deleteEventCascade(eventId, adminContext) {
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (!event) {
|
||||
@@ -678,40 +295,4 @@ async function deleteEventCascade(eventId, adminContext) {
|
||||
return { id: event.id, name: event.event_name };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live
|
||||
// events that auto-picks-up new uploads (migration 138). Mirrors the
|
||||
// client-access second-token pattern: the link is minted on demand, rotatable
|
||||
// and disable-able, independent of the gallery password / share link.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Allowed slide transition styles (kept in sync with the SlideshowPage).
|
||||
// dipwhite/dipblack = fade through highlights / lowlights between images.
|
||||
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||||
// Allowed per-slide color filters.
|
||||
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
||||
// Allowed slideshow play orders (#202). 'chronological' = upload order,
|
||||
// 'random' = client-side shuffle.
|
||||
const SLIDESHOW_ORDERS = ['chronological', 'random'];
|
||||
module.exports = {
|
||||
RECOVERABLE_PASSWORD_COLUMNS,
|
||||
validateHeroImageAnchor,
|
||||
getStoragePath,
|
||||
getEventFieldRequirements,
|
||||
readBooleanSetting,
|
||||
decodeSettingValue,
|
||||
getDownloadProtectionDefaults,
|
||||
getImageSecurityDefaults,
|
||||
resolveImageSecurityColumns,
|
||||
getBrandingDefaults,
|
||||
getCustomerNameFromPayload,
|
||||
getCustomerEmailFromPayload,
|
||||
getCustomerPhoneFromPayload,
|
||||
isPhoneFieldEnabled,
|
||||
mapEventForApi,
|
||||
hasCustomerContactColumns,
|
||||
deleteEventCascade,
|
||||
SLIDESHOW_ORDERS,
|
||||
SLIDESHOW_TRANSITIONS,
|
||||
SLIDESHOW_COLORFILTERS,
|
||||
};
|
||||
module.exports = { ...settings, deleteEventCascade };
|
||||
|
||||
@@ -24,14 +24,20 @@ const eventLogoStorage = multer.diskStorage({
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const eventId = Number(req.params.id);
|
||||
if (!Number.isInteger(eventId) || eventId <= 0) {
|
||||
return cb(new Error('Invalid event id'));
|
||||
}
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
|
||||
cb(null, `event-${eventId}-logo-${Date.now()}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const eventLogoUpload = multer({
|
||||
storage: eventLogoStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
|
||||
@@ -41,7 +41,10 @@ function diskUpload(subdir) {
|
||||
},
|
||||
filename: (_req, file, cb) => cb(null, `${subdir.split('/').pop()}-${Date.now()}${path.extname(file.originalname) || ''}`),
|
||||
}),
|
||||
limits: { fileSize: 15 * 1024 * 1024 },
|
||||
// CVE-2026-82333: both callers (`inboundUpload` → 'file', `proofUpload`
|
||||
// → 'proof') take a single unnamed field — no legitimate array-indexed
|
||||
// field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 15 * 1024 * 1024, fieldArrayIndexLimit: 0 },
|
||||
fileFilter: (_req, file, cb) => (ALLOWED_MIME.includes(file.mimetype) ? cb(null, true) : cb(new Error('Only PDF, JPEG or PNG files are allowed'))),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@ const importedInvoiceStorage = multer.diskStorage({
|
||||
});
|
||||
const importedInvoiceUpload = multer({
|
||||
storage: importedInvoiceStorage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
// CVE-2026-82333: single unnamed `pdf` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype === 'application/pdf') cb(null, true);
|
||||
else cb(new Error('Only PDF files are allowed for imported invoices'));
|
||||
|
||||
@@ -112,7 +112,12 @@ const createUpload = (maxFileSizeBytes) => multer({
|
||||
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
parts: 10000,
|
||||
headerPairs: 2000
|
||||
headerPairs: 2000,
|
||||
// CVE-2026-82333: files arrive as repeated `photos` parts via
|
||||
// multer's own .array('photos', N) — not bracket-indexed field names
|
||||
// like `photos[0]` — so no legitimate field name uses array-index
|
||||
// syntax at all. Reject any that do.
|
||||
fieldArrayIndexLimit: 0
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// req.allowedMimeTypes is populated by the middleware that runs before multer
|
||||
@@ -1701,18 +1706,30 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, r
|
||||
try {
|
||||
const { uploadId, chunkIndex } = req.params;
|
||||
|
||||
// Get chunk data from request body
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const chunkData = Buffer.concat(chunks);
|
||||
|
||||
const result = await chunkedUpload.uploadChunk(uploadId, parseInt(chunkIndex), chunkData);
|
||||
// The request stream is handed over unread (#1403). Every check — unknown
|
||||
// upload id, bad index, the per-file cap against Content-Length — runs
|
||||
// inside uploadChunk before a byte is consumed, and the body is then
|
||||
// streamed to the chunk file under a hard cap rather than concatenated in
|
||||
// memory. Buffering it first meant a rejected 300MB request still cost
|
||||
// 300MB of heap.
|
||||
const declaredBytes = Number(req.headers['content-length']);
|
||||
const result = await chunkedUpload.uploadChunk(uploadId, parseInt(chunkIndex), req, {
|
||||
declaredBytes: Number.isFinite(declaredBytes) ? declaredBytes : undefined,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error.statusCode === 413 || error.statusCode === 400) {
|
||||
// Client-caused states (unknown/finished/expired upload, bad index, too
|
||||
// large) carry their own status. Only a genuinely unexpected error should
|
||||
// reach the 500 below and the error log with it.
|
||||
if (error.statusCode) {
|
||||
// Refusing the body early is the point — but it leaves unread bytes in
|
||||
// flight on a connection this response still advertises as keep-alive.
|
||||
// Node does not drain them, so the NEXT request on that socket hangs
|
||||
// until it times out. Retire the connection instead.
|
||||
if (!req.readableEnded) {
|
||||
res.set('Connection', 'close');
|
||||
}
|
||||
return res.status(error.statusCode).json({ error: error.message });
|
||||
}
|
||||
logger.error('Error uploading chunk:', error);
|
||||
@@ -1762,8 +1779,10 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
|
||||
photos: uploadedPhotos
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.statusCode === 413) {
|
||||
return res.status(413).json({ error: error.message });
|
||||
// Same rule as the chunk route: a tagged status is a client-caused state
|
||||
// (unknown/expired upload, missing chunks), not a server fault.
|
||||
if (error.statusCode) {
|
||||
return res.status(error.statusCode).json({ error: error.message });
|
||||
}
|
||||
logger.error('Error completing chunked upload:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to complete upload' });
|
||||
|
||||
@@ -810,24 +810,72 @@ async function checkRestorePathsAllowed({ source, manifestPath }) {
|
||||
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;
|
||||
// GHSA-xfvx: nothing configured to compare against used to mean "a
|
||||
// restore can't be scoped, so don't pretend to enforce" — returning
|
||||
// null (allow). That's fail-OPEN: on a fresh install (or one where an
|
||||
// operator never set backup_destination_path/backup_manifest_path) any
|
||||
// authenticated `backup.restore` caller could point source/manifestPath
|
||||
// — and, via the manifest, database.backup_file — at literally any path
|
||||
// on disk. Require configuration instead of silently allowing
|
||||
// everything; the normal restore wizard already needs one of these
|
||||
// settings populated to discover backups in the first place.
|
||||
logger.warn('Refusing restore: no backup location configured to scope it to', { candidates });
|
||||
return 'No backup location is configured (backup_destination_path / backup_manifest_path). ' +
|
||||
'Configure one before restoring.';
|
||||
}
|
||||
|
||||
const resolvedRoots = roots.map((r) => path.resolve(r));
|
||||
for (const candidate of candidates) {
|
||||
const isInsideRoots = (candidate) => {
|
||||
const resolved = path.resolve(candidate);
|
||||
const inside = resolvedRoots.some(
|
||||
return resolvedRoots.some(
|
||||
(root) => resolved === root || resolved.startsWith(root + path.sep)
|
||||
);
|
||||
if (!inside) {
|
||||
};
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!isInsideRoots(candidate)) {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// GHSA-xfvx: source/manifestPath containment alone isn't enough — the
|
||||
// manifest FILE (which just passed containment above) can itself carry a
|
||||
// `database.backup_file` field that restoreService's candidate resolution
|
||||
// used to hand straight to `sqlite3 .restore` with no containment check at
|
||||
// all. Peek at the manifest here (it's already proven to live inside an
|
||||
// allowed root) and reject an ABSOLUTE backup_file that escapes the same
|
||||
// roots — the case that's unambiguous to check without re-deriving
|
||||
// restoreService's own `backupPath` resolution for the relative-path
|
||||
// candidates. This is deliberately defense in depth, not the only gate:
|
||||
// restoreService.performDatabaseRestore independently re-derives and
|
||||
// enforces containment (including relative/`..` candidates) against
|
||||
// `backupPath` right before ever using the resolved path, and remains the
|
||||
// authoritative check for S3-sourced manifests (downloaded after this
|
||||
// pre-check runs).
|
||||
if (manifestPath && !isS3(manifestPath) && !isTypeToken(manifestPath)) {
|
||||
try {
|
||||
const raw = await fs.readFile(manifestPath, 'utf8');
|
||||
const trimmed = raw.trimStart();
|
||||
const parsed = (trimmed.startsWith('{') || trimmed.startsWith('['))
|
||||
? JSON.parse(raw)
|
||||
: null; // non-JSON (e.g. YAML) manifests are re-checked inside restoreService
|
||||
const dbBackupFile = parsed?.database?.backup_file;
|
||||
if (typeof dbBackupFile === 'string' && path.isAbsolute(dbBackupFile) && !isInsideRoots(dbBackupFile)) {
|
||||
logger.warn('Refusing restore: manifest database.backup_file escapes configured backup roots', {
|
||||
manifestPath, backupFile: dbBackupFile,
|
||||
});
|
||||
return 'Manifest database.backup_file must be inside a configured backup location';
|
||||
}
|
||||
} catch (_) {
|
||||
// Unreadable/corrupt/non-JSON manifest: let the normal restore flow
|
||||
// surface the real error (loadAndValidateManifest) instead of failing
|
||||
// this pre-check for an unrelated reason.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user