Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5907a0833 | |||
| f13d163c74 | |||
| 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.3-beta.0"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,71 @@ 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.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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ jest.mock('../../src/services/productUsageService', () =>
|
||||
'tick',
|
||||
'status',
|
||||
'dismiss',
|
||||
'markPromptShown',
|
||||
'enable',
|
||||
'disable',
|
||||
'abandon',
|
||||
@@ -129,6 +130,7 @@ const ROUTES = [
|
||||
['post', '/abandon'],
|
||||
['post', '/retry'],
|
||||
['post', '/dismiss'],
|
||||
['post', '/prompt-seen'],
|
||||
['get', '/preview'],
|
||||
['get', '/export'],
|
||||
['put', '/feedback-preferences'],
|
||||
@@ -178,6 +180,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,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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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,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();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
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.3-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",
|
||||
|
||||
+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();
|
||||
};
|
||||
@@ -1,9 +1,5 @@
|
||||
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');
|
||||
|
||||
@@ -32,100 +28,10 @@ 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' });
|
||||
}
|
||||
|
||||
// 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
|
||||
});
|
||||
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'
|
||||
});
|
||||
}
|
||||
const admin = await sessionAccess.admin(decoded);
|
||||
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 });
|
||||
}
|
||||
|
||||
// Add user info to request (enhanced with role)
|
||||
@@ -146,7 +52,10 @@ async function adminAuth(req, res, next) {
|
||||
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,15 @@
|
||||
const { mutationOriginAllowed } = require('../utils/requestOrigin');
|
||||
|
||||
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 jsonLike = contentType === 'application/json' || contentType.endsWith('+json');
|
||||
if (hasBody && !jsonLike && contentType !== 'multipart/form-data') {
|
||||
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
|
||||
}
|
||||
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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -109,7 +109,9 @@ const pdfLogoStorage = multer.diskStorage({
|
||||
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -73,7 +73,9 @@ const signedPdfStorage = multer.diskStorage({
|
||||
|
||||
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 };
|
||||
|
||||
@@ -31,7 +31,9 @@ const eventLogoStorage = multer.diskStorage({
|
||||
|
||||
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
|
||||
|
||||
@@ -153,7 +153,10 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
// CVE-2026-82333: single unnamed field (`logo` or `watermarkLogo`) per
|
||||
// route — 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) => {
|
||||
// Note: SVG files are excluded from magic number validation for logos
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
@@ -181,7 +184,9 @@ const faviconStorage = multer.diskStorage({
|
||||
|
||||
const faviconUpload = multer({
|
||||
storage: faviconStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB — roomy enough for a 512×512+ square PNG
|
||||
// CVE-2026-82333: single unnamed `favicon` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 2 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 2MB — roomy enough for a 512×512+ square PNG
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimeTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
|
||||
const name = file.originalname.toLowerCase();
|
||||
|
||||
@@ -204,7 +204,9 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
|
||||
// cannot clobber each other, and writes thumbnail_path back itself.
|
||||
// Nulling thumbnail_path is what stops it short-circuiting on
|
||||
// isThumbnailValid — the same trick /regenerate-previews uses.
|
||||
const newThumbnailPath = await ensureThumbnail({ ...photo, thumbnail_path: null });
|
||||
// `force` is what stops it joining a lazy generation that is still
|
||||
// running under the OLD settings and adopting that result (#1020).
|
||||
const newThumbnailPath = await ensureThumbnail({ ...photo, thumbnail_path: null }, { force: true });
|
||||
|
||||
if (newThumbnailPath) {
|
||||
// Drop the superseded canonical rendition when the key MOVED.
|
||||
@@ -293,7 +295,8 @@ router.post('/regenerate-previews', adminAuth, requirePermission('photos.edit'),
|
||||
// is precisely the case this endpoint exists for (a replaced
|
||||
// reference source, or a corrupted rendition).
|
||||
await require('../services/imageProcessor').deletePreviewTiers(photo);
|
||||
const newPreviewPath = await ensurePreviewImage({ ...photo, preview_path: null });
|
||||
// `force`: never adopt a lazy generation already in flight (#1020).
|
||||
const newPreviewPath = await ensurePreviewImage({ ...photo, preview_path: null }, { force: true });
|
||||
if (newPreviewPath) {
|
||||
successCount++;
|
||||
} else {
|
||||
|
||||
@@ -50,7 +50,10 @@ const tempStorage = multer.diskStorage({
|
||||
function buildAdminUploader(maxSizeBytes, allowed) {
|
||||
return multer({
|
||||
storage: tempStorage,
|
||||
limits: { fileSize: maxSizeBytes, files: ADMIN_MAX_FILES },
|
||||
// CVE-2026-82333: files arrive as repeated `files` parts via .array(),
|
||||
// not bracket-indexed field names like `files[0]` — no legitimate
|
||||
// field name uses array-index syntax at all. Reject any that do.
|
||||
limits: { fileSize: maxSizeBytes, files: ADMIN_MAX_FILES, fieldArrayIndexLimit: 0 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
|
||||
return cb(new Error('This file type is not allowed'));
|
||||
|
||||
@@ -73,6 +73,13 @@ router.post(
|
||||
'/dismiss',
|
||||
wrap(async (_req, res) => res.json(await service.dismiss()))
|
||||
);
|
||||
// Acknowledges the one-time opt-in prompt (setup wizard or the post-update
|
||||
// modal) regardless of whether the admin enabled or declined — either way it
|
||||
// must not ask this installation again.
|
||||
router.post(
|
||||
'/prompt-seen',
|
||||
wrap(async (_req, res) => res.json(await service.markPromptShown()))
|
||||
);
|
||||
router.post(
|
||||
'/enable',
|
||||
wrap(async (req, res) =>
|
||||
|
||||
+40
-102
@@ -1,6 +1,8 @@
|
||||
const { isGalleryAvailable } = require('../utils/galleryLifecycle');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
|
||||
const { db, logActivity } = require('../database/db');
|
||||
@@ -354,7 +356,9 @@ router.post('/logout', async (req, res) => {
|
||||
|
||||
if (token) {
|
||||
// Revoke the token so it can't be reused, then end the session
|
||||
await revokeToken(token, 'user_logout');
|
||||
if (!await revokeToken(token, 'user_logout')) {
|
||||
throw new Error('Token revocation failed');
|
||||
}
|
||||
endSession(token);
|
||||
|
||||
try {
|
||||
@@ -398,6 +402,8 @@ router.post('/logout', async (req, res) => {
|
||||
|
||||
res.json({ message: 'Logged out successfully', ...(ssoLogoutUrl ? { ssoLogoutUrl } : {}) });
|
||||
} catch (error) {
|
||||
clearAdminAuthCookie(res);
|
||||
clearGalleryAuthCookies(res);
|
||||
errorResponse(res, error, 500, 'Logout failed');
|
||||
}
|
||||
});
|
||||
@@ -420,7 +426,7 @@ router.post('/gallery/verify', [
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
if (!isGalleryAvailable(event)) {
|
||||
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
|
||||
await bcrypt.compare(password || '', DUMMY_BCRYPT_HASH);
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
@@ -496,6 +502,9 @@ router.post('/gallery/verify', [
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery',
|
||||
// Unique per token: the revocation key falls back to eventId+iat otherwise,
|
||||
// so one guest's logout would revoke every same-second login (#1357).
|
||||
jti: crypto.randomUUID(),
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
@@ -545,7 +554,7 @@ router.post('/gallery/:slug/client-login', [
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event || !event.client_access_enabled || !event.client_password_hash) {
|
||||
if (!isGalleryAvailable(event) || !event.client_access_enabled || !event.client_password_hash) {
|
||||
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
@@ -570,6 +579,9 @@ router.post('/gallery/:slug/client-login', [
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery',
|
||||
// Unique per token: the revocation key falls back to eventId+iat otherwise,
|
||||
// so one guest's logout would revoke every same-second login (#1357).
|
||||
jti: crypto.randomUUID(),
|
||||
accessLevel: 'client',
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
@@ -631,14 +643,14 @@ router.post('/gallery/share-login', [
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
if (!isGalleryAvailable(event)) {
|
||||
const resolved = await resolveShareIdentifier(slug);
|
||||
if (resolved?.event) {
|
||||
event = resolved.event;
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
if (!isGalleryAvailable(event)) {
|
||||
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
@@ -666,6 +678,9 @@ router.post('/gallery/share-login', [
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery',
|
||||
// Unique per token: the revocation key falls back to eventId+iat otherwise,
|
||||
// so one guest's logout would revoke every same-second login (#1357).
|
||||
jti: crypto.randomUUID(),
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
@@ -703,11 +718,14 @@ router.post('/gallery/logout', async (req, res) => {
|
||||
const { slug } = req.body || {};
|
||||
const token = getGalleryTokenFromRequest(req, slug);
|
||||
if (token) {
|
||||
await revokeToken(token, 'gallery_logout');
|
||||
if (!await revokeToken(token, 'gallery_logout')) {
|
||||
throw new Error('Token revocation failed');
|
||||
}
|
||||
}
|
||||
clearGalleryAuthCookies(res, slug);
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
clearGalleryAuthCookies(res, req.body?.slug);
|
||||
errorResponse(res, error, 500, 'Logout failed');
|
||||
}
|
||||
});
|
||||
@@ -743,106 +761,26 @@ router.get('/session', async (req, res) => {
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
// Check if token has been revoked (e.g. after logout)
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
return res.status(401).json({ valid: false, error: 'Session has been invalidated' });
|
||||
}
|
||||
|
||||
// The redirect loop reported on the v3.32.4-beta.0 release came
|
||||
// from /auth/session reporting valid: true while the protected
|
||||
// adminAuth / galleryAuth middleware rejected the same token for
|
||||
// reasons /auth/session never checked: the admin user was
|
||||
// deactivated, the admin's password had been changed since iat,
|
||||
// or the gallery event was archived/deleted. Mirror those checks
|
||||
// here so the session endpoint is always at least as strict as
|
||||
// what the protected endpoints will enforce next.
|
||||
// Full user payload for admin sessions — the SSO callback establishes
|
||||
// the session via redirect (no JSON response the SPA could store), so
|
||||
// session restoration must be able to hydrate the user object (#798).
|
||||
const sessions = require('../services/sessionAccessService');
|
||||
let adminUser = null;
|
||||
|
||||
if (decoded.type === 'admin') {
|
||||
let admin = null;
|
||||
try {
|
||||
admin = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
|
||||
.select(
|
||||
'admin_users.id', 'admin_users.username', 'admin_users.email',
|
||||
'admin_users.password_changed_at', 'admin_users.must_change_password',
|
||||
'roles.name as role_name', 'roles.display_name as role_display_name'
|
||||
)
|
||||
.first();
|
||||
} catch (lookupErr) {
|
||||
// admin_users table not present (test fixture, fresh DB) — fall
|
||||
// through and trust the token. Real deployments always have it.
|
||||
admin = null;
|
||||
// intentional swallow; if the table is missing we do not want
|
||||
// to fail-closed during e.g. early bootstrap.
|
||||
}
|
||||
|
||||
if (admin === null) {
|
||||
// Lookup didn't run because the table is missing; skip the
|
||||
// existence/password checks and treat the token as valid.
|
||||
} else if (!admin) {
|
||||
return res.json({ valid: false, error: 'Admin account no longer active' });
|
||||
} else if (admin.password_changed_at) {
|
||||
const passwordChangedSeconds = Math.floor(
|
||||
new Date(admin.password_changed_at).getTime() / 1000
|
||||
);
|
||||
if (decoded.iat < passwordChangedSeconds) {
|
||||
return res.json({ valid: false, error: 'Token invalid due to password change' });
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the session-timeout check that sessionTimeoutMiddleware
|
||||
// enforces on every /api/admin endpoint. Without this, /auth/session
|
||||
// returns valid:true for an idle/old-iat token that protected
|
||||
// endpoints reject with 401 SESSION_TIMEOUT — the same redirect-loop
|
||||
// shape as the issuer-claim and password-change asymmetries (issue
|
||||
// #350 recurrence on v3.39.1-beta.0).
|
||||
try {
|
||||
const { isSessionExpired } = require('../middleware/sessionTimeout');
|
||||
if (await isSessionExpired(token, decoded)) {
|
||||
return res.json({ valid: false, error: 'Session expired' });
|
||||
}
|
||||
} catch (timeoutErr) {
|
||||
// Helper lookup failed (test stub may not export it) — fall through
|
||||
// and trust the token. Real deployments always have the middleware.
|
||||
}
|
||||
|
||||
if (admin) {
|
||||
adminUser = {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : null
|
||||
};
|
||||
const admin = await sessions.admin(decoded, { includeProfile: true });
|
||||
const { isSessionExpired } = require('../middleware/sessionTimeout');
|
||||
if (await isSessionExpired(token, decoded)) {
|
||||
return res.json({ valid: false, error: 'Session expired' });
|
||||
}
|
||||
adminUser = {
|
||||
id: admin.id, username: admin.username, email: admin.email,
|
||||
mustChangePassword: !!admin.must_change_password,
|
||||
role: admin.role_name ? { name: admin.role_name, displayName: admin.role_display_name } : null,
|
||||
};
|
||||
} else if (decoded.type === 'gallery') {
|
||||
try {
|
||||
const event = await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false),
|
||||
})
|
||||
.first();
|
||||
if (!event) {
|
||||
return res.json({ valid: false, error: 'Gallery no longer available' });
|
||||
}
|
||||
if (event.expires_at && new Date(event.expires_at) < new Date()) {
|
||||
return res.json({ valid: false, error: 'Gallery has expired' });
|
||||
}
|
||||
} catch (galleryLookupErr) {
|
||||
// events table missing in this context — same fallback as
|
||||
// admin path; trust the token rather than fail-closed.
|
||||
}
|
||||
const access = require('../services/galleryAccessService');
|
||||
const event = await db('events').where({ id: decoded.eventId }).first();
|
||||
if (!event) return res.json({ valid: false, error: 'Gallery no longer available' });
|
||||
await access.authorize(event, access.grant(event, 'gallery', decoded));
|
||||
} else {
|
||||
return res.status(403).json({ valid: false, error: 'Invalid token type' });
|
||||
}
|
||||
|
||||
// Calculate remaining time
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { isGalleryAvailable, isGalleryExpired } = require('../utils/galleryLifecycle');
|
||||
/**
|
||||
* Customer dashboard routes
|
||||
*
|
||||
@@ -14,6 +15,7 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { getBcryptRounds, MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
|
||||
@@ -165,10 +167,12 @@ router.get('/events/:slug/access-token', [
|
||||
if (event.is_archived) {
|
||||
return res.status(410).json({ error: 'This gallery has been archived' });
|
||||
}
|
||||
if (event.expires_at && new Date(event.expires_at) < new Date()) {
|
||||
if (isGalleryExpired(event)) {
|
||||
return res.status(410).json({ error: 'This gallery has expired' });
|
||||
}
|
||||
|
||||
if (!isGalleryAvailable(event)) return res.status(404).json({ error: 'Event not found' });
|
||||
|
||||
const hasAccess = await customerAccountsService.customerHasAccessToEvent(
|
||||
req.customer.id,
|
||||
event.id
|
||||
@@ -189,10 +193,12 @@ router.get('/events/:slug/access-token', [
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery',
|
||||
// Unique per token: the revocation key falls back to eventId+iat otherwise,
|
||||
// so one guest's logout would revoke every same-second login (#1357).
|
||||
jti: crypto.randomUUID(),
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now(),
|
||||
// Optional bookkeeping claim — surfaces the originating customer in
|
||||
// logs when the token is later used. Doesn't affect authorization.
|
||||
// Rechecked on each gallery/media request, including account status.
|
||||
via: 'customer',
|
||||
customerId: req.customer.id,
|
||||
}, process.env.JWT_SECRET, {
|
||||
|
||||
@@ -181,7 +181,9 @@ router.post('/logout', async (req, res) => {
|
||||
try {
|
||||
const token = getCustomerTokenFromRequest(req);
|
||||
if (token) {
|
||||
await revokeToken(token, 'user_logout');
|
||||
if (!await revokeToken(token, 'user_logout')) {
|
||||
throw new Error('Token revocation failed');
|
||||
}
|
||||
}
|
||||
clearCustomerAuthCookie(res);
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
|
||||
+10
-3359
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,945 @@
|
||||
const express = require('express');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
const archiver = require('archiver');
|
||||
const path = require('path');
|
||||
const { resolvePhotoContentType } = require('../../utils/photoContentType');
|
||||
const router = express.Router();
|
||||
const watermarkService = require('../../services/watermarkService');
|
||||
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
|
||||
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||
const logger = require('../../utils/logger');
|
||||
const { pipeStreamToResponse } = require('../../utils/streamResponse');
|
||||
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../../services/photoResolver');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { blockHiddenGallery } = require('../../utils/revealMode');
|
||||
const downloadZipService = require('../../services/downloadZipService');
|
||||
const { renderPhotoForDownload, resolveWatermarkSettings } = require('../../services/downloadRendition');
|
||||
const downloadJobService = require('../../services/downloadJobService');
|
||||
const {
|
||||
resolveEventDownloadPolicy,
|
||||
pickRequestedResolution,
|
||||
parseResolution,
|
||||
} = require('../../utils/downloadResolutions');
|
||||
const { applyPhotoVisibilityFilter, canSeeHiddenPhotos } = require('../../utils/photoVisibility');
|
||||
const {
|
||||
getUseOriginalFilenames,
|
||||
pickRawDownloadName,
|
||||
getZipEntryNames,
|
||||
} = require('../../services/downloadFilenameService');
|
||||
const { buildContentDisposition } = require('../../utils/filenameSanitizer');
|
||||
const { getStorage } = require('../../services/storage');
|
||||
const fs = require('fs');
|
||||
function parseByteRange(header, size) {
|
||||
if (!header || typeof header !== 'string' || !size) return null;
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const [, rawStart, rawEnd] = match;
|
||||
if (rawStart === '' && rawEnd === '') return null;
|
||||
|
||||
let start;
|
||||
let end;
|
||||
if (rawStart === '') {
|
||||
// Suffix form: the last N bytes.
|
||||
const suffix = parseInt(rawEnd, 10);
|
||||
if (!suffix) return null;
|
||||
start = Math.max(0, size - suffix);
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = parseInt(rawStart, 10);
|
||||
end = rawEnd === '' ? size - 1 : parseInt(rawEnd, 10);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
if (start > end || start >= size) return null;
|
||||
return { start, end: Math.min(end, size - 1) };
|
||||
}
|
||||
function galleryActor(req) {
|
||||
// Portal tokens run as accessLevel 'guest' but carry via:'customer'
|
||||
// (req.viaCustomer); PIN-client logins carry accessLevel 'client'.
|
||||
// Both are customers, not guests (codex review of #849, final round).
|
||||
const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client'));
|
||||
return { type: isCustomer ? 'customer' : 'guest' };
|
||||
}
|
||||
const SINGLE_DOWNLOAD_DEBOUNCE_MS = 60 * 60 * 1000;
|
||||
const singleDownloadNotifiedAt = new Map();
|
||||
function notifySinglePhotoDownload(event, req) {
|
||||
const now = Date.now();
|
||||
const last = singleDownloadNotifiedAt.get(event.id) || 0;
|
||||
if (now - last < SINGLE_DOWNLOAD_DEBOUNCE_MS) return;
|
||||
singleDownloadNotifiedAt.set(event.id, now);
|
||||
logActivity('gallery_downloaded', { scope: 'single' }, event.id, galleryActor(req));
|
||||
}
|
||||
|
||||
router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
// Check if downloads are allowed for this event
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Per-category download permission (#640). Photos without a category are
|
||||
// always downloadable when the event allows downloads — only categorised
|
||||
// photos can opt out per-category.
|
||||
if (photo.category_id) {
|
||||
const cat = await db('photo_categories')
|
||||
.where('id', photo.category_id)
|
||||
.first('allow_downloads');
|
||||
if (cat && !parseBooleanInput(cat.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this category' });
|
||||
}
|
||||
}
|
||||
|
||||
// Download resolution (#858). Resolved BEFORE the counters below: a
|
||||
// rejected resolution must not inflate download stats, which a guest
|
||||
// could otherwise do by replaying ?resolution=bogus.
|
||||
const isVideo = photo.media_type === 'video'
|
||||
|| (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
const policy = await resolveEventDownloadPolicy(req.event);
|
||||
const requested = pickRequestedResolution(policy, req.query.resolution);
|
||||
if (requested === null) {
|
||||
return res.status(400).json({ error: 'Resolution not available for this gallery' });
|
||||
}
|
||||
const box = isVideo ? null : parseResolution(requested);
|
||||
|
||||
// A HEAD is a metadata probe, not a download. Answering it below the
|
||||
// counters recorded every probe as a real download, and answering it below
|
||||
// renderPhotoForDownload fetched and watermarked an image whose body Node
|
||||
// then discards. Both happen before this point in a GET, so HEAD leaves
|
||||
// here — with no side effects and no bytes read.
|
||||
if (req.method === 'HEAD') {
|
||||
const headUseOriginal = await getUseOriginalFilenames();
|
||||
const headHeaders = {
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': buildContentDisposition(pickRawDownloadName(photo, headUseOriginal)),
|
||||
'Accept-Ranges': 'bytes',
|
||||
};
|
||||
|
||||
// Content-Length only when the bytes ship untransformed AND the size can
|
||||
// be read without fetching them. A watermark or resize changes the
|
||||
// length, and the only way to learn the new one is to do the work this
|
||||
// branch exists to avoid — HEAD is allowed to omit it.
|
||||
const headWatermark = await resolveWatermarkSettings(req.event);
|
||||
if (!box && !headWatermark) {
|
||||
try {
|
||||
const headKey = resolvePhotoStorageKey(req.event, photo);
|
||||
const headStorage = getStorage();
|
||||
if (headKey && headStorage.kind() !== 'local') {
|
||||
const headStat = await headStorage.stat(headKey);
|
||||
if (!headStat) return res.status(404).json({ error: 'Photo file not found' });
|
||||
headHeaders['Content-Length'] = headStat.size;
|
||||
if (headStat.mtime) headHeaders['Last-Modified'] = new Date(headStat.mtime).toUTCString();
|
||||
}
|
||||
} catch (headErr) {
|
||||
// No length is a valid HEAD; not worth failing the probe over.
|
||||
logger.debug('HEAD probe could not stat the object', { photoId, error: headErr.message });
|
||||
}
|
||||
}
|
||||
|
||||
res.set(headHeaders);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
// Admin preview (#868) downloads are excluded from the download count +
|
||||
// guest analytics — kept out of client-facing stats.
|
||||
if (!req.isAdminPreview) {
|
||||
// Update download count
|
||||
await db('photos').where('id', photoId).increment('download_count', 1);
|
||||
|
||||
// Log download
|
||||
await db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download',
|
||||
photo_id: photoId
|
||||
});
|
||||
}
|
||||
// Surface in the admin notification bell (#746) — debounced, and only
|
||||
// once the response actually finished: notifying up-front would log a
|
||||
// download that then 404s/fails and the debounce would suppress the
|
||||
// next real one for an hour (codex review of #849).
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode < 400 && !req.isAdminPreview) notifySinglePhotoDownload(req.event, req);
|
||||
});
|
||||
|
||||
// #493: if the admin enabled "use original filenames", surface the
|
||||
// pre-rename camera filename in Content-Disposition. Storage path is
|
||||
// unchanged — only the user-visible download name is swapped.
|
||||
const useOriginal = await getUseOriginalFilenames();
|
||||
const downloadName = pickRawDownloadName(photo, useOriginal);
|
||||
const contentDisposition = buildContentDisposition(downloadName);
|
||||
|
||||
// The gallery's standard applies to EVERY ordinary download, single photos
|
||||
// included — otherwise a lowered standard is trivially bypassed by
|
||||
// downloading photos one at a time. `box` was resolved above, before the
|
||||
// counters. Videos have no resize path and always ship as-is.
|
||||
//
|
||||
// renderPhotoForDownload (#858) owns the resize-then-watermark ordering
|
||||
// and the storage fetch, and is what the zip builders below already use.
|
||||
// It returns null when the photo needs no transformation at all, which is
|
||||
// the default gallery's common case and lets us ship the stored bytes
|
||||
// without buffering a full-size original into memory.
|
||||
const effectiveSettings = await resolveWatermarkSettings(req.event);
|
||||
|
||||
let rendered;
|
||||
try {
|
||||
rendered = await renderPhotoForDownload(req.event, photo, box, effectiveSettings);
|
||||
} catch (renderError) {
|
||||
// Classify, the same way the pass-through branch below does. This can
|
||||
// reject because the source object is gone, but equally because
|
||||
// getToFile timed out, the tmp filesystem filled up, or sharp failed —
|
||||
// and reporting an operational failure as 404 tells the guest their
|
||||
// photo does not exist and tells us nothing.
|
||||
const gone = renderError.code === 'ENOENT'
|
||||
|| renderError.name === 'NoSuchKey'
|
||||
|| renderError.name === 'NotFound'
|
||||
|| renderError.$metadata?.httpStatusCode === 404;
|
||||
logger.error('Failed to render photo for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: renderError.message,
|
||||
});
|
||||
return gone
|
||||
? res.status(404).json({ error: 'Photo file not found' })
|
||||
: res.status(500).json({ error: 'Failed to download photo' });
|
||||
}
|
||||
|
||||
if (rendered) {
|
||||
res.set({
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': contentDisposition,
|
||||
'Content-Length': rendered.length
|
||||
});
|
||||
|
||||
return res.send(rendered);
|
||||
}
|
||||
|
||||
// Untransformed: ship the stored bytes.
|
||||
//
|
||||
// Managed photos live behind the storage abstraction and on an S3/R2
|
||||
// deployment are not on local disk at all — resolving a filesystem path
|
||||
// unconditionally here is what made every single-photo download 404 with
|
||||
// ENOENT in S3 mode (#1048), while download-all and secure-images worked
|
||||
// because they already went through getStorage().
|
||||
//
|
||||
// resolvePhotoStorageKey returns null for external/reference photos: those
|
||||
// live on a local mount and keep the sendFile path.
|
||||
let storageKey = null;
|
||||
try {
|
||||
storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo storage key for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
if (storageKey && storage.kind() !== 'local') {
|
||||
// Deliberately NOT the local path: res.sendFile emits Content-Length,
|
||||
// Accept-Ranges, ETag and Last-Modified and answers Range requests with
|
||||
// a 206, and a bare stream.pipe(res) has none of that. On local disk
|
||||
// sendFile stays the better implementation, so it stays the branch.
|
||||
//
|
||||
// On S3 we reproduce the parts that matter for a download: the length
|
||||
// (browsers need it for the progress indicator, which matters most on
|
||||
// exactly the large files this route serves) and Range, so an
|
||||
// interrupted download resumes instead of appending a second full body
|
||||
// onto the partial file. Conditional requests are not reproduced —
|
||||
// there is no ETag here, so a client revalidating gets the whole body,
|
||||
// same as it does today.
|
||||
const stat = await storage.stat(storageKey);
|
||||
if (!stat) {
|
||||
logger.error('Photo not found in storage backend for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
storageKey,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
const lastModified = stat.mtime ? new Date(stat.mtime).toUTCString() : null;
|
||||
const headers = {
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': contentDisposition,
|
||||
'Accept-Ranges': 'bytes',
|
||||
};
|
||||
if (lastModified) headers['Last-Modified'] = lastModified;
|
||||
|
||||
// If-Range: a client resuming an interrupted download sends back the
|
||||
// validator it was given last time. If the object has been replaced
|
||||
// since — the watcher re-importing a swapped file, an admin re-upload —
|
||||
// answering 206 from the NEW bytes lets the client splice two different
|
||||
// versions into one corrupt file. A validator that doesn't match means
|
||||
// a full 200, which is the whole point of the header.
|
||||
const ifRange = req.headers['if-range'];
|
||||
const staleValidator = !!ifRange && (!lastModified || ifRange.trim() !== lastModified);
|
||||
const range = staleValidator ? null : parseByteRange(req.headers.range, stat.size);
|
||||
|
||||
// Open the stream BEFORE any header is staged or sent. stat() succeeding
|
||||
// does not mean get() will: a concurrent delete or replace, or a
|
||||
// transient backend error, lands here. Once writeHead(206) has gone out
|
||||
// the outer catch can do nothing but throw ERR_HTTP_HEADERS_SENT, and in
|
||||
// the non-range case it would send its 500 JSON underneath the staged
|
||||
// image/jpeg attachment headers — a .jpg file full of JSON.
|
||||
let stream;
|
||||
try {
|
||||
stream = range
|
||||
? await storage.getRange(storageKey, range.start, range.end)
|
||||
: await storage.get(storageKey);
|
||||
} catch (fetchError) {
|
||||
const gone = fetchError.code === 'ENOENT'
|
||||
|| fetchError.name === 'NoSuchKey'
|
||||
|| fetchError.name === 'NotFound'
|
||||
|| fetchError.$metadata?.httpStatusCode === 404;
|
||||
logger.error('Failed to open photo stream for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
storageKey,
|
||||
error: fetchError.message,
|
||||
});
|
||||
return gone
|
||||
? res.status(404).json({ error: 'Photo file not found' })
|
||||
: res.status(500).json({ error: 'Failed to download photo' });
|
||||
}
|
||||
|
||||
if (range) {
|
||||
// status()+set() rather than writeHead(): writeHead commits the
|
||||
// response immediately, so a stream that resolves and THEN errors
|
||||
// before its first chunk would leave pipeStreamToResponse able only to
|
||||
// destroy the connection. Staged headers are flushed by the first body
|
||||
// write, which means an error at byte zero can still clear them and
|
||||
// return a clean, retryable status instead of a transport reset.
|
||||
res.status(206).set({
|
||||
...headers,
|
||||
'Content-Range': `bytes ${range.start}-${range.end}/${stat.size}`,
|
||||
'Content-Length': (range.end - range.start) + 1,
|
||||
});
|
||||
} else {
|
||||
res.set({ ...headers, 'Content-Length': stat.size });
|
||||
}
|
||||
pipeStreamToResponse(stream, res, {
|
||||
context: range ? `download range for photo ${photo.id}` : `download for photo ${photo.id}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath;
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// res.download() builds Content-Disposition itself but doesn't emit the
|
||||
// RFC 5987 filename* parameter, so unicode camera filenames would lose
|
||||
// their bytes on download. Set the header explicitly and stream the
|
||||
// file with res.sendFile-equivalent semantics.
|
||||
res.set({
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': contentDisposition,
|
||||
});
|
||||
res.sendFile(filePath, (downloadError) => {
|
||||
if (downloadError) {
|
||||
logger.error('Error streaming gallery download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: downloadError.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to download photo');
|
||||
}
|
||||
});
|
||||
|
||||
// Download all photos as ZIP
|
||||
// Zip downloads count toward each contained photo's download_count (#895)
|
||||
// — previously only single-photo downloads did, so galleries whose guests
|
||||
// grab the zip showed 0 per-photo downloads forever. Used by the
|
||||
// pre-generated-zip branches only: it mirrors downloadZipService._build,
|
||||
// which zips EVERY event photo with no per-category allow_downloads
|
||||
// filter — the counter has to reflect what actually shipped. (That the
|
||||
// prebuilt zip ignores per-category download opt-outs is a separate,
|
||||
// pre-existing issue.) Known approximation: _build skips entries whose
|
||||
// WATERMARK step fails and still publishes the zip; counting those
|
||||
// would need a persisted archive manifest, which isn't worth it for
|
||||
// that tail case. Fire-and-forget at the call sites: counters must
|
||||
// never fail a download.
|
||||
async function bumpEventDownloadCounts(eventId) {
|
||||
await db('photos').where('event_id', eventId).increment('download_count', 1);
|
||||
}
|
||||
|
||||
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||
try {
|
||||
// Check if downloads are allowed for this event
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
// Try to serve pre-generated zip (instant download with Content-Length).
|
||||
// Guests may use the prebuilt cache ONLY when the event has no hidden
|
||||
// photos: a cache built before a photo was hidden — or before this
|
||||
// visibility-aware builder shipped — could otherwise still leak it, and
|
||||
// getZipInfo only checks the DB pointer + file stat, not freshness. When
|
||||
// hidden photos exist, guests fall through to the visibility-filtered
|
||||
// stream below. PIN-clients always stream a full archive.
|
||||
const isClient = canSeeHiddenPhotos(req.accessLevel);
|
||||
const eventHasHidden = await db('photos')
|
||||
.where({ event_id: req.event.id, visibility: 'hidden' })
|
||||
.first()
|
||||
.then(Boolean);
|
||||
const zipInfo = (isClient || eventHasHidden)
|
||||
? null
|
||||
: await downloadZipService.getZipInfo(req.event.id);
|
||||
if (zipInfo) {
|
||||
const storage = getStorage();
|
||||
|
||||
// Stream via the authenticated route so logout, restore and account
|
||||
// changes are checked on every download, including S3-backed archives.
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Length', zipInfo.size);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
|
||||
const stream = await storage.get(zipInfo.key);
|
||||
pipeStreamToResponse(stream, res, { context: `prepared zip for event ${req.event.id}`, missingStatus: 410 });
|
||||
|
||||
// Log bulk download (admin preview #868 excluded — stats stay client-only).
|
||||
if (!req.isAdminPreview) {
|
||||
db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_all'
|
||||
}).catch(() => {});
|
||||
bumpEventDownloadCounts(req.event.id).catch(() => {});
|
||||
// Surface in the admin notification bell (#746) — only once the
|
||||
// stream actually finished; logging at pipe-time would report
|
||||
// downloads that then broke mid-transfer (codex review of #849).
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: on-the-fly streaming (existing behavior). Only pre-build the
|
||||
// guest cache when it will actually be served next time — a guest
|
||||
// download of an event with no hidden photos. Client bypasses and
|
||||
// hidden-photo events always stream, so rebuilding the guest archive on
|
||||
// those requests is wasted I/O (codex review).
|
||||
if (!isClient && !eventHasHidden) {
|
||||
downloadZipService.generateZip(req.event.id).catch(err =>
|
||||
logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message })
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch photos — exclude photos in categories that disabled downloads (#640).
|
||||
// Uncategorised photos are always included; categories without the column
|
||||
// (pre-migration-135) fall through the LEFT JOIN's null and are included.
|
||||
const photos = await applyPhotoVisibilityFilter(
|
||||
db('photos')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.where('photos.event_id', req.event.id)
|
||||
.where(function () {
|
||||
this.whereNull('photos.category_id')
|
||||
.orWhere('photo_categories.allow_downloads', true)
|
||||
.orWhereNull('photo_categories.allow_downloads');
|
||||
}),
|
||||
req.accessLevel
|
||||
)
|
||||
.select('photos.*')
|
||||
.orderBy('photos.type', 'asc')
|
||||
.orderBy('photos.uploaded_at', 'desc');
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.status(404).json({ error: 'No photos found' });
|
||||
}
|
||||
|
||||
// Count unique types
|
||||
const uniqueTypes = new Set(photos.map(p => p.type)).size;
|
||||
const hasMultipleTypes = uniqueTypes > 1;
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||
archive.on('error', (err) => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
archive.pipe(res);
|
||||
|
||||
// Get watermark settings - apply if global setting OR event-level setting is enabled
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
|
||||
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
|
||||
const effectiveSettings = shouldApplyWatermark ? {
|
||||
...watermarkSettings,
|
||||
enabled: true,
|
||||
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
||||
} : null;
|
||||
|
||||
// The gallery's standard resolution applies to the streamed archive too,
|
||||
// not only the cached one (#858).
|
||||
const { standardBox: bulkBox } = await resolveEventDownloadPolicy(req.event);
|
||||
|
||||
// Add photos to archive — managed photos via storage backend, external via local path.
|
||||
const { resolvePhotoStorageKey } = require('../../services/photoResolver');
|
||||
const storage = getStorage();
|
||||
// #493: resolve a unique display filename per photo up-front so collisions
|
||||
// get a deterministic `_1` suffix before the entries hit the archive.
|
||||
const useOriginalBulk = await getUseOriginalFilenames();
|
||||
const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk);
|
||||
// Only photos whose append succeeded count as downloaded (#895) — the
|
||||
// catch below deliberately skips missing/corrupt sources, and those
|
||||
// never make it into the archive.
|
||||
const appendedIds = [];
|
||||
for (let i = 0; i < photos.length; i += 1) {
|
||||
const photo = photos[i];
|
||||
const storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||
const entryName = bulkEntryNames[i];
|
||||
let archiveName;
|
||||
if (hasMultipleTypes) {
|
||||
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
||||
archiveName = path.join(folderName, entryName);
|
||||
} else {
|
||||
archiveName = entryName;
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the source exists BEFORE appending — but only for local
|
||||
// sources: fs.createReadStream is lazy, so its error fires outside
|
||||
// this try/catch and the archive 'error' handler then kills the
|
||||
// whole response instead of skipping one photo (#895 review). S3's
|
||||
// get() awaits GetObject and rejects right here on a missing key,
|
||||
// so a preflight HEAD per entry would just be a redundant serial
|
||||
// round trip (500-photo zip = 500 extra HEADs).
|
||||
if (storageKey && storage.kind() === 'local') {
|
||||
const srcStat = await storage.stat(storageKey);
|
||||
if (!srcStat) {
|
||||
throw new Error(`Photo missing in storage: ${storageKey}`);
|
||||
}
|
||||
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
|
||||
throw new Error('Photo file missing on disk');
|
||||
}
|
||||
|
||||
// Resize to the gallery's standard resolution (#858) and/or watermark.
|
||||
// This branch runs whenever the cached zip isn't usable — the first
|
||||
// download after an invalidation, PIN clients, and galleries with
|
||||
// hidden photos all land here, so skipping the cap would leak
|
||||
// full-resolution files for exactly those cases.
|
||||
const rendered = await renderPhotoForDownload(req.event, photo, bulkBox, effectiveSettings);
|
||||
if (rendered) {
|
||||
archive.append(rendered, { name: archiveName });
|
||||
} else if (storageKey) {
|
||||
const stream = await storage.get(storageKey);
|
||||
archive.append(stream, { name: archiveName });
|
||||
} else {
|
||||
archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName });
|
||||
}
|
||||
appendedIds.push(photo.id);
|
||||
} catch (err) {
|
||||
logger.warn('Skipping photo in bulk download due to error', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Notification only after the response actually finished — finalize()
|
||||
// ends Archiver's input, not the HTTP transfer (codex review of #849,
|
||||
// confirmation round). Registered before finalize so it can't be missed.
|
||||
// Admin preview (#868) streams the archive but is excluded from stats.
|
||||
if (!req.isAdminPreview) {
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
|
||||
});
|
||||
}
|
||||
await archive.finalize();
|
||||
|
||||
if (!req.isAdminPreview) {
|
||||
// Log bulk download
|
||||
await db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_all'
|
||||
});
|
||||
// Exactly the photos that made it into this archive (#895) — skipped
|
||||
// (missing/corrupt) sources don't count.
|
||||
if (appendedIds.length > 0) {
|
||||
db('photos').whereIn('id', appendedIds)
|
||||
.increment('download_count', 1).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to create download archive');
|
||||
}
|
||||
});
|
||||
|
||||
// Download selected photos as ZIP
|
||||
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||
try {
|
||||
// Check if downloads are allowed for this event
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
const ids = Array.isArray(req.body?.photo_ids) ? req.body.photo_ids : [];
|
||||
if (!ids.length) {
|
||||
return res.status(400).json({ error: 'photo_ids is required (non-empty array)' });
|
||||
}
|
||||
|
||||
// Clean IDs
|
||||
const photoIds = ids
|
||||
.map((v) => parseInt(v, 10))
|
||||
.filter((v) => Number.isInteger(v))
|
||||
.slice(0, 500);
|
||||
|
||||
if (photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'No valid photo IDs provided' });
|
||||
}
|
||||
|
||||
// Fetch photos — exclude photos in categories that disabled downloads (#640).
|
||||
// Same LEFT JOIN pattern as the download-all endpoint.
|
||||
const photos = await applyPhotoVisibilityFilter(
|
||||
db('photos')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.where('photos.event_id', req.event.id)
|
||||
.whereIn('photos.id', photoIds)
|
||||
.where(function () {
|
||||
this.whereNull('photos.category_id')
|
||||
.orWhere('photo_categories.allow_downloads', true)
|
||||
.orWhereNull('photo_categories.allow_downloads');
|
||||
}),
|
||||
req.accessLevel
|
||||
)
|
||||
.select('photos.*')
|
||||
.orderBy('photos.uploaded_at', 'desc');
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.status(404).json({ error: 'No photos found for selected IDs' });
|
||||
}
|
||||
|
||||
// Download resolution (#858). Resolve BEFORE any header goes out — once
|
||||
// the archive starts streaming we can no longer return a JSON error.
|
||||
const selectedPolicy = await resolveEventDownloadPolicy(req.event);
|
||||
const selectedResolution = pickRequestedResolution(selectedPolicy, req.body?.resolution);
|
||||
if (selectedResolution === null) {
|
||||
return res.status(400).json({ error: 'Resolution not available for this gallery' });
|
||||
}
|
||||
const selectedBox = parseResolution(selectedResolution);
|
||||
|
||||
const archiveName = `${req.event.slug}-selected.zip`;
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||
archive.on('error', (err) => {
|
||||
logger.error('Zip error generating selected download', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: err.message,
|
||||
});
|
||||
try {
|
||||
res.status(500).end();
|
||||
} catch (_) {
|
||||
// ignore double-send errors
|
||||
}
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
// Check watermark settings - apply if global setting OR event-level setting is enabled
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
|
||||
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
|
||||
const effectiveSettings = shouldApplyWatermark ? {
|
||||
...watermarkSettings,
|
||||
enabled: true,
|
||||
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
||||
} : null;
|
||||
|
||||
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../../services/photoResolver');
|
||||
const selectedStorage = getStorage();
|
||||
// #493: same display-name resolution as bulk download, with dedup.
|
||||
const useOriginalSelected = await getUseOriginalFilenames();
|
||||
const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected);
|
||||
// Only photos whose append succeeded count as downloaded (#895).
|
||||
const appendedIds = [];
|
||||
for (let i = 0; i < photos.length; i += 1) {
|
||||
const photo = photos[i];
|
||||
const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`;
|
||||
const storageKey = resolveSelectedKey(req.event, photo);
|
||||
try {
|
||||
// Same pre-append source check as download-all (#895 review),
|
||||
// local backend only: a lazy fs stream's async error would kill
|
||||
// the response instead of skipping the photo; S3's get() rejects
|
||||
// at the await below, so no redundant per-entry HEAD there.
|
||||
if (storageKey && selectedStorage.kind() === 'local') {
|
||||
const srcStat = await selectedStorage.stat(storageKey);
|
||||
if (!srcStat) {
|
||||
throw new Error(`Photo missing in storage: ${storageKey}`);
|
||||
}
|
||||
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
|
||||
throw new Error('Photo file missing on disk');
|
||||
}
|
||||
|
||||
// Resize (#858) and/or watermark. renderPhotoForDownload returns null
|
||||
// when neither applies, so the untransformed case still streams from
|
||||
// storage rather than buffering the whole photo.
|
||||
const rendered = await renderPhotoForDownload(req.event, photo, selectedBox, effectiveSettings);
|
||||
if (rendered) {
|
||||
archive.append(rendered, { name });
|
||||
} else if (storageKey) {
|
||||
const stream = await selectedStorage.get(storageKey);
|
||||
archive.append(stream, { name });
|
||||
} else {
|
||||
archive.file(resolvePhotoFilePath(req.event, photo), { name });
|
||||
}
|
||||
appendedIds.push(photo.id);
|
||||
} catch (err) {
|
||||
logger.warn('Skipping selected photo due to error', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// See download-all: notify only on response 'finish'.
|
||||
// Admin preview (#868) streams the archive but is excluded from stats.
|
||||
if (!req.isAdminPreview) {
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'selected', photo_count: photoIds.length }, req.event.id, galleryActor(req));
|
||||
});
|
||||
}
|
||||
await archive.finalize();
|
||||
|
||||
if (!req.isAdminPreview) {
|
||||
await db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_selected'
|
||||
});
|
||||
// Exactly the photos that made it into this archive (#895) — skipped
|
||||
// (missing/corrupt) sources don't count.
|
||||
if (appendedIds.length > 0) {
|
||||
db('photos').whereIn('id', appendedIds)
|
||||
.increment('download_count', 1).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to download selected photos');
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Custom-resolution download jobs (#858).
|
||||
//
|
||||
// The plain download-all is served from the pre-built cache at the gallery's
|
||||
// STANDARD resolution. Picking a different size has nothing to cache against,
|
||||
// and resizing a whole gallery inside one request would sit far past any
|
||||
// reverse-proxy timeout — so those archives are built as a job the client
|
||||
// polls. Same access rules as the download routes above.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Kick off (or join) a build. Returns the polling token.
|
||||
router.post('/:slug/download-jobs', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||
try {
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
const policy = await resolveEventDownloadPolicy(req.event);
|
||||
if (!policy.pickerEnabled) {
|
||||
return res.status(403).json({ error: 'Resolution choice is not enabled for this gallery' });
|
||||
}
|
||||
const resolution = pickRequestedResolution(policy, req.body?.resolution);
|
||||
if (resolution === null) {
|
||||
return res.status(400).json({ error: 'Resolution not available for this gallery' });
|
||||
}
|
||||
|
||||
// Optional subset. Absent = the whole visible gallery.
|
||||
let photoIds = null;
|
||||
if (Array.isArray(req.body?.photo_ids) && req.body.photo_ids.length) {
|
||||
photoIds = req.body.photo_ids
|
||||
.map((v) => parseInt(v, 10))
|
||||
.filter((v) => Number.isInteger(v))
|
||||
.slice(0, 500);
|
||||
if (photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'No valid photo IDs provided' });
|
||||
}
|
||||
}
|
||||
|
||||
let job;
|
||||
try {
|
||||
job = await downloadJobService.createJob({
|
||||
event: req.event,
|
||||
resolution,
|
||||
photoIds,
|
||||
accessLevel: req.accessLevel,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === 'NO_PHOTOS') {
|
||||
return res.status(404).json({ error: 'No photos available for this selection' });
|
||||
}
|
||||
if (err.code === 'BUSY') {
|
||||
return res.status(429).json({ error: 'Too many downloads are being prepared right now — please try again shortly' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
res.status(202).json({
|
||||
token: job.token,
|
||||
status: job.status,
|
||||
resolution: job.resolution,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to start download preparation');
|
||||
}
|
||||
});
|
||||
|
||||
// Poll. The token is unguessable, but it is never sufficient on its own —
|
||||
// verifyGalleryAccess still runs and the job must belong to THIS event.
|
||||
// no-store: a cached 'preparing' would strand the caller in a poll that can
|
||||
// never observe the job finishing.
|
||||
router.get('/:slug/download-jobs/:token', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, noStoreCache, async (req, res) => {
|
||||
try {
|
||||
const job = await downloadJobService.getStatus(req.params.token);
|
||||
if (!job || job.event_id !== req.event.id) {
|
||||
return res.status(404).json({ error: 'Download job not found' });
|
||||
}
|
||||
res.json({
|
||||
status: job.status,
|
||||
resolution: job.resolution,
|
||||
photo_count: job.photo_count || 0,
|
||||
size_bytes: job.size_bytes || null,
|
||||
error: job.status === 'failed' ? (job.error || 'Preparation failed') : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to read download job');
|
||||
}
|
||||
});
|
||||
|
||||
// Deliver the finished archive.
|
||||
router.get('/:slug/download-jobs/:token/file', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||
try {
|
||||
// Downloads can be switched off after a job was created — every other
|
||||
// download route re-checks this per request, so this one must too.
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
const job = await downloadJobService.getStatus(req.params.token);
|
||||
if (!job || job.event_id !== req.event.id) {
|
||||
return res.status(404).json({ error: 'Download job not found' });
|
||||
}
|
||||
// The token alone never grants access: the archive was built under one
|
||||
// visibility scope, and only a requester still in that scope may take it.
|
||||
// Without this, a leaked client token would hand hidden photos to a guest.
|
||||
if (job.visibility_scope !== downloadJobService.visibilityScopeFor(req.accessLevel)) {
|
||||
return res.status(404).json({ error: 'Download job not found' });
|
||||
}
|
||||
if (job.status !== 'ready' || !job.zip_path) {
|
||||
return res.status(409).json({ error: 'Download is not ready yet', status: job.status });
|
||||
}
|
||||
if (new Date(job.expires_at).getTime() <= Date.now()) {
|
||||
return res.status(410).json({ error: 'This download has expired — please request it again' });
|
||||
}
|
||||
// A photo hidden AFTER this archive was built is still inside it, and the
|
||||
// scope check above can't see that — both sides remain 'public'. Re-run
|
||||
// the visibility query over the packaged set before handing it over.
|
||||
if (!(await downloadJobService.isStillDeliverable(job, req.event, req.accessLevel))) {
|
||||
return res.status(409).json({
|
||||
error: 'This gallery changed since the download was prepared — please request it again',
|
||||
status: 'stale',
|
||||
});
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const stat = await storage.stat(job.zip_path);
|
||||
if (!stat) {
|
||||
return res.status(410).json({ error: 'This download is no longer available' });
|
||||
}
|
||||
|
||||
// Stats parity with the other bulk paths (#895): only count once the
|
||||
// response actually completed, and keep admin previews out of guest stats.
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode >= 400 || req.isAdminPreview) return;
|
||||
// The DELIVERED set, not the requested one: a photo whose source was
|
||||
// missing at build time isn't in the zip and must not be counted.
|
||||
let ids = [];
|
||||
try {
|
||||
ids = JSON.parse(job.delivered_photo_ids || job.photo_ids || '[]');
|
||||
} catch (_) { /* malformed row — skip counting rather than fail */ }
|
||||
if (ids.length > 0) {
|
||||
db('photos').whereIn('id', ids).increment('download_count', 1).catch(() => {});
|
||||
}
|
||||
db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download',
|
||||
photo_id: null,
|
||||
}).catch(() => {});
|
||||
logActivity('gallery_downloaded', { scope: 'all', resolution: job.resolution },
|
||||
req.event.id, galleryActor(req));
|
||||
});
|
||||
|
||||
const suffix = job.resolution === 'original' ? 'original' : job.resolution;
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}-${suffix}.zip"`);
|
||||
const stream = await storage.get(job.zip_path);
|
||||
pipeStreamToResponse(stream, res, { context: `download job ${job.id}`, missingStatus: 410 });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to serve prepared download');
|
||||
}
|
||||
});
|
||||
|
||||
// Explicit per-photo view beacon (#895). Counting views on the image-
|
||||
// serving routes is wrong in both directions: the lightbox preloads the
|
||||
// prev/next neighbours (three fetches per open), while a preloaded
|
||||
// neighbour that becomes the current slide is never re-fetched (#505
|
||||
// keeps the DOM node alive across the swipe) — so request-level counters
|
||||
// overcount preloads AND undercount swipe-throughs. Instead the lightbox
|
||||
// pings this endpoint exactly when a photo becomes the visible slide.
|
||||
// This also covers enhanced/maximum-protection galleries, whose bytes
|
||||
// are served by /api/secure-images and never pass the routes below.
|
||||
// The slideshow kiosk is excluded (denySlideshowToken; migration 138).
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,639 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../../database/db');
|
||||
const path = require('path');
|
||||
const { resolvePhotoContentType } = require('../../utils/photoContentType');
|
||||
const router = express.Router();
|
||||
const watermarkService = require('../../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../../services/watermarkGeneratorService');
|
||||
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
|
||||
const withPreview = (req, url) => (req.isAdminPreview ? `${url}${url.includes('?') ? '&' : '?'}admin_preview=1` : url);
|
||||
const secureImageService = require('../../services/secureImageService');
|
||||
const logger = require('../../utils/logger');
|
||||
const { pipeStreamToResponse } = require('../../utils/streamResponse');
|
||||
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { blockHiddenGallery } = require('../../utils/revealMode');
|
||||
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../../services/imageProcessor');
|
||||
const { getStorage } = require('../../services/storage');
|
||||
const fs = require('fs');
|
||||
const { getStoragePath } = require('../../config/storage');
|
||||
|
||||
router.post('/:slug/photo/:photoId/view',
|
||||
verifyGalleryAccess,
|
||||
denySlideshowToken,
|
||||
blockHiddenGallery,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const photo = await db('photos')
|
||||
.where({ id: req.params.photoId, event_id: req.event.id })
|
||||
.first('id', 'visibility');
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
// Admin preview (#981 review) is excluded from per-photo view analytics.
|
||||
if (!req.isAdminPreview) {
|
||||
await db('photos').where('id', photo.id).increment('view_count', 1);
|
||||
}
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to record view');
|
||||
}
|
||||
});
|
||||
|
||||
// View single photo (with watermark if enabled)
|
||||
router.get('/:slug/photo/:photoId',
|
||||
verifyGalleryAccess,
|
||||
blockHiddenGallery,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Check if this is a video
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
|
||||
// Check protection level - basic and standard protection allow direct JWT access
|
||||
const protectionLevel = req.event.protection_level || 'standard';
|
||||
|
||||
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
|
||||
// For enhanced/maximum protection, redirect to secure endpoint
|
||||
return res.status(302).json({
|
||||
error: 'Secure access required',
|
||||
secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`,
|
||||
photoId: photoId
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve where to read the photo bytes from. For external/reference
|
||||
// photos the source is always a local mount path. For managed photos
|
||||
// we go through the storage abstraction so S3 deployments work too
|
||||
// (#432 — previously this route did fs.* directly and 500'd in S3
|
||||
// mode because the file wasn't on the container's local fs).
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../../services/photoResolver');
|
||||
const storage = getStorage();
|
||||
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
|
||||
const useStorageBackend = !isExternal;
|
||||
|
||||
let filePath = null; // Local fs path (external photos OR LocalFs storage)
|
||||
let storageKey = null; // Relative storage key (managed photos via storage abstraction)
|
||||
let stat;
|
||||
let fileSize;
|
||||
|
||||
if (useStorageBackend) {
|
||||
try {
|
||||
storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo storage key', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
photoPath: photo.path,
|
||||
photoFilename: photo.filename
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
stat = await storage.stat(storageKey);
|
||||
if (!stat) {
|
||||
logger.error('Photo not found in storage backend', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
storageKey
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
fileSize = stat.size;
|
||||
} else {
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
photoPath: photo.path,
|
||||
photoFilename: photo.filename
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.error('Photo file does not exist at resolved path', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
resolvedPath: filePath,
|
||||
photoPath: photo.path
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
stat = fs.statSync(filePath);
|
||||
fileSize = stat.size;
|
||||
}
|
||||
|
||||
// Handle video streaming with range requests
|
||||
if (isVideo) {
|
||||
const range = req.headers.range;
|
||||
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
// Validate before writing the 206: a NaN, inverted or out-of-file
|
||||
// range used to be committed to the headers and then throw while
|
||||
// streaming (or read past the end).
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end)
|
||||
|| start < 0 || end < start || start >= fileSize) {
|
||||
res.set('Content-Range', `bytes */${fileSize}`);
|
||||
return res.status(416).end();
|
||||
}
|
||||
const boundedEnd = Math.min(end, fileSize - 1);
|
||||
const chunksize = (boundedEnd - start) + 1;
|
||||
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${boundedEnd}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize,
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
const file = useStorageBackend
|
||||
? await storage.getRange(storageKey, start, boundedEnd)
|
||||
: fs.createReadStream(filePath, { start, end: boundedEnd });
|
||||
pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` });
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': fileSize,
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
const file = useStorageBackend
|
||||
? await storage.get(storageKey)
|
||||
: fs.createReadStream(filePath);
|
||||
pipeStreamToResponse(file, res, { context: `video for photo ${photo.id}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Image path
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
// orientation_checked_at participates because the backfill (#1198) can
|
||||
// change these bytes without touching either of the other two inputs:
|
||||
// it rewrites the derived renditions while the ORIGINAL's mtime and the
|
||||
// watermark settings both stay exactly as they were. Without it a guest
|
||||
// holding a pre-fix ETag keeps getting 304 and keeps their cached
|
||||
// sideways image, however many times the backfill succeeds.
|
||||
const orientationVersion = photo.orientation_checked_at
|
||||
? `-o${new Date(photo.orientation_checked_at).getTime()}`
|
||||
: '';
|
||||
const etag = `"${photoId}-${mtimeMs}${watermarkHash}${orientationVersion}"`;
|
||||
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Pre-generated watermarked file: served via the storage backend
|
||||
// (managed) or directly from local fs (external).
|
||||
if (photo.watermark_path) {
|
||||
try {
|
||||
if (useStorageBackend) {
|
||||
const wmStat = await storage.stat(photo.watermark_path);
|
||||
if (wmStat) {
|
||||
res.set({
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Length': wmStat.size,
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
const wmStream = await storage.get(photo.watermark_path);
|
||||
return pipeStreamToResponse(wmStream, res, { context: `watermarked photo ${photo.id}` });
|
||||
}
|
||||
} else {
|
||||
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
|
||||
if (fs.existsSync(watermarkFilePath)) {
|
||||
res.set({
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
return res.sendFile(watermarkFilePath);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: apply watermark on-the-fly. applyWatermark needs a
|
||||
// local file path (sharp + fs.readFile) — for managed photos in
|
||||
// S3 mode, withLocalCopy materializes to a tmp file and cleans up.
|
||||
const watermarkedBuffer = useStorageBackend
|
||||
? await withLocalCopy(storageKey, (localPath) =>
|
||||
watermarkService.applyWatermark(localPath, watermarkSettings))
|
||||
: await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
// Queue watermark generation in background for next request
|
||||
watermarkGeneratorService.generateForPhoto(photo.id)
|
||||
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
|
||||
|
||||
res.set({
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
res.set({
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
if (useStorageBackend) {
|
||||
res.set('Content-Length', stat.size);
|
||||
res.set('Content-Type', resolvePhotoContentType(photo));
|
||||
const stream = await storage.get(storageKey);
|
||||
pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` });
|
||||
} else {
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
||||
res.sendFile(absolutePath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to serve photo');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Serve thumbnail
|
||||
router.get('/:slug/thumbnail/:photoId',
|
||||
verifyGalleryAccess,
|
||||
blockHiddenGallery,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||
// Responsive tier (#1095), whitelisted the same way the preview route's
|
||||
// is. Unrecognised or absent falls through to the canonical 300px
|
||||
// thumbnail, so existing clients are untouched.
|
||||
const { THUMBNAIL_WIDTHS, normalizeTierWidth, ensureThumbnailAtWidth } =
|
||||
require('../../services/imageProcessor');
|
||||
const thumbTier = normalizeTierWidth(req.query.w, THUMBNAIL_WIDTHS);
|
||||
|
||||
const thumbnailPath = thumbTier
|
||||
? (await ensureThumbnailAtWidth(photo, thumbTier)) || (await ensureThumbnail(photo))
|
||||
: await ensureThumbnail(photo);
|
||||
|
||||
// What was actually resolved, not what was asked for. A tier request can
|
||||
// land on the canonical thumbnail — generation failed, or the row is a
|
||||
// video — and stamping the requested tier into the ETag below would then
|
||||
// have the client cache a 300px image under its 900px key for the full
|
||||
// max-age, with no way to notice.
|
||||
const servedTier = thumbTier && thumbnailPath
|
||||
&& path.basename(thumbnailPath).startsWith(`thumb_w${thumbTier}_`)
|
||||
? thumbTier
|
||||
: null;
|
||||
|
||||
if (!thumbnailPath) {
|
||||
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||
return res.status(404).json({ error: 'Thumbnail generation failed' });
|
||||
}
|
||||
|
||||
// Read thumbnail metadata via the storage abstraction so we work in
|
||||
// both LocalFs and S3 modes (#432). The previous fs.statSync on the
|
||||
// resolved local path 500'd in S3 deployments because the thumbnail
|
||||
// only exists in the bucket, not on the container's local fs.
|
||||
const storage = getStorage();
|
||||
const stat = await storage.stat(thumbnailPath);
|
||||
if (!stat) {
|
||||
logger.error(`Thumbnail not found in storage backend for photo ${photoId}`, { thumbnailPath });
|
||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||
}
|
||||
|
||||
// Log thumbnail access
|
||||
await secureImageService.logImageAccess(
|
||||
photoId,
|
||||
req.event.id,
|
||||
req.clientInfo,
|
||||
'thumbnail'
|
||||
);
|
||||
|
||||
// Check if watermarks are enabled and apply to thumbnail
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
// ETag uses storage stat mtime + photo id + watermark hash.
|
||||
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
// Tier in the ETag, same reason as the preview route: without it a
|
||||
// client holding the 300px thumbnail gets a 304 for its 600px request
|
||||
// and renders the small one, which is this feature inverted.
|
||||
const etag = `"thumb-${photoId}-${servedTier || 'def'}-${mtimeMs}${watermarkHash}"`;
|
||||
|
||||
// Check if client has valid cached version
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
// Set appropriate headers with enhanced security
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Reduced cache time
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Protected-Thumbnail': 'true',
|
||||
'ETag': etag
|
||||
});
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Watermarking needs a local file path (sharp + fs.readFile).
|
||||
// Materialize via withLocalCopy — no-op in local mode, downloads
|
||||
// to a tmp file then cleans up in S3 mode.
|
||||
const watermarkedBuffer = await withLocalCopy(thumbnailPath, (localPath) =>
|
||||
watermarkService.applyWatermark(localPath, watermarkSettings)
|
||||
);
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
const stream = await storage.get(thumbnailPath);
|
||||
pipeStreamToResponse(stream, res, { context: `thumbnail for photo ${photoId}` });
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to serve thumbnail');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Serve hero-optimized image (1920x1080 for full-width hero sections)
|
||||
router.get('/:slug/hero/:photoId',
|
||||
verifyGalleryAccess,
|
||||
// Reveal-gated too: this route serves a 1920px derivative of ANY photo id,
|
||||
// not just the chosen hero — an open bypass while hidden (review round 1).
|
||||
blockHiddenGallery,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Check if this is a video - videos don't get hero images
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
if (isVideo) {
|
||||
// For videos, redirect to the regular photo endpoint
|
||||
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||
}
|
||||
|
||||
// Ensure hero image exists and is valid, regenerate if needed
|
||||
const heroPath = await ensureHeroImage(photo);
|
||||
|
||||
if (!heroPath) {
|
||||
// If hero generation fails, fall back to original photo
|
||||
logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`);
|
||||
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||
}
|
||||
|
||||
// Hero images are always written via the storage abstraction (see
|
||||
// imageProcessor.generateHeroImage), so they're a managed-storage
|
||||
// key in both LocalFs and S3 modes (#432). Read via storage.
|
||||
const storage = getStorage();
|
||||
const stat = await storage.stat(heroPath);
|
||||
if (!stat) {
|
||||
logger.error('Hero image file does not exist in storage backend', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
heroPath
|
||||
});
|
||||
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||
}
|
||||
|
||||
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||
const etag = `"hero-${photoId}-${mtimeMs}"`;
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Hero-Image': 'true',
|
||||
'ETag': etag
|
||||
});
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// applyWatermark needs a local file path; materialize via
|
||||
// withLocalCopy so this works in S3 mode too.
|
||||
const watermarkedBuffer = await withLocalCopy(heroPath, (localPath) =>
|
||||
watermarkService.applyWatermark(localPath, watermarkSettings)
|
||||
);
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
const stream = await storage.get(heroPath);
|
||||
pipeStreamToResponse(stream, res, { context: `hero for photo ${photoId}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error serving hero image:', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id
|
||||
});
|
||||
// Fall back to original photo on any error
|
||||
res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Lightbox preview tier (#492). Aspect-preserved JPEG capped at 1920px
|
||||
// long edge — admin-controlled opt-in via app_settings.lightbox_preview_enabled.
|
||||
// Mirrors the hero route shape: same auth, ETag from preview mtime,
|
||||
// fall back to original on any failure so the lightbox never shows a
|
||||
// broken image. The watermark application path is preserved so a
|
||||
// preview surfaced in the lightbox carries the same protection a
|
||||
// guest would see on the full original.
|
||||
router.get('/:slug/preview/:photoId',
|
||||
verifyGalleryAccess,
|
||||
blockHiddenGallery,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Videos don't get a preview tier — fall through to the regular
|
||||
// photo endpoint (which serves the source). The frontend should
|
||||
// already be checking media_type before requesting /preview but
|
||||
// belt-and-braces in case a stale tab does.
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
if (isVideo) {
|
||||
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||
}
|
||||
|
||||
// Responsive tier (#1095). Whitelisted only — an open ?w= would let
|
||||
// anyone fill the disk with renditions nobody asked for. An unrecognised
|
||||
// or absent value falls through to the canonical 1920 preview, so old
|
||||
// clients and hand-typed URLs behave exactly as before.
|
||||
const { PREVIEW_WIDTHS, normalizeTierWidth, ensurePreviewImageAtWidth } =
|
||||
require('../../services/imageProcessor');
|
||||
const tierWidth = normalizeTierWidth(req.query.w, PREVIEW_WIDTHS);
|
||||
|
||||
// Lazy generation: ensurePreviewImage returns null on any
|
||||
// failure (corrupt source, sharp OOM, storage unavailable, …).
|
||||
// Fall back to the original so the lightbox always renders.
|
||||
const previewPath = tierWidth
|
||||
? (await ensurePreviewImageAtWidth(photo, tierWidth)) || (await ensurePreviewImage(photo))
|
||||
: await ensurePreviewImage(photo);
|
||||
if (!previewPath) {
|
||||
logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`);
|
||||
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const stat = await storage.stat(previewPath);
|
||||
if (!stat) {
|
||||
logger.error('Preview file does not exist in storage backend', {
|
||||
slug: req.params.slug, photoId, eventId: req.event.id, previewPath,
|
||||
});
|
||||
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||
}
|
||||
|
||||
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
// Tier is part of the etag: without it a client that already holds the
|
||||
// 1920 rendition would get a 304 for its 640 request and render the
|
||||
// wrong size, which is the whole point of the feature inverted.
|
||||
const etag = `"preview-${photoId}-${tierWidth || 'def'}-${mtimeMs}${watermarkHash}"`;
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
res.set({
|
||||
// From the key, not hard-coded: a preview of a transparent or animated
|
||||
// source is WebP, because JPEG carries neither. `nosniff` below means
|
||||
// getting this wrong shows a broken image rather than being silently
|
||||
// corrected by the browser. Pre-existing keys have no .webp suffix and
|
||||
// are JPEG, so they keep their old header.
|
||||
'Content-Type': previewPath.endsWith('.webp') ? 'image/webp' : 'image/jpeg',
|
||||
// Cache aggressively — preview only changes on photo
|
||||
// re-upload (which generates a new preview key) or settings
|
||||
// regenerate (which writes a new mtime + ETag).
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Preview-Image': 'true',
|
||||
'ETag': etag,
|
||||
});
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// No Content-Type override here. applyWatermark PRESERVES the source
|
||||
// format (watermarkService.js: png -> png, webp -> webp, else jpeg),
|
||||
// and its input is this preview — so the output format matches the key
|
||||
// the header was already derived from. Forcing image/jpeg would
|
||||
// mislabel a watermarked WebP preview, and `nosniff` means the browser
|
||||
// will not correct it.
|
||||
//
|
||||
// What is still lost is the animation: the compositor flattens a
|
||||
// multi-frame source to one frame while keeping the WebP container.
|
||||
// That is a separate problem and a much larger one.
|
||||
const watermarkedBuffer = await withLocalCopy(previewPath, (localPath) =>
|
||||
watermarkService.applyWatermark(localPath, watermarkSettings)
|
||||
);
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
const stream = await storage.get(previewPath);
|
||||
pipeStreamToResponse(stream, res, { context: `preview for photo ${photoId}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error serving preview image:', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id,
|
||||
});
|
||||
res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /:slug/feedback-settings lives in galleryFeedback.js. A duplicate of it
|
||||
// used to sit here, and since server.js mounts galleryRoutes before
|
||||
// galleryFeedback it shadowed the real handler — dropping the per-guest caps
|
||||
// (#655) from the guest payload, so the gallery could never render the
|
||||
// favorite/like limits or their counters (#1030).
|
||||
|
||||
// Get photo stats. no-store: view/download/visitor counters are private
|
||||
// gallery analytics and change on every request.
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,249 @@
|
||||
const { isGalleryExpired } = require('../../utils/galleryLifecycle');
|
||||
const express = require('express');
|
||||
const { db } = require('../../database/db');
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { timingSafeEqualStr } = require('../../utils/timingSafe');
|
||||
const router = express.Router();
|
||||
const { resolveHeroLogoVisible } = require('../../services/galleryModel');
|
||||
const { verifyAdminPreview } = require('../../middleware/gallery');
|
||||
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||
const logger = require('../../utils/logger');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { handleAsync, errorResponse } = require('../../utils/routeHelpers');
|
||||
const { isGalleryHidden } = require('../../utils/revealMode');
|
||||
const { NotFoundError } = require('../../utils/errors');
|
||||
async function checkSlugRedirect(slug) {
|
||||
try {
|
||||
const hasTable = await db.schema.hasTable('slug_redirects');
|
||||
if (!hasTable) return null;
|
||||
|
||||
const redirect = await db('slug_redirects')
|
||||
.where({ old_slug: slug })
|
||||
.first();
|
||||
|
||||
return redirect ? redirect.new_slug : null;
|
||||
} catch (error) {
|
||||
logger.warn('Error checking slug redirect:', { slug, error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/resolve/:identifier', handleAsync(async (req, res) => {
|
||||
const { identifier } = req.params;
|
||||
let result = await resolveShareIdentifier(identifier);
|
||||
|
||||
// If not found, check for redirect
|
||||
if (!result) {
|
||||
const newSlug = await checkSlugRedirect(identifier);
|
||||
if (newSlug) {
|
||||
return res.status(301).json({
|
||||
redirect: true,
|
||||
newSlug,
|
||||
message: 'Gallery has been renamed'
|
||||
});
|
||||
}
|
||||
throw new NotFoundError('Gallery');
|
||||
}
|
||||
|
||||
const { event, matchType, shareToken } = result;
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
// The share_token is a bearer secret. Only return it (and the share
|
||||
// links/URLs that embed it) when the caller already proved they hold it —
|
||||
// i.e. they resolved via the token or the full share link. A bare *slug*
|
||||
// lookup (slugs appear in gallery URLs and are guessable) must NOT hand
|
||||
// back the secret, or an anonymous caller could turn a known slug into
|
||||
// share-link access to a no-password gallery (GHSA-rh8r).
|
||||
const callerHasToken = matchType !== 'slug';
|
||||
if (!callerHasToken) {
|
||||
return res.json({ slug: event.slug, matchType, requires_password: requiresPassword });
|
||||
}
|
||||
|
||||
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
|
||||
res.json({
|
||||
slug: event.slug,
|
||||
token: shareToken,
|
||||
matchType,
|
||||
share_link: event.share_link,
|
||||
share_path: linkVariants.sharePath,
|
||||
share_url: linkVariants.shareUrl,
|
||||
short_enabled: linkVariants.shortEnabled,
|
||||
requires_password: requiresPassword
|
||||
});
|
||||
}));
|
||||
|
||||
// Verify share token. no-store: this is an authorization decision — a cached
|
||||
// `{ valid: true }` would keep answering for a token the admin has rotated.
|
||||
router.get('/:slug/verify-token/:token', noStoreCache, handleAsync(async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) })
|
||||
.select('id', 'share_link', 'share_token')
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
throw new NotFoundError('Gallery');
|
||||
}
|
||||
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
|
||||
throw new NotFoundError('Gallery', 'Invalid gallery link');
|
||||
}
|
||||
|
||||
res.json({ valid: true });
|
||||
}));
|
||||
|
||||
// Get gallery info (with optional token verification)
|
||||
router.get('/:slug/info', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const { token } = req.query;
|
||||
|
||||
let event = await db('events')
|
||||
.where({ slug })
|
||||
.select(
|
||||
'id',
|
||||
'created_by',
|
||||
'event_name',
|
||||
'event_type',
|
||||
'event_date',
|
||||
'expires_at',
|
||||
'is_active',
|
||||
'is_archived',
|
||||
'share_link',
|
||||
'share_token',
|
||||
'allow_downloads',
|
||||
'allow_user_uploads',
|
||||
'reveal_mode',
|
||||
'reveal_at',
|
||||
'revealed_at',
|
||||
'disable_right_click',
|
||||
'watermark_downloads',
|
||||
'watermark_text',
|
||||
'require_password',
|
||||
'color_theme',
|
||||
'enable_devtools_protection',
|
||||
'use_canvas_rendering',
|
||||
'hero_logo_visible',
|
||||
'hero_logo_size',
|
||||
'hero_logo_position',
|
||||
'hero_logo_url',
|
||||
'login_logo_visible',
|
||||
'header_style',
|
||||
'hero_divider_style',
|
||||
'hero_image_anchor',
|
||||
'is_draft',
|
||||
'default_photo_sort',
|
||||
// Per-event promotional override (#440). Resolution into a
|
||||
// ready-to-render markdown string happens below so the
|
||||
// frontend doesn't have to know about modes.
|
||||
'promo_mode',
|
||||
'promo_markdown',
|
||||
'info_mode',
|
||||
'info_markdown'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
// Check for redirect
|
||||
const newSlug = await checkSlugRedirect(slug);
|
||||
if (newSlug) {
|
||||
return res.status(301).json({
|
||||
redirect: true,
|
||||
newSlug,
|
||||
message: 'Gallery has been renamed'
|
||||
});
|
||||
}
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
// Check if event is archived
|
||||
if (event.is_archived) {
|
||||
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
|
||||
}
|
||||
|
||||
// Admin preview (#868) bypasses both the draft gate and — below — the
|
||||
// password gate. Computed once and reused.
|
||||
const adminPreview = await verifyAdminPreview(req, event);
|
||||
// Check if event is a draft (allow admin preview)
|
||||
if (event.is_draft && !adminPreview) {
|
||||
return res.status(404).json({ error: 'Gallery is not yet published' });
|
||||
}
|
||||
|
||||
// If token provided, verify it matches the share link
|
||||
if (token) {
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
|
||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||
}
|
||||
}
|
||||
|
||||
// Admin preview skips the guest password on published, protected galleries
|
||||
// (#868) — the admin already sees every photo through the admin routes.
|
||||
const requiresPassword = adminPreview
|
||||
? false
|
||||
: !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||
|
||||
res.json({
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
expires_at: event.expires_at,
|
||||
is_active: event.is_active,
|
||||
is_expired: !event.is_active || isGalleryExpired(event),
|
||||
requires_password: requiresPassword,
|
||||
color_theme: event.color_theme,
|
||||
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
|
||||
allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1',
|
||||
// Reveal mode (#838): effective hidden state (computed, time-exact) so
|
||||
// the landing page can hint at the reveal before login too.
|
||||
hidden_until_reveal: isGalleryHidden(event),
|
||||
reveal_at: isGalleryHidden(event) ? (event.reveal_at || null) : null,
|
||||
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
|
||||
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||
watermark_text: event.watermark_text,
|
||||
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
|
||||
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
|
||||
// #894: only an explicit false hides the logo on the password page;
|
||||
// NULL keeps the default (show).
|
||||
login_logo_visible: !(event.login_logo_visible === false || event.login_logo_visible === 0 || event.login_logo_visible === '0'),
|
||||
// #756: NULL per-event size inherits the global branding_logo_size.
|
||||
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
hero_logo_url: event.hero_logo_url || null,
|
||||
header_style: event.header_style || 'standard',
|
||||
hero_divider_style: event.hero_divider_style || 'wave',
|
||||
hero_image_anchor: event.hero_image_anchor || 'center',
|
||||
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
|
||||
// Per-event promotional override (#440). Frontend resolves
|
||||
// 'inherit' against branding_promo_markdown from public settings.
|
||||
promo_mode: event.promo_mode || 'inherit',
|
||||
promo_markdown: event.promo_markdown || null,
|
||||
// Info banner (#932). Same inherit/custom/off semantics as promo,
|
||||
// resolved against branding_info_markdown from public settings.
|
||||
info_mode: event.info_mode || 'inherit',
|
||||
info_markdown: event.info_markdown || null
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch gallery info');
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live Slideshow ("Diashow") — token-only fullscreen kiosk surface
|
||||
// (migration 138). The token in the URL IS the secret (no gallery password),
|
||||
// so these routes are unauthenticated except for the token match itself. The
|
||||
// slideshow shows ALL public/visible, finished photos — exactly the guest
|
||||
// set — so once /session mints a short-lived `accessLevel:'slideshow'` JWT,
|
||||
// the page reuses the normal /photos + image endpoints unchanged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Photos a slideshow may display: published, finished, non-hidden. Mirrors the
|
||||
// guest filter in GET /:slug/photos so the live count matches the rendered set.
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,192 @@
|
||||
const express = require('express');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const router = express.Router();
|
||||
const { verifyGalleryAccess } = require('../../middleware/gallery');
|
||||
const { resolveGuest } = require('../../middleware/guestAuth');
|
||||
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||
const { generateGuestIdentifier } = require('../../middleware/feedbackRateLimit');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { guestBlockedByReveal } = require('../../utils/revealMode');
|
||||
const downloadZipService = require('../../services/downloadZipService');
|
||||
const GALLERY_OPENED_DEBOUNCE_MS = 6 * 60 * 60 * 1000;
|
||||
const galleryOpenedNotifiedAt = new Map();
|
||||
function galleryActor(req) {
|
||||
// Portal tokens run as accessLevel 'guest' but carry via:'customer'
|
||||
// (req.viaCustomer); PIN-client logins carry accessLevel 'client'.
|
||||
// Both are customers, not guests (codex review of #849, final round).
|
||||
const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client'));
|
||||
return { type: isCustomer ? 'customer' : 'guest' };
|
||||
}
|
||||
function notifyGalleryOpened(event, req) {
|
||||
// Customer-PORTAL opens already log `customer_event_access` on the
|
||||
// access-token mint — a second `gallery_opened` per portal click would
|
||||
// double-notify. Keyed on the portal provenance (req.viaCustomer), NOT
|
||||
// on accessLevel: PIN-client logins are 'client' without any other
|
||||
// open signal and must keep notifying (codex review of #849, final
|
||||
// round — the previous check had this inverted).
|
||||
if (req && req.viaCustomer) return;
|
||||
const now = Date.now();
|
||||
const last = galleryOpenedNotifiedAt.get(event.id) || 0;
|
||||
if (now - last < GALLERY_OPENED_DEBOUNCE_MS) return;
|
||||
galleryOpenedNotifiedAt.set(event.id, now);
|
||||
// Fire-and-forget — logActivity swallows its own errors.
|
||||
logActivity('gallery_opened', {}, event.id, galleryActor(req));
|
||||
}
|
||||
|
||||
router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => {
|
||||
try {
|
||||
const payload = await require('../../services/galleryQueryService').getGalleryPhotos({
|
||||
event: req.event, slug: req.params.slug, query: req.query,
|
||||
identity: { guestId: req.guest?.id, guestIdentifier: generateGuestIdentifier(req) },
|
||||
accessLevel: req.accessLevel, adminPreview: req.isAdminPreview,
|
||||
hiddenForGuest: guestBlockedByReveal(req),
|
||||
});
|
||||
// Log view — but NOT for the Live Slideshow kiosk. A running projector
|
||||
// refetches this list on every new-upload poll, which would massively
|
||||
// inflate total_views / unique_visitors. The slideshow is explicitly
|
||||
// excluded from real visitor analytics (migration 138 design).
|
||||
// Admin preview (#868) is excluded from guest analytics + the "gallery
|
||||
// opened" bell — it's the photographer looking at their own gallery.
|
||||
if (req.accessLevel !== 'slideshow' && !req.isAdminPreview && !(Number(req.query.page) > 1)) {
|
||||
await db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'view'
|
||||
});
|
||||
notifyGalleryOpened(req.event, req);
|
||||
}
|
||||
|
||||
res.json(payload);
|
||||
} catch (error) { errorResponse(res, error, 500, 'Failed to fetch photos'); }
|
||||
});
|
||||
|
||||
/**
|
||||
* People in this gallery (#1074).
|
||||
*
|
||||
* Returns [] rather than 403 whenever the feature is unavailable — a guest
|
||||
* must not be able to tell "this gallery has no people" from "this gallery
|
||||
* has the feature switched off". Same reasoning as reveal mode returning an
|
||||
* empty photo set rather than an error.
|
||||
*
|
||||
* Counts and cover faces are computed against the caller's own visibility
|
||||
* scope inside facePeopleService; nothing here reads face_count_total.
|
||||
*/
|
||||
// no-store for the same reason as /photos: the people list and its scan
|
||||
// progress are scoped to what THIS viewer may see.
|
||||
router.get('/:slug/people', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => {
|
||||
try {
|
||||
const isClient = req.accessLevel === 'client';
|
||||
const { isEnabledForEvent, areFacesVisibleToGuests, getThresholds } =
|
||||
require('../../services/faceSettings');
|
||||
|
||||
if (!(await isEnabledForEvent(req.event))) {
|
||||
return res.json({ people: [] });
|
||||
}
|
||||
if (!isClient && !areFacesVisibleToGuests(req.event)) {
|
||||
return res.json({ people: [] });
|
||||
}
|
||||
// While a gallery is hidden behind reveal mode (#838), a plain guest sees
|
||||
// no photos — so they see no people either.
|
||||
if (guestBlockedByReveal(req)) {
|
||||
return res.json({ people: [] });
|
||||
}
|
||||
|
||||
const { listPeople, getScanStatus } = require('../../services/facePeopleService');
|
||||
const thresholds = await getThresholds();
|
||||
|
||||
const people = await listPeople(req.event.id, {
|
||||
isClient,
|
||||
forAdmin: false,
|
||||
minClusterSize: thresholds.face_min_cluster_size,
|
||||
});
|
||||
|
||||
// Drives the "Finding people… 240/1200" progress line during a backfill.
|
||||
// Scoped to what this viewer may see — an unscoped total would leak the
|
||||
// number of hidden photos through the progress bar.
|
||||
const status = await getScanStatus(req.event.id, { isClient });
|
||||
|
||||
res.json({
|
||||
people,
|
||||
scan: {
|
||||
in_progress: status.in_progress,
|
||||
scanned: status.scanned,
|
||||
total: status.total,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch people');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle photo visibility (client-only)
|
||||
router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
if (req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Client access required' });
|
||||
}
|
||||
|
||||
const { photoId } = req.params;
|
||||
const { visibility } = req.body;
|
||||
|
||||
if (!['visible', 'hidden'].includes(visibility)) {
|
||||
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.update({ visibility });
|
||||
|
||||
// A client hiding/showing a photo changes the guest download bundle —
|
||||
// drop the cached ZIP so it rebuilds fresh (codex review).
|
||||
downloadZipService.invalidate(req.event.id);
|
||||
|
||||
res.json({ message: 'Photo visibility updated', visibility });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update photo visibility');
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk toggle photo visibility (client-only)
|
||||
router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
if (req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Client access required' });
|
||||
}
|
||||
|
||||
const { photoIds, visibility } = req.body;
|
||||
|
||||
if (!Array.isArray(photoIds) || photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'Invalid photo IDs' });
|
||||
}
|
||||
|
||||
if (!['visible', 'hidden'].includes(visibility)) {
|
||||
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||
}
|
||||
|
||||
const count = await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
.where('event_id', req.event.id)
|
||||
.update({ visibility });
|
||||
|
||||
// Client bulk hide/show alters the guest download bundle — invalidate
|
||||
// the cached ZIP (codex review).
|
||||
downloadZipService.invalidate(req.event.id);
|
||||
|
||||
res.json({ message: `${count} photos updated`, visibility });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update photo visibility');
|
||||
}
|
||||
});
|
||||
|
||||
// Download single photo
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,293 @@
|
||||
const { isGalleryAvailable } = require('../../utils/galleryLifecycle');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../../database/db');
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
|
||||
const router = express.Router();
|
||||
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||
const logger = require('../../utils/logger');
|
||||
const { getEventShareToken, buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { handleAsync } = require('../../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../../utils/errors');
|
||||
const { setGalleryAuthCookies } = require('../../utils/tokenUtils');
|
||||
const { getSlideshowGlobals } = require('../../utils/slideshowGlobals');
|
||||
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
|
||||
|
||||
function slideshowPhotosQuery(eventId, categoryId = null) {
|
||||
const q = db('photos')
|
||||
.where('photos.event_id', eventId)
|
||||
.where(function() {
|
||||
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
|
||||
})
|
||||
.where(function() {
|
||||
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
|
||||
});
|
||||
// Category filter (#202) — keep the /session + /state count in sync with the
|
||||
// photos the kiosk actually renders.
|
||||
if (categoryId) q.where('photos.category_id', categoryId);
|
||||
return q;
|
||||
}
|
||||
|
||||
// Resolve an active slideshow by slug + token. Returns the event row, or null
|
||||
// when the link is missing/rotated/disabled or the gallery isn't live (archived
|
||||
// / draft / inactive / expired) — every one of those collapses to a 404 so a
|
||||
// dead link reveals nothing and stops any projector on its next poll.
|
||||
async function resolveSlideshow(slug, token) {
|
||||
if (!token) return null;
|
||||
// The `slideshow` feature flag is a master kill-switch: when an admin turns
|
||||
// Live Slideshow off, every existing /show/ link dies on its next request
|
||||
// (the running projector stops within one /state poll), not just the admin UI.
|
||||
if (!(await isFeatureEnabled('slideshow'))) return null;
|
||||
const event = await db('events')
|
||||
.where({
|
||||
slug,
|
||||
show_share_token: token,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false),
|
||||
is_draft: formatBoolean(false)
|
||||
})
|
||||
.first();
|
||||
if (!isGalleryAvailable(event)) return null;
|
||||
return event;
|
||||
}
|
||||
|
||||
// Resolve the slideshow's live styling, including the ZDF/ARD-ident-style
|
||||
// watermark (a white, semi-transparent corner logo). The logo URL is resolved
|
||||
// from the chosen source so the kiosk renders it without knowing about
|
||||
// branding/event internals; null url = nothing to overlay.
|
||||
async function slideshowSettings(event, req) {
|
||||
// The global look/fit (Settings → Slideshow) + branding logo URLs come from a
|
||||
// short-TTL cached bundle so a 3s projector poll doesn't re-fire ~10 settings
|
||||
// reads each time (PR #646 review, concern 2).
|
||||
const g = await getSlideshowGlobals();
|
||||
|
||||
// Watermark: the LOOK (logo/position/opacity/style/size) is configured ONCE
|
||||
// globally; it is NOT duplicated per event. The only per-event control is
|
||||
// whether the watermark shows: `show_watermark` NULL inherits the global
|
||||
// enabled flag, true/false force it on/off.
|
||||
const wm = event.show_watermark;
|
||||
const inherit = (wm === null || wm === undefined);
|
||||
const enabled = inherit ? g.watermark_enabled : (wm === true || wm === 1 || wm === '1');
|
||||
let watermark = null;
|
||||
if (enabled) {
|
||||
// Resolve the chosen logo to a URL. Branding assets come from settings;
|
||||
// the event source uses the event's own hero logo.
|
||||
let url;
|
||||
if (g.watermark_source === 'event') {
|
||||
url = event.hero_logo_url || null;
|
||||
} else if (g.watermark_source === 'logo_dark') {
|
||||
url = g.branding_logo_url_dark;
|
||||
} else if (g.watermark_source === 'favicon') {
|
||||
url = g.branding_favicon_url;
|
||||
} else {
|
||||
url = g.branding_logo_url;
|
||||
}
|
||||
if (url) {
|
||||
watermark = {
|
||||
url,
|
||||
position: g.watermark_position,
|
||||
opacity: g.watermark_opacity,
|
||||
style: g.watermark_style,
|
||||
size: g.watermark_size,
|
||||
};
|
||||
}
|
||||
}
|
||||
// QR overlay (#837): like the watermark, the LOOK is global-only and the
|
||||
// per-event `show_qr` tri-state (NULL = inherit) decides visibility. The QR
|
||||
// encodes the gallery share URL and ships as a data URI so the public
|
||||
// slideshow client needs no QR library and no extra authenticated endpoint.
|
||||
const qrOverride = event.show_qr;
|
||||
const qrInherit = (qrOverride === null || qrOverride === undefined);
|
||||
const qrEnabled = qrInherit ? g.qr_enabled : (qrOverride === true || qrOverride === 1 || qrOverride === '1');
|
||||
let qr = null;
|
||||
if (qrEnabled) {
|
||||
const dataUrl = await slideshowQrDataUrl(event, req);
|
||||
if (dataUrl) {
|
||||
qr = {
|
||||
data_url: dataUrl,
|
||||
position: g.qr_position,
|
||||
opacity: g.qr_opacity,
|
||||
size: g.qr_size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interval_ms: event.show_interval_ms || 5000,
|
||||
transition: event.show_transition || 'crossfade',
|
||||
transition_ms: event.show_transition_ms || 800,
|
||||
colorfilter: event.show_colorfilter || 'none',
|
||||
// Play order (#202): 'chronological' | 'random'. The client shuffles when
|
||||
// 'random' so live-appended uploads keep working.
|
||||
order: event.show_order || 'chronological',
|
||||
fit: g.fit,
|
||||
watermark,
|
||||
qr,
|
||||
};
|
||||
}
|
||||
|
||||
// The state endpoint is polled every ~3s per projector — cache the generated
|
||||
// QR data URI per share URL instead of re-encoding on every poll. Bounded:
|
||||
// entries live for past events / rotated tokens too, so without eviction the
|
||||
// map would grow with every share URL ever displayed (codex review of #848).
|
||||
// Insertion-order eviction is enough — concurrently-shown events stay hot.
|
||||
const SLIDESHOW_QR_CACHE_MAX = 50;
|
||||
// Keyed by event id (NOT by URL): the origin is caller-influenced when the
|
||||
// configured base is loopback, so URL-keyed caching would let a slideshow
|
||||
// -link holder force a fresh QRCode.toDataURL per request with unique
|
||||
// origins — a cheap CPU-exhaustion path (codex review of #848,
|
||||
// confirmation round). Per-event entries + a regeneration throttle bound
|
||||
// the encode rate regardless of what the caller sends.
|
||||
const SLIDESHOW_QR_REGEN_MS = 60_000;
|
||||
const slideshowQrCache = new Map(); // eventId -> { url, dataUrl, at }
|
||||
// Localhost/relative guard (codex review of #848): with the compose-default
|
||||
// FRONTEND_URL=http://localhost:3000 (or none configured) the QR would send
|
||||
// scanning phones to THEIR localhost. The state poll comes from the kiosk
|
||||
// browser itself, so its Host header + protocol are exactly the public
|
||||
// origin guests can reach — prefer that whenever the configured base is
|
||||
// missing or loopback. trust proxy is configured, so req.protocol respects
|
||||
// X-Forwarded-Proto behind the standard reverse-proxy setups.
|
||||
// Centralised in utils/frontendUrl (#705) so the QR path and the public-origin
|
||||
// resolver agree on what counts as a non-shareable base.
|
||||
const QR_LOCAL_BASE_RE = { test: (v) => require('../../utils/frontendUrl').isLoopbackBase(v) };
|
||||
const QR_ORIGIN_RE = /^https?:\/\/[^\s/]+$/i;
|
||||
async function slideshowQrDataUrl(event, req) {
|
||||
try {
|
||||
const shareToken = getEventShareToken(event);
|
||||
if (!shareToken) return null;
|
||||
let { shareUrl, sharePath } = await buildShareLinkVariants({ slug: event.slug, shareToken });
|
||||
if (!/^https?:\/\//i.test(shareUrl) || QR_LOCAL_BASE_RE.test(shareUrl)) {
|
||||
// Prefer the kiosk's own window.location.origin (?origin=, validated):
|
||||
// req.get('host') is NOT the browser origin behind the standard
|
||||
// proxies — frontend/nginx.conf forwards $host (port stripped), so a
|
||||
// compose LAN deployment on :3000 would encode port 80. A LOOPBACK
|
||||
// kiosk origin is rejected too: it is no more guest-reachable than
|
||||
// the loopback base it would replace (codex review of #848).
|
||||
const rawOrigin = req?.query?.origin;
|
||||
const queryOrigin = typeof rawOrigin === 'string' && QR_ORIGIN_RE.test(rawOrigin) && !QR_LOCAL_BASE_RE.test(rawOrigin)
|
||||
? rawOrigin.replace(/\/$/, '')
|
||||
: null;
|
||||
const host = req && req.get ? req.get('host') : null;
|
||||
const hostOrigin = host ? `${req.protocol}://${host}` : null;
|
||||
if (queryOrigin) shareUrl = `${queryOrigin}${sharePath}`;
|
||||
else if (hostOrigin && !QR_LOCAL_BASE_RE.test(hostOrigin)) shareUrl = `${hostOrigin}${sharePath}`;
|
||||
// Still loopback/relative → no reachable URL exists; suppress the
|
||||
// overlay rather than encode a QR that sends phones to localhost.
|
||||
else return null;
|
||||
}
|
||||
|
||||
const cached = slideshowQrCache.get(event.id);
|
||||
if (cached && cached.url === shareUrl) return cached.dataUrl;
|
||||
// URL differs from the cached one: NEVER serve the mismatched artifact —
|
||||
// a slideshow-token holder could otherwise poison the projector's QR
|
||||
// with an attacker origin for a whole throttle window (codex review of
|
||||
// #848, final round). Inside the window the overlay is briefly
|
||||
// suppressed instead; regeneration stays bounded per event.
|
||||
if (cached && Date.now() - cached.at < SLIDESHOW_QR_REGEN_MS) {
|
||||
return cached.pending ? cached.dataUrl : null;
|
||||
}
|
||||
// Single-flight: concurrent polls on a cold cache must not each
|
||||
// schedule their own 512px encode — reserve the entry with a shared
|
||||
// promise before awaiting.
|
||||
if (cached && cached.pending && cached.url === shareUrl) return cached.pending;
|
||||
const QRCode = require('qrcode');
|
||||
const entry = { url: shareUrl, dataUrl: null, at: Date.now(), pending: null };
|
||||
entry.pending = QRCode.toDataURL(shareUrl, { width: 512, margin: 4 }).then((dataUrl) => {
|
||||
entry.dataUrl = dataUrl;
|
||||
entry.pending = null;
|
||||
return dataUrl;
|
||||
}).catch((e) => {
|
||||
slideshowQrCache.delete(event.id);
|
||||
throw e;
|
||||
});
|
||||
if (!slideshowQrCache.has(event.id) && slideshowQrCache.size >= SLIDESHOW_QR_CACHE_MAX) {
|
||||
slideshowQrCache.delete(slideshowQrCache.keys().next().value);
|
||||
}
|
||||
slideshowQrCache.set(event.id, entry);
|
||||
return await entry.pending;
|
||||
} catch (e) {
|
||||
logger.error('Slideshow QR generation failed:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Open a slideshow session: validate the token and mint a short-lived gallery
|
||||
// JWT scoped to `accessLevel:'slideshow'` (treated as a guest by the photo /
|
||||
// image endpoints → visible photos only, no client-only/hidden). The page
|
||||
// stores this token and the existing axios interceptor injects it.
|
||||
// no-store: this response *is* a credential (it mints a gallery JWT and sets
|
||||
// the per-slug auth cookie), so it must never be retained anywhere.
|
||||
router.get('/:slug/show/:token/session', noStoreCache, handleAsync(async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
const event = await resolveSlideshow(slug, token);
|
||||
if (!event) {
|
||||
throw new NotFoundError('Slideshow');
|
||||
}
|
||||
|
||||
const sessionToken = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery',
|
||||
// Unique per token: the revocation key falls back to eventId+iat otherwise,
|
||||
// so one guest's logout would revoke every same-second login (#1357).
|
||||
jti: crypto.randomUUID(),
|
||||
accessLevel: 'slideshow',
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '12h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
// <img> tags can't carry an Authorization header, so the photo/thumbnail/
|
||||
// preview endpoints authenticate via the per-slug gallery cookie. Set it
|
||||
// here so the kiosk's image requests are authorized with zero extra wiring.
|
||||
setGalleryAuthCookies(res, sessionToken, event.slug);
|
||||
|
||||
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
|
||||
|
||||
res.json({
|
||||
token: sessionToken,
|
||||
event: {
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
color_theme: event.color_theme
|
||||
},
|
||||
settings: await slideshowSettings(event, req),
|
||||
photo_count: parseInt(count, 10) || 0,
|
||||
expires_at: event.expires_at || null
|
||||
});
|
||||
}));
|
||||
|
||||
// Cheap live-poll endpoint (tiny payload, hit every ~3s by the running show):
|
||||
// current settings + the visible photo count. The page diffs photo_count to
|
||||
// decide when to refetch the full list, and re-reads settings so admin changes
|
||||
// take effect live. A dead/disabled link 404s here → the projector stops.
|
||||
router.get('/:slug/show/:token/state', noStoreCache, handleAsync(async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
const event = await resolveSlideshow(slug, token);
|
||||
if (!event) {
|
||||
throw new NotFoundError('Slideshow');
|
||||
}
|
||||
|
||||
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
|
||||
|
||||
res.json({
|
||||
...(await slideshowSettings(event, req)),
|
||||
photo_count: parseInt(count, 10) || 0,
|
||||
expires_at: event.expires_at || null
|
||||
});
|
||||
}));
|
||||
|
||||
// Get all photos.
|
||||
//
|
||||
// no-store (B6): the payload is private and per-guest — it carries the
|
||||
// viewer's own likes/favorites/ratings and, for a client token, photos hidden
|
||||
// from plain guests. With no Cache-Control at all a browser applies heuristic
|
||||
// freshness and may reuse a body it stored on disk, on a shared device, for a
|
||||
// gallery whose password has since been rotated. Express still computes its
|
||||
// weak ETag, so a caller that does revalidate (React Query's own in-memory
|
||||
// cache is unaffected either way) still gets a correct 304.
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,44 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../../database/db');
|
||||
const router = express.Router();
|
||||
const { verifyGalleryAccess } = require('../../middleware/gallery');
|
||||
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||
const { blockHiddenGallery } = require('../../utils/revealMode');
|
||||
|
||||
router.get('/:slug/stats', verifyGalleryAccess, blockHiddenGallery, noStoreCache, async (req, res) => {
|
||||
try {
|
||||
const totalPhotos = await db('photos')
|
||||
.where('event_id', req.event.id)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const totalViews = await db('access_logs')
|
||||
.where('event_id', req.event.id)
|
||||
.where('action', 'view')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const totalDownloads = await db('photos')
|
||||
.where('event_id', req.event.id)
|
||||
.sum('download_count as total')
|
||||
.first();
|
||||
|
||||
const uniqueVisitors = await db('access_logs')
|
||||
.where('event_id', req.event.id)
|
||||
.countDistinct('ip_address as count')
|
||||
.first();
|
||||
|
||||
res.json({
|
||||
total_photos: totalPhotos.count,
|
||||
total_views: totalViews.count,
|
||||
total_downloads: totalDownloads.total || 0,
|
||||
unique_visitors: uniqueVisitors.count
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch stats' });
|
||||
}
|
||||
});
|
||||
|
||||
// User photo upload endpoint
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,41 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../../database/db');
|
||||
const router = express.Router();
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
router.get('/:slug/css-template', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
|
||||
// Find the event by slug
|
||||
const event = await db('events')
|
||||
.where({ slug })
|
||||
.select('css_template_id')
|
||||
.first();
|
||||
|
||||
if (!event || !event.css_template_id) {
|
||||
// No custom CSS - return 204 No Content
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
// Get the template if it's enabled
|
||||
const template = await db('css_templates')
|
||||
.where({ id: event.css_template_id, is_enabled: true })
|
||||
.select('css_content')
|
||||
.first();
|
||||
|
||||
if (!template || !template.css_content) {
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
// Return CSS with caching headers
|
||||
res.setHeader('Content-Type', 'text/css');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
|
||||
res.send(template.css_content);
|
||||
} catch (error) {
|
||||
logger.error('Get CSS template error:', error);
|
||||
res.status(500).send('/* Error loading template */');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,216 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../../database/db');
|
||||
|
||||
const router = express.Router();
|
||||
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
|
||||
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||
const logger = require('../../utils/logger');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
|
||||
router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.eventId);
|
||||
|
||||
// Verify the event matches the token
|
||||
if (req.event.id !== eventId) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
// Check if user uploads are allowed
|
||||
if (!req.event.allow_user_uploads) {
|
||||
return res.status(403).json({ error: 'User uploads are not allowed for this event' });
|
||||
}
|
||||
|
||||
// Ensure temp upload directory exists
|
||||
const fs = require('fs');
|
||||
const tempUploadDir = '/tmp/uploads/';
|
||||
if (!fs.existsSync(tempUploadDir)) {
|
||||
try {
|
||||
fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 });
|
||||
logger.info('Created temp upload directory:', tempUploadDir);
|
||||
} catch (mkdirErr) {
|
||||
return errorResponse(res, mkdirErr, 500, 'Server configuration error: unable to create upload directory');
|
||||
}
|
||||
}
|
||||
|
||||
// Import multer and photo processing
|
||||
const multer = require('multer');
|
||||
const { getAllowedMimeTypes, getMaxFilesPerUpload, getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings');
|
||||
const { validateFileType } = require('../../utils/fileSecurityUtils');
|
||||
|
||||
// Resolve allowed MIME types from settings
|
||||
let allowedMimeTypes;
|
||||
try {
|
||||
allowedMimeTypes = await getAllowedMimeTypes();
|
||||
} catch {
|
||||
allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
}
|
||||
|
||||
// #613 — per-batch file count was hardcoded to 10 here, so the admin's
|
||||
// Settings → General → "Max Files per Upload" value silently didn't
|
||||
// apply to guest uploads (only admin uploads honoured it via
|
||||
// adminPhotos.js:131). Zszywany reported uploading 16 files succeeded
|
||||
// even with the limit set to 10. Mirror the admin path: resolve from
|
||||
// settings (cached for 60s in the service) and feed multer both
|
||||
// `limits.files` and the `.array(...)` cap. Fall back to the service's
|
||||
// default if the read fails.
|
||||
let maxFilesPerUpload;
|
||||
try {
|
||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||
} catch {
|
||||
maxFilesPerUpload = 500;
|
||||
}
|
||||
|
||||
// Per-file size cap was hardcoded to 50MB here, so the admin's Settings →
|
||||
// General → "Max File Size (MB)" value (general_max_file_size_mb) never
|
||||
// applied to guest uploads — a guest could not upload a large video even
|
||||
// when the admin allowed it (reported on #613 by mat1990dj). Resolve it from
|
||||
// settings like the count above; fall back to the 50MB default on read error.
|
||||
let maxFileSizeBytes;
|
||||
try {
|
||||
maxFileSizeBytes = await getMaxFileSizeBytes();
|
||||
} catch {
|
||||
maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
dest: tempUploadDir,
|
||||
limits: {
|
||||
fileSize: maxFileSizeBytes,
|
||||
files: maxFilesPerUpload,
|
||||
// CVE-2026-82333: files arrive as repeated `photos` parts via
|
||||
// .array(), not bracket-indexed field names like `photos[0]` — no
|
||||
// legitimate field name uses array-index syntax at all. Reject any
|
||||
// that do.
|
||||
fieldArrayIndexLimit: 0
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type'));
|
||||
}
|
||||
}
|
||||
}).array('photos', maxFilesPerUpload);
|
||||
|
||||
// Handle upload
|
||||
upload(req, res, async (err) => {
|
||||
if (err) {
|
||||
logger.error('Upload error:', err);
|
||||
// Turn multer's generic "File too large" into an actionable message
|
||||
// that names the configured limit.
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
const limitMb = Math.floor(maxFileSizeBytes / (1024 * 1024));
|
||||
return res.status(400).json({ error: `File too large. Maximum size is ${limitMb} MB per file.` });
|
||||
}
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
const { queueFilesForProcessing } = require('../../services/photoProcessor');
|
||||
const rawCategory = req.body.category_id || req.event.upload_category_id || null;
|
||||
const numericCategoryId = (() => {
|
||||
if (rawCategory === null || rawCategory === undefined) return null;
|
||||
const n = parseInt(rawCategory, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
})();
|
||||
|
||||
try {
|
||||
// Queue files as 'pending' — the background worker will process
|
||||
// thumbnails / EXIF / dimensions off the request thread (#357).
|
||||
const result = await queueFilesForProcessing(req.files, {
|
||||
eventId,
|
||||
photoType: 'individual',
|
||||
categoryId: numericCategoryId,
|
||||
});
|
||||
|
||||
res.status(202).json({
|
||||
message: 'Photos queued for processing',
|
||||
upload_id: result.uploadId,
|
||||
count: result.photos.length,
|
||||
photo_ids: result.photos.map((p) => p.id),
|
||||
photos: result.photos,
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
});
|
||||
} catch (processError) {
|
||||
errorResponse(res, processError, 500, 'Failed to process photos');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to upload photos');
|
||||
}
|
||||
});
|
||||
|
||||
// A guest upload_id is `crypto.randomBytes(16).toString('hex')`
|
||||
// (photoProcessor.js). The pattern is deliberately a little wider than that so
|
||||
// an id-format change does not silently 400, but narrow enough that the value
|
||||
// can only ever be an opaque token.
|
||||
const UPLOAD_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
|
||||
// The guest UI uploads one file per request, so a batch of N files yields N
|
||||
// upload ids. Batching them into a single poll keeps the request rate flat
|
||||
// regardless of batch size; the cap bounds the IN-list.
|
||||
const MAX_UPLOAD_STATUS_IDS = 50;
|
||||
|
||||
/**
|
||||
* GET /:slug/uploads/status?ids=<upload_id>[,<upload_id>…]
|
||||
*
|
||||
* Guest-facing processing status for the guest's own uploads (B7).
|
||||
*
|
||||
* The upload route answers 202 and queues the files, and /photos only returns
|
||||
* rows that reached `processing_status: 'complete'`. Without this the gallery
|
||||
* had to poll /photos blind, could not say "processing…", and could not tell a
|
||||
* slow worker from a photo that failed outright — the guest just watched their
|
||||
* upload not appear.
|
||||
*
|
||||
* Authorization: `verifyGalleryAccess` already resolved `req.event` from the
|
||||
* caller's gallery token, and the query is filtered on `event_id = req.event.id`
|
||||
* as well as the ids. An id belonging to another gallery therefore matches no
|
||||
* row rather than being reported as forbidden — no cross-event read, and no
|
||||
* existence oracle either. Slideshow tokens are denied because a kiosk never
|
||||
* uploads.
|
||||
*
|
||||
* The response is counts only. The guest already knows which files they sent;
|
||||
* anything more (filenames, `processing_error` strings, which can carry
|
||||
* internal paths) would be leaking beyond "how far along is my upload".
|
||||
*/
|
||||
router.get('/:slug/uploads/status', verifyGalleryAccess, denySlideshowToken, noStoreCache, async (req, res) => {
|
||||
try {
|
||||
const ids = String(req.query.ids || '')
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (ids.length === 0 || ids.length > MAX_UPLOAD_STATUS_IDS || !ids.every((id) => UPLOAD_ID_PATTERN.test(id))) {
|
||||
return res.status(400).json({ error: 'Invalid upload ids' });
|
||||
}
|
||||
|
||||
const rows = await db('photos')
|
||||
.where('event_id', req.event.id)
|
||||
.whereIn('upload_id', ids)
|
||||
.select('processing_status');
|
||||
|
||||
const summary = { total: rows.length, pending: 0, processing: 0, complete: 0, failed: 0 };
|
||||
for (const row of rows) {
|
||||
// NULL is a pre-async-migration row, treated as complete exactly as the
|
||||
// /photos filter treats it.
|
||||
const status = row.processing_status || 'complete';
|
||||
if (Object.prototype.hasOwnProperty.call(summary, status) && status !== 'total') {
|
||||
summary[status] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
res.json(summary);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to read upload status');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:slug/css-template
|
||||
* Get custom CSS template for gallery (public endpoint)
|
||||
*/
|
||||
|
||||
module.exports = router;
|
||||
@@ -6,6 +6,7 @@ const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const galleryAccessService = require('../services/galleryAccessService');
|
||||
const { getStorage } = require('../services/storage');
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { withLocalCopy } = require('../services/imageProcessor');
|
||||
@@ -19,16 +20,13 @@ const router = express.Router();
|
||||
/**
|
||||
* Generate a signed URL token for image access
|
||||
*/
|
||||
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false) {
|
||||
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false, galleryAccess) {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
const expires = Date.now() + (expiresIn * 1000);
|
||||
// Third segment (#838): whether the minting context bypasses reveal mode
|
||||
// (slideshow/client/admin). Fourth segment: whether the minter was a
|
||||
// PIN-client, allowing the serve route to still deliver a photo that was
|
||||
// hidden AFTER minting (TOCTOU) — a guest's token carries 0, so it stops
|
||||
// working the moment the photo is hidden. Old shorter tokens verify
|
||||
// unchanged and read both flags as no-bypass.
|
||||
const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}:${clientBypass ? 1 : 0}`;
|
||||
// Bind the URL to its issuing access grant. Old tokens without a grant
|
||||
// must be refreshed: they cannot prove session revocation or ownership.
|
||||
const grant = Buffer.from(JSON.stringify(galleryAccess)).toString('base64url');
|
||||
const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}:${clientBypass ? 1 : 0}:${grant}`;
|
||||
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
|
||||
return `${Buffer.from(data).toString('base64')}.${signature}`;
|
||||
}
|
||||
@@ -41,7 +39,7 @@ function verifyImageToken(token) {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
const [data, signature] = token.split('.');
|
||||
const decoded = Buffer.from(data, 'base64').toString();
|
||||
const [photoId, expires, bypassFlag, clientFlag] = decoded.split(':');
|
||||
const [photoId, expires, bypassFlag, clientFlag, grant] = decoded.split(':');
|
||||
|
||||
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
|
||||
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
|
||||
@@ -50,7 +48,7 @@ function verifyImageToken(token) {
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (Date.now() > parseInt(expires)) {
|
||||
if (!Number.isFinite(Number(expires)) || Date.now() >= Number(expires) || !grant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -59,6 +57,7 @@ function verifyImageToken(token) {
|
||||
expires: parseInt(expires),
|
||||
revealBypass: bypassFlag === '1',
|
||||
clientBypass: clientFlag === '1',
|
||||
galleryAccess: JSON.parse(Buffer.from(grant, 'base64url').toString()),
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
@@ -170,6 +169,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery
|
||||
res.send(finalImage);
|
||||
|
||||
} catch (error) {
|
||||
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||
logger.error('Error serving protected image:', error);
|
||||
res.status(500).json({ error: 'Failed to serve image' });
|
||||
}
|
||||
@@ -207,6 +207,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
|
||||
// Generate secure token. clientBypass lets a client's token keep serving
|
||||
// a photo hidden after minting; a guest's stops at the serve route.
|
||||
const token = secureImageService.generateSecureToken(photoId, req.sessionID || 'anonymous', {
|
||||
galleryAccess: req.galleryAccess,
|
||||
expiresIn,
|
||||
maxUses: protectionLevel === 'maximum' ? 1 : 3,
|
||||
clientFingerprint,
|
||||
@@ -222,6 +223,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||
logger.error('Error generating secure token:', error);
|
||||
res.status(500).json({ error: 'Failed to generate token' });
|
||||
}
|
||||
@@ -262,7 +264,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
|
||||
// Generate signed token. The client-bypass flag lets a PIN-client's
|
||||
// token keep serving a photo hidden after minting; a guest's token
|
||||
// (clientBypass=0) stops the moment the photo is hidden.
|
||||
const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel));
|
||||
const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel), req.galleryAccess);
|
||||
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
|
||||
|
||||
res.json({
|
||||
@@ -271,6 +273,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||
logger.error('Error generating signed URL:', error);
|
||||
res.status(500).json({ error: 'Failed to generate URL' });
|
||||
}
|
||||
@@ -299,6 +302,8 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
await galleryAccessService.authorize(event, tokenData.galleryAccess);
|
||||
|
||||
// Reveal mode (#838): a signed URL minted before a re-hide must not keep
|
||||
// serving hidden photos; tokens minted by bypass contexts carry the flag.
|
||||
if (isGalleryHidden(event) && !tokenData.revealBypass) {
|
||||
@@ -338,7 +343,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
|
||||
res.set({
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Length': imageBuffer.length,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
});
|
||||
|
||||
@@ -346,9 +351,10 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
|
||||
res.send(imageBuffer);
|
||||
|
||||
} catch (error) {
|
||||
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||
logger.error('Error serving signed image:', error);
|
||||
res.status(500).json({ error: 'Failed to serve image' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -62,7 +62,10 @@ const signedPdfStorage = multer.diskStorage({
|
||||
|
||||
const signedPdfUpload = multer({
|
||||
storage: signedPdfStorage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
||||
// CVE-2026-82333: single unnamed `file` field only, and this route is
|
||||
// unauthenticated (token-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) => {
|
||||
if (validateFileType(file.originalname, file.mimetype, ['application/pdf'])) return cb(null, true);
|
||||
return cb(new Error('Only PDF files are allowed'));
|
||||
|
||||
@@ -122,7 +122,11 @@ const tempStorage = multer.diskStorage({
|
||||
function buildUploader(maxSizeBytes, allowed) {
|
||||
return multer({
|
||||
storage: tempStorage,
|
||||
limits: { fileSize: maxSizeBytes, files: MAX_FILES_PER_UPLOAD },
|
||||
// CVE-2026-82333: files arrive as repeated `files` parts via .array(),
|
||||
// not bracket-indexed field names like `files[0]`, and this route is
|
||||
// unauthenticated (token-only) — no legitimate field name uses
|
||||
// array-index syntax at all. Reject any that do.
|
||||
limits: { fileSize: maxSizeBytes, files: MAX_FILES_PER_UPLOAD, fieldArrayIndexLimit: 0 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
|
||||
return cb(new Error('This file type is not allowed'));
|
||||
|
||||
@@ -4,6 +4,7 @@ const { db } = require('../database/db');
|
||||
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
||||
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const galleryAccessService = require('../services/galleryAccessService');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
@@ -58,6 +59,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
|
||||
|
||||
// Generate secure token with appropriate settings
|
||||
const tokenOptions = {
|
||||
galleryAccess: req.galleryAccess,
|
||||
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
|
||||
maxUses: accessType === 'download' ? 1 : 3,
|
||||
clientFingerprint,
|
||||
@@ -96,6 +98,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||
logger.error('Error generating secure token', {
|
||||
error: error.message,
|
||||
photoId: req.body.photoId,
|
||||
@@ -151,6 +154,10 @@ router.get('/:slug/secure/:photoId/:token',
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
// Revalidate the issuing session, ownership and gallery lifecycle at
|
||||
// every use, including capabilities minted before logout or restore.
|
||||
await galleryAccessService.authorize(event, tokenValidation.data?.galleryAccess);
|
||||
|
||||
// Bind the token to the gallery + photo it was minted for
|
||||
// (GHSA-g94x-8vv8-3c9f). This route serves via <img src> with the
|
||||
// token in the URL, so it can't require verifyGalleryAccess like the
|
||||
@@ -248,6 +255,7 @@ router.get('/:slug/secure/:photoId/:token',
|
||||
res.send(processedImage);
|
||||
|
||||
} catch (error) {
|
||||
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||
logger.error('Error serving secure image', {
|
||||
error: error.message,
|
||||
photoId,
|
||||
@@ -292,6 +300,8 @@ router.get('/:slug/secure-download/:photoId/:token',
|
||||
return res.status(403).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
|
||||
await galleryAccessService.authorize(req.event, tokenValidation.data?.galleryAccess);
|
||||
|
||||
// Bind the token to the photo it was minted for (GHSA-crxv) — the
|
||||
// /secure serve route does this, but secure-download did not, so a
|
||||
// token minted for photo A could download photo B (incl. a hidden one).
|
||||
@@ -386,6 +396,7 @@ router.get('/:slug/secure-download/:photoId/:token',
|
||||
res.send(fileBuffer);
|
||||
|
||||
} catch (error) {
|
||||
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||
logger.error('Error serving secure download', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId
|
||||
@@ -414,6 +425,7 @@ router.get('/security/stats', adminAuth, requirePermission('settings.view'), asy
|
||||
res.json(stats);
|
||||
|
||||
} catch (error) {
|
||||
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||
logger.error('Error getting security stats', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to get security stats' });
|
||||
}
|
||||
|
||||
@@ -1,283 +1,74 @@
|
||||
/**
|
||||
* Regression tests for issue #550.
|
||||
*
|
||||
* Two related bugs in POST /v1/events:
|
||||
* 1. color_theme was not accepted on the request body and never written
|
||||
* to the events row. Editing such an event later in the admin UI
|
||||
* snapped the theme picker to GALLERY_THEME_PRESETS.default and
|
||||
* saving overwrote whatever theme was inherited visually.
|
||||
* 2. event_feedback_settings row was never created, so the gallery UI
|
||||
* read it as "feedback off" regardless of the global
|
||||
* event_default_feedback_enabled toggle (#520).
|
||||
*
|
||||
* Test pattern mirrors events.category.test.js — queue up db() chains
|
||||
* with db.__setImplementations() in the exact order the handler invokes
|
||||
* them, then assert against the captured payloads.
|
||||
*/
|
||||
|
||||
/** Persisted contracts, not a mock tied to the number/order of Knex calls. */
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../../../../__tests__/integration/helpers/crmDb');
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const buildChain = ({ firstResult, insertResult, returningResult, selectResult } = {}) => {
|
||||
const chain = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
whereIn: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orWhere: jest.fn().mockReturnThis(),
|
||||
// `select` resolves to an array so `await db(...).whereIn(...).select(...)`
|
||||
// gives an iterable result (used by the branding-defaults probe added in
|
||||
// #592 follow-up). Tests that don't need it leave selectResult undefined
|
||||
// and get `[]`, which is a safe no-op for any caller that iterates.
|
||||
select: jest.fn().mockResolvedValue(selectResult ?? []),
|
||||
first: jest.fn().mockResolvedValue(firstResult),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
jest.mock('../../../database/db', () => {
|
||||
const dbMock = jest.fn();
|
||||
dbMock.raw = jest.fn();
|
||||
dbMock.__setImplementations = (...chains) => {
|
||||
dbMock.mockReset();
|
||||
chains.forEach((chain) => {
|
||||
dbMock.mockImplementationOnce(() => chain);
|
||||
});
|
||||
};
|
||||
return {
|
||||
db: dbMock,
|
||||
logActivity: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
let db, cleanup, app, adminId, adminToken, apiToken;
|
||||
const base = { event_type: 'wedding', event_name: 'Creation parity', event_date: '2030-06-15',
|
||||
customer_name: 'Ada', customer_email: 'ada@example.test', admin_email: 'admin@example.test',
|
||||
require_password: false, is_draft: false, expires_at: '2030-07-15T00:00:00.000Z' };
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb()); ({ adminId } = await seedMinimal(db)); await assignAdminRole(db, adminId);
|
||||
adminToken = mintAdminToken(adminId);
|
||||
const generated = require('../../../middleware/apiTokenAuth').generateApiToken(); apiToken = generated.plaintext;
|
||||
await db('api_tokens').insert({ name: 'parity', hashed_token: generated.hashed, scopes: 'admin', created_by: adminId });
|
||||
app = express(); app.use(express.json());
|
||||
app.use('/admin', require('../../adminEvents'));
|
||||
app.use('/v1', require('../events'));
|
||||
}, 120000);
|
||||
afterAll(async () => { await require('../../../services/serviceShutdown').stopServices(); await cleanup(); });
|
||||
async function create(source, extra) {
|
||||
const input = { ...base, ...extra };
|
||||
if (source === 'legacy') return require('../../../services/eventService').createEvent(input, { actor: { id: adminId } });
|
||||
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
|
||||
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send(input);
|
||||
expect(response.status).toBe(source === 'admin' ? 200 : 201);
|
||||
return response.body;
|
||||
}
|
||||
it.each(['admin', 'v1', 'legacy'])('%s stores theme, owner, dates and feedback defaults through one use case', async source => {
|
||||
const theme = JSON.stringify({ primaryColor: '#ff0066' });
|
||||
const created = await create(source, { color_theme: theme, feedback_enabled: true });
|
||||
const row = await db('events').where({ id: created.id }).first();
|
||||
expect(row).toMatchObject({ color_theme: theme, created_by: adminId, event_name: base.event_name, customer_email: base.customer_email });
|
||||
expect(require('../../../utils/dateNormalize').toIso(row.expires_at)).toBe(base.expires_at);
|
||||
expect([false, 0]).toContain(row.require_password);
|
||||
expect(row.updated_at).toBeTruthy(); expect(row.share_token).toBeTruthy(); expect(row.password_hash).toBeTruthy();
|
||||
const feedback = await db('event_feedback_settings').where({ event_id: row.id }).first();
|
||||
for (const key of ['feedback_enabled','allow_ratings','allow_likes','allow_comments','allow_favorites','allow_reactions','moderate_comments','show_feedback_to_guests']) expect([true, 1]).toContain(feedback[key]);
|
||||
expect([false, 0]).toContain(feedback.allow_color_labels); expect(feedback.keybind_mode).toBe('colors');
|
||||
});
|
||||
|
||||
// RBAC is enforced on these routes since GHSA-9697 (requirePermission), but
|
||||
// this suite mocks the database, so a real permission lookup would 500. These
|
||||
// tests cover route logic, not authorization — the intersection of token
|
||||
// scopes and role permissions is pinned in __tests__/routes/v1EventOwnership.
|
||||
jest.mock('../../../middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
userHasAnyPermission: async () => true,
|
||||
userHasAllPermissions: async () => true,
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/apiTokenAuth', () => ({
|
||||
apiTokenAuth: (req, _res, next) => {
|
||||
req.apiToken = { id: 1, admin_id: 1, scopes: ['admin'] };
|
||||
req.admin = { id: 1, username: 'token-admin' };
|
||||
next();
|
||||
},
|
||||
requireApiScope: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// bcrypt.hash is awaited twice per request (real path + dummy path).
|
||||
// Stub it to a constant so tests don't burn CPU on bcrypt rounds.
|
||||
jest.mock('bcrypt', () => ({
|
||||
hash: jest.fn().mockResolvedValue('$2b$10$mocked-hash'),
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/shareLinkService', () => ({
|
||||
buildShareLinkVariants: jest.fn().mockResolvedValue({
|
||||
shareUrl: 'https://example.test/gallery/some-slug?t=abc',
|
||||
shareLinkToStore: '/gallery/some-slug?t=abc',
|
||||
}),
|
||||
}));
|
||||
|
||||
// Webhook fire is in a try/catch; stub to silence the predictable
|
||||
// failure log so test output stays clean.
|
||||
jest.mock('../../../services/webhookService', () => ({
|
||||
fire: jest.fn().mockResolvedValue(undefined),
|
||||
buildEventSubject: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// event_type is validated against the live event_types catalog (#800) —
|
||||
// that lookup would consume the first queued db() chain and shift the
|
||||
// call sequence these tests pin. Stub it valid; the invalid path has its
|
||||
// own test below.
|
||||
jest.mock('../../../services/eventTypeService', () => ({
|
||||
isValidEventType: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const { db } = require('../../../database/db');
|
||||
const { isValidEventType } = require('../../../services/eventTypeService');
|
||||
const eventsRouter = require('../events');
|
||||
|
||||
const buildApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/', eventsRouter);
|
||||
return app;
|
||||
};
|
||||
|
||||
const BASE_BODY = {
|
||||
event_name: 'Issue 550 Wedding',
|
||||
event_type: 'wedding',
|
||||
event_date: '2026-06-15',
|
||||
require_password: false,
|
||||
};
|
||||
|
||||
// db() call sequence for BASE_BODY (no feedback / devtools provided,
|
||||
// require_password supplied so its probe is skipped, no customer_phone,
|
||||
// no slug collision):
|
||||
// 1. app_settings.where('event_default_feedback_enabled').first() (#550)
|
||||
// 2. app_settings.where('enable_devtools_protection').first() (#592)
|
||||
// 3. app_settings.whereIn([branding_logo_display_hero,...]).select(...) (#592 follow-up)
|
||||
// Then slug probe, events insert, optional feedback insert.
|
||||
const baseSettingsChains = () => [
|
||||
buildChain({ firstResult: null }), // feedback default
|
||||
buildChain({ firstResult: null }), // devtools default
|
||||
buildChain({ selectResult: [] }), // image-security whereIn → empty rows (#1296)
|
||||
buildChain({ selectResult: [] }), // branding whereIn → empty rows
|
||||
];
|
||||
|
||||
describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('persists color_theme to the events row when provided', async () => {
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 42 }] });
|
||||
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, color_theme: 'default' })
|
||||
.expect(201);
|
||||
|
||||
const insertedRow = insertChain.insert.mock.calls[0][0];
|
||||
expect(insertedRow).toMatchObject({
|
||||
event_name: 'Issue 550 Wedding',
|
||||
color_theme: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a JSON-encoded theme string and persists it verbatim', async () => {
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 43 }] });
|
||||
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
|
||||
|
||||
const customTheme = JSON.stringify({ primaryColor: '#ff0066' });
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, color_theme: customTheme })
|
||||
.expect(201);
|
||||
|
||||
const insertedRow = insertChain.insert.mock.calls[0][0];
|
||||
expect(insertedRow.color_theme).toBe(customTheme);
|
||||
});
|
||||
|
||||
it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => {
|
||||
// feedback_enabled provided → feedback probe SKIPPED. Sequence:
|
||||
// 1. devtools probe
|
||||
// 2. image-security probe (whereIn → select, #1296)
|
||||
// 3. branding probe (whereIn → select)
|
||||
// 4. slug probe
|
||||
// 5. events insert
|
||||
// 6. feedback sub-toggle defaults probe (whereIn → select, #1044)
|
||||
// 7. event_feedback_settings insert
|
||||
const devtoolsChain = buildChain({ firstResult: null });
|
||||
const imageSecurityChain = buildChain({ selectResult: [] });
|
||||
const brandingChain = buildChain({ selectResult: [] });
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 50 }] });
|
||||
const feedbackDefaultsChain = buildChain({ selectResult: [] });
|
||||
const feedbackInsertChain = buildChain();
|
||||
db.__setImplementations(
|
||||
devtoolsChain, imageSecurityChain, brandingChain, slugChain, insertChain,
|
||||
feedbackDefaultsChain, feedbackInsertChain,
|
||||
);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, feedback_enabled: true })
|
||||
.expect(201);
|
||||
|
||||
expect(db).toHaveBeenNthCalledWith(7, 'event_feedback_settings');
|
||||
|
||||
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
|
||||
expect(feedbackRow).toMatchObject({ event_id: 50 });
|
||||
// formatBoolean() returns 1/0 on SQLite and true/false on PG. Either
|
||||
// way the value must be truthy/falsy in the right places — assert by
|
||||
// coercion so the test stays driver-agnostic.
|
||||
expect(Boolean(feedbackRow.feedback_enabled)).toBe(true);
|
||||
expect(Boolean(feedbackRow.allow_ratings)).toBe(true);
|
||||
expect(Boolean(feedbackRow.allow_likes)).toBe(true);
|
||||
expect(Boolean(feedbackRow.allow_comments)).toBe(true);
|
||||
expect(Boolean(feedbackRow.allow_favorites)).toBe(true);
|
||||
// #1044: this insert used to omit allow_reactions entirely, so v1-created
|
||||
// events only got reactions by accident of the column default.
|
||||
expect(Boolean(feedbackRow.allow_reactions)).toBe(true);
|
||||
// Colour labels are opt-in, so they stay off until the global is flipped.
|
||||
expect(Boolean(feedbackRow.allow_color_labels)).toBe(false);
|
||||
expect(feedbackRow.keybind_mode).toBe('colors');
|
||||
expect(Boolean(feedbackRow.require_name_email)).toBe(false);
|
||||
expect(Boolean(feedbackRow.moderate_comments)).toBe(true);
|
||||
expect(Boolean(feedbackRow.show_feedback_to_guests)).toBe(true);
|
||||
});
|
||||
|
||||
it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => {
|
||||
// Feedback probe returns serialized "true" → fallback kicks in and
|
||||
// the feedback insert runs. Sequence: feedback probe, devtools probe,
|
||||
// image-security probe (#1296), branding probe, slug, insert, sub-toggle
|
||||
// defaults probe (#1044), feedback insert (8 calls total).
|
||||
const feedbackProbe = buildChain({
|
||||
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' },
|
||||
});
|
||||
const devtoolsChain = buildChain({ firstResult: null });
|
||||
const imageSecurityChain = buildChain({ selectResult: [] });
|
||||
const brandingChain = buildChain({ selectResult: [] });
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 51 }] });
|
||||
const feedbackDefaultsChain = buildChain({ selectResult: [] });
|
||||
const feedbackInsertChain = buildChain();
|
||||
db.__setImplementations(
|
||||
feedbackProbe, devtoolsChain, imageSecurityChain, brandingChain, slugChain,
|
||||
insertChain, feedbackDefaultsChain, feedbackInsertChain,
|
||||
);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send(BASE_BODY)
|
||||
.expect(201);
|
||||
|
||||
expect(db).toHaveBeenNthCalledWith(8, 'event_feedback_settings');
|
||||
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT create a feedback row when global setting is unset and body omits feedback_enabled', async () => {
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 52 }] });
|
||||
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send(BASE_BODY)
|
||||
.expect(201);
|
||||
|
||||
// 6 db() calls: feedback + devtools + image-security + branding probes,
|
||||
// slug, insert. event_feedback_settings is never touched.
|
||||
expect(db).toHaveBeenCalledTimes(6);
|
||||
expect(db).not.toHaveBeenCalledWith('event_feedback_settings');
|
||||
});
|
||||
|
||||
it('rejects non-boolean feedback_enabled with 400', async () => {
|
||||
// Validators run before any db() call, so no chain queueing needed.
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejects an event_type unknown to the catalog with 400 (#800)', async () => {
|
||||
isValidEventType.mockResolvedValueOnce(false);
|
||||
const res = await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, event_type: 'nope' })
|
||||
.expect(400);
|
||||
|
||||
expect(isValidEventType).toHaveBeenCalledWith('nope');
|
||||
expect(JSON.stringify(res.body.errors)).toContain('event_type');
|
||||
expect(db).not.toHaveBeenCalled();
|
||||
});
|
||||
it('inherits global feedback and preserves explicit overrides for every entry point', async () => {
|
||||
await db('app_settings').insert({ setting_key: 'event_default_feedback_enabled', setting_value: 'true', setting_type: 'boolean' })
|
||||
.onConflict('setting_key').merge({ setting_value: 'true' });
|
||||
for (const source of ['admin', 'v1', 'legacy']) {
|
||||
const inherited = await create(source, {});
|
||||
expect(await db('event_feedback_settings').where({ event_id: inherited.id }).first()).toBeTruthy();
|
||||
const override = await create(source, { feedback_enabled: false });
|
||||
expect(await db('event_feedback_settings').where({ event_id: override.id }).first()).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it('queues a publication email only for published galleries', async () => {
|
||||
const draft = await create('admin', { is_draft: true });
|
||||
expect(await db('email_queue').where({ event_id: draft.id })).toHaveLength(0);
|
||||
const published = await create('v1', {});
|
||||
expect(await db('email_queue').where({ event_id: published.id, email_type: 'gallery_created' })).toHaveLength(1);
|
||||
});
|
||||
it('rejects a required password that is missing with 400 on both routes', async () => {
|
||||
for (const source of ['admin', 'v1']) {
|
||||
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
|
||||
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send({ ...base, require_password: true });
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
});
|
||||
it('keeps accepting "0"/"1" string booleans on the v1 surface', async () => {
|
||||
const created = await create('v1', { require_password: '0', feedback_enabled: '1' });
|
||||
const row = await db('events').where({ id: created.id }).first();
|
||||
expect([false, 0]).toContain(row.require_password);
|
||||
expect(await db('event_feedback_settings').where({ event_id: row.id }).first()).toBeTruthy();
|
||||
});
|
||||
it.each([{ feedback_enabled: 'maybe' }, { event_type: 'unknown' }])('rejects invalid creation data before persistence: %j', async extra => {
|
||||
for (const source of ['admin', 'v1']) {
|
||||
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
|
||||
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send({ ...base, ...extra });
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
+10
-260
@@ -30,15 +30,13 @@ const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ow
|
||||
// requirePermission gates supply the missing half; they key on req.admin.id,
|
||||
// which apiTokenAuth populates.
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const { resolveEventFeedbackDefaults } = require('../../services/feedbackDefaults');
|
||||
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
|
||||
|
||||
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { generateThumbnail } = require('../../services/imageProcessor');
|
||||
const logger = require('../../utils/logger');
|
||||
const { slugify } = require('../../utils/slug');
|
||||
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
const { getImageSecurityDefaults, resolveImageSecurityColumns, decodeSettingValue } = require('../adminEvents/helpers');
|
||||
|
||||
const { isValidEventType } = require('../../services/eventTypeService');
|
||||
const { replacePhoto } = require('../../services/photoReplacementService');
|
||||
const { getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings');
|
||||
@@ -71,7 +69,9 @@ const photoStorage = multer.diskStorage({
|
||||
});
|
||||
const buildPhotoUpload = (maxFileSizeBytes) => multer({
|
||||
storage: photoStorage,
|
||||
limits: { fileSize: maxFileSizeBytes },
|
||||
// CVE-2026-82333: single unnamed `photo` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: maxFileSizeBytes, fieldArrayIndexLimit: 0 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (/^image\//.test(file.mimetype)) cb(null, true);
|
||||
else cb(new Error('Only image uploads are accepted on this endpoint'));
|
||||
@@ -195,262 +195,12 @@ router.post(
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
const {
|
||||
event_name, event_type, event_date,
|
||||
customer_name = null, customer_email = null, customer_phone = null,
|
||||
admin_email = null,
|
||||
require_password: requirePasswordInput,
|
||||
password,
|
||||
expires_at = null,
|
||||
color_theme = null,
|
||||
feedback_enabled: feedbackEnabledInput,
|
||||
enable_devtools_protection: devtoolsInput,
|
||||
hero_logo_visible: heroLogoVisibleInput,
|
||||
hero_logo_size: heroLogoSizeInput,
|
||||
hero_logo_position: heroLogoPositionInput
|
||||
} = req.body;
|
||||
|
||||
// Issue #550 — mirror the admin POST path so API-created events
|
||||
// pick up the global "Enable Guest Feedback by default" toggle
|
||||
// (event_default_feedback_enabled). Without this, the UI reads
|
||||
// a missing event_feedback_settings row as "feedback off"
|
||||
// regardless of the admin's chosen default.
|
||||
let feedbackEnabledFallback = false;
|
||||
if (feedbackEnabledInput === undefined) {
|
||||
const setting = await db('app_settings').where('setting_key', 'event_default_feedback_enabled').first();
|
||||
if (setting) {
|
||||
try {
|
||||
const parsed = JSON.parse(setting.setting_value);
|
||||
if (typeof parsed === 'boolean') feedbackEnabledFallback = parsed;
|
||||
} catch { /* keep false */ }
|
||||
}
|
||||
}
|
||||
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
|
||||
|
||||
// Issue #592 — same shape as the feedback fallback above. The
|
||||
// events table column default is `true`, so without this an admin
|
||||
// who disabled devtools detection globally still gets it ON for
|
||||
// every API-created gallery. Mirrors adminEvents.js behaviour.
|
||||
let devtoolsFallback = true;
|
||||
if (devtoolsInput === undefined) {
|
||||
const setting = await db('app_settings').where('setting_key', 'enable_devtools_protection').first();
|
||||
if (setting) {
|
||||
// Shared decoder: a legacy row can carry several layers of JSON
|
||||
// quoting, and a single parse would leave the string 'false' here,
|
||||
// reject it, and quietly enable protection the operator disabled.
|
||||
const parsed = decodeSettingValue(setting.setting_value);
|
||||
if (typeof parsed === 'boolean') devtoolsFallback = parsed;
|
||||
}
|
||||
}
|
||||
const enable_devtools_protection = parseBooleanInput(devtoolsInput, devtoolsFallback);
|
||||
|
||||
// #1296 — same shape again, for the four Image Security settings that
|
||||
// were stored and applied nowhere. Shared with the admin create route
|
||||
// so a gallery's security level does not depend on which endpoint made
|
||||
// it; #592 above is the bug this would otherwise repeat.
|
||||
const imageSecurityColumns = resolveImageSecurityColumns(
|
||||
req.body,
|
||||
await getImageSecurityDefaults(),
|
||||
);
|
||||
|
||||
// Same shape as the feedback / devtools fallbacks: honour the global
|
||||
// event_default_require_password toggle (#317). Without this an admin
|
||||
// who disabled "require password by default" globally still got
|
||||
// password-required galleries through the API.
|
||||
let requirePasswordFallback = true;
|
||||
if (requirePasswordInput === undefined) {
|
||||
const setting = await db('app_settings').where('setting_key', 'event_default_require_password').first();
|
||||
if (setting) {
|
||||
try {
|
||||
const parsed = JSON.parse(setting.setting_value);
|
||||
if (typeof parsed === 'boolean') requirePasswordFallback = parsed;
|
||||
} catch { /* keep true */ }
|
||||
}
|
||||
}
|
||||
const require_password = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
|
||||
|
||||
// Branding inheritance (Feature 7) — mirror adminEvents.js
|
||||
// getBrandingDefaults so API-created events inherit the global
|
||||
// hero logo visibility + size. hero_logo_position is intentionally
|
||||
// NOT settings-backed (see migration 084 / #357 — branding_logo_position
|
||||
// is the *header bar*, a different concept than the hero block).
|
||||
let heroLogoVisibleFallback = true;
|
||||
let heroLogoSizeFallback = 'medium';
|
||||
const brandingRows = await db('app_settings')
|
||||
.whereIn('setting_key', ['branding_logo_display_hero', 'branding_logo_size'])
|
||||
.select('setting_key', 'setting_value');
|
||||
for (const row of brandingRows) {
|
||||
let value = row.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch { /* keep raw */ }
|
||||
}
|
||||
if (row.setting_key === 'branding_logo_display_hero') heroLogoVisibleFallback = value !== false;
|
||||
if (row.setting_key === 'branding_logo_size' && value) heroLogoSizeFallback = value;
|
||||
}
|
||||
const hero_logo_visible = heroLogoVisibleInput !== undefined ? heroLogoVisibleInput : heroLogoVisibleFallback;
|
||||
const hero_logo_size = heroLogoSizeInput || heroLogoSizeFallback;
|
||||
const hero_logo_position = heroLogoPositionInput || 'top';
|
||||
|
||||
if (require_password && (!password || password.length < 6)) {
|
||||
return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' });
|
||||
}
|
||||
|
||||
// Honour global phone-field toggle (#322).
|
||||
let persistPhone = null;
|
||||
if (customer_phone) {
|
||||
const setting = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
|
||||
const enabled = setting ? JSON.parse(setting.setting_value) === true : false;
|
||||
persistPhone = enabled ? customer_phone : null;
|
||||
}
|
||||
|
||||
// Generate unique slug.
|
||||
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date || crypto.randomBytes(3).toString('hex')}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
while (await db('events').where({ slug }).first()) slug = `${baseSlug}-${counter++}`;
|
||||
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// password_hash is NOT NULL; use a random placeholder when no
|
||||
// password is required so the column constraint is satisfied.
|
||||
const bcrypt = require('bcrypt');
|
||||
const passwordHash = require_password
|
||||
? await bcrypt.hash(password, 10)
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10);
|
||||
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
event_date: event_date || null,
|
||||
host_name: customer_name,
|
||||
host_email: customer_email,
|
||||
admin_email,
|
||||
password_hash: passwordHash,
|
||||
// #1271 — recoverable copy rides with the hash; only when there is one
|
||||
...(require_password && password ? await galleryPasswordColumns({ password }) : {}),
|
||||
require_password,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at || null,
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: req.admin.id,
|
||||
is_draft: false,
|
||||
// Issue #550 — without this, editing an API-created event in the
|
||||
// admin UI snaps the theme picker to GALLERY_THEME_PRESETS.default
|
||||
// and saving overwrites whatever theme was inherited visually.
|
||||
color_theme,
|
||||
// Issue #592 — write the resolved devtools setting (input value
|
||||
// or global fallback) so the column default doesn't shadow it.
|
||||
enable_devtools_protection: formatBoolean(enable_devtools_protection),
|
||||
// Request value, else the global default, else the column default.
|
||||
...imageSecurityColumns,
|
||||
// Branding inheritance — resolved value from body or app_settings.
|
||||
hero_logo_visible: formatBoolean(hero_logo_visible),
|
||||
hero_logo_size,
|
||||
hero_logo_position,
|
||||
...(customer_name ? { customer_name } : {}),
|
||||
...(customer_email ? { customer_email } : {}),
|
||||
...(persistPhone ? { customer_phone: persistPhone } : {})
|
||||
}).returning('id');
|
||||
const id = insertResult[0]?.id || insertResult[0];
|
||||
if (require_password && password) await dropCopiesIfStorageOff(id);
|
||||
|
||||
// Issue #550 — mirror adminEvents.js: create event_feedback_settings
|
||||
// row when feedback is enabled, so the gallery actually shows feedback
|
||||
// UI. The sub-flags come from the shared global defaults (#1044) rather
|
||||
// than a hard-coded list, which is how this path silently shipped
|
||||
// without allow_reactions for two releases.
|
||||
if (feedback_enabled) {
|
||||
const feedbackDefaults = await resolveEventFeedbackDefaults();
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: id,
|
||||
feedback_enabled: formatBoolean(true),
|
||||
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(false),
|
||||
moderate_comments: formatBoolean(true),
|
||||
show_feedback_to_guests: formatBoolean(true),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
await logActivity('event_created', { via: 'api_v1', event_type }, id, {
|
||||
type: 'admin', id: req.admin.id, name: req.admin.username
|
||||
const created = await require('../../services/eventCreationService').createEvent(req.body, {
|
||||
actor: req.admin, source: 'v1',
|
||||
});
|
||||
|
||||
// Customer notifications (#647 follow-up). v1 events go live in the
|
||||
// same call (not draft-aware), so the gallery_created email + WhatsApp
|
||||
// fire here — mirroring the adminEvents.js create-and-publish path.
|
||||
// Both are best-effort: a queue failure must not block the API response.
|
||||
const expiryIso = expires_at ? new Date(expires_at).toISOString() : null;
|
||||
if (customer_email) {
|
||||
try {
|
||||
const { queueEmail } = require('../../services/emailProcessor');
|
||||
await queueEmail(id, customer_email, 'gallery_created', {
|
||||
customer_name: customer_name || '',
|
||||
customer_email,
|
||||
host_name: customer_name || '',
|
||||
event_name,
|
||||
event_date: event_date || null,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: require_password ? password : 'No password required',
|
||||
expiry_date: expiryIso,
|
||||
welcome_message: ''
|
||||
});
|
||||
} catch (emailError) {
|
||||
logger.warn('v1 POST /events: failed to queue gallery_created email', { error: emailError.message });
|
||||
}
|
||||
}
|
||||
if (persistPhone) {
|
||||
try {
|
||||
const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor');
|
||||
const waConfig = await getWhatsAppConfig();
|
||||
if (waConfig && waConfig.enabled) {
|
||||
await queueWhatsapp(id, persistPhone, 'gallery_created', {
|
||||
customer_name: customer_name || '',
|
||||
event_name,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: require_password ? password : '',
|
||||
expiry_date: expiryIso,
|
||||
language: null,
|
||||
});
|
||||
}
|
||||
} catch (waError) {
|
||||
logger.warn('v1 POST /events: failed to queue WhatsApp notification', { error: waError.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook lifecycle (#327). v1 events are not draft-aware, so they're
|
||||
// both created AND published in the same call. Canonical event
|
||||
// subject (#341) — customer contact + share_token always included.
|
||||
try {
|
||||
const webhookService = require('../../services/webhookService');
|
||||
const eventSubject = webhookService.buildEventSubject({
|
||||
id,
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
event_date,
|
||||
share_url: shareUrl,
|
||||
share_token: shareToken,
|
||||
customer_name,
|
||||
customer_email,
|
||||
customer_phone,
|
||||
});
|
||||
await webhookService.fire('event.created', { event: eventSubject });
|
||||
await webhookService.fire('event.published', { event: eventSubject });
|
||||
} catch (e) { /* non-fatal */ }
|
||||
|
||||
res.status(201).json({ id, slug, share_url: shareUrl, share_token: shareToken });
|
||||
res.status(201).json({ id: created.id, slug: created.slug, share_url: created.share_link, share_token: created.share_token });
|
||||
} catch (error) {
|
||||
if (error.isOperational) return res.status(error.statusCode).json(error.responseBody || { error: error.message, code: error.code });
|
||||
logger.error('v1 POST /events failed', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({ error: 'Failed to create event', detail: error.message });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { DatabaseBackupService } = require('../databaseBackup');
|
||||
const { db } = require('../../database/db');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Mock dependencies
|
||||
@@ -8,6 +8,10 @@ jest.mock('../../database/db');
|
||||
jest.mock('../../utils/logger');
|
||||
jest.mock('../emailProcessor');
|
||||
jest.mock('child_process');
|
||||
jest.mock('node-cron', () => ({ schedule: jest.fn(() => ({ stop: jest.fn() })) }));
|
||||
|
||||
const { DatabaseBackupService, startScheduledBackups, databaseBackupService, isUnderPubliclyServableRoot } = require('../databaseBackup');
|
||||
const cron = require('node-cron');
|
||||
|
||||
describe('DatabaseBackupService', () => {
|
||||
let service;
|
||||
@@ -191,6 +195,213 @@ describe('DatabaseBackupService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup() destination path resolution (#1365)', () => {
|
||||
// getBackupConfig() returns database_backup_*-prefixed keys.
|
||||
// Regression: backup() used to destructure the unprefixed names
|
||||
// (`destinationPath`, ...) straight off that object, which never
|
||||
// matched, so the configured path was silently ignored and every
|
||||
// run tried to create the hardcoded /backup/database default.
|
||||
it('creates the directory from database_backup_destination_path when configured', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_destination_path', setting_value: JSON.stringify('/data/db-backups') }
|
||||
])
|
||||
});
|
||||
|
||||
const stop = new Error('stop after mkdir — nothing past it matters for this test');
|
||||
const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop);
|
||||
|
||||
await expect(service.backup({})).rejects.toThrow(stop.message);
|
||||
|
||||
expect(mkdirSpy).toHaveBeenCalledWith('/data/db-backups', { recursive: true });
|
||||
mkdirSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('falls back to /backup/database only when nothing is configured', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([])
|
||||
});
|
||||
|
||||
const stop = new Error('stop after mkdir');
|
||||
const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop);
|
||||
|
||||
await expect(service.backup({})).rejects.toThrow(stop.message);
|
||||
|
||||
expect(mkdirSpy).toHaveBeenCalledWith('/backup/database', { recursive: true });
|
||||
mkdirSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUnderPubliclyServableRoot (GHSA-jw8m class, #1365)', () => {
|
||||
const originalStoragePath = process.env.STORAGE_PATH;
|
||||
const storage = '/tmp/picpeak-test-storage';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.STORAGE_PATH = storage;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalStoragePath === undefined) {
|
||||
delete process.env.STORAGE_PATH;
|
||||
} else {
|
||||
process.env.STORAGE_PATH = originalStoragePath;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
path.join(storage, 'uploads', 'logos'),
|
||||
path.join(storage, 'uploads', 'logos', 'sub'),
|
||||
path.join(storage, 'uploads', 'favicons'),
|
||||
path.join(storage, 'fonts'),
|
||||
path.join(storage, 'fonts', 'inter'),
|
||||
// Bundled fallback fonts — nodejs-owned per the Dockerfile's
|
||||
// COPY --chown, and served at the same public /fonts route.
|
||||
path.resolve(__dirname, '../../../assets/fonts'),
|
||||
// Case-insensitive-but-preserving filesystems (APFS, NTFS, Docker
|
||||
// Desktop bind mounts of either) resolve this to the same directory
|
||||
// as uploads/logos even though path.resolve() never folds case.
|
||||
path.join(storage, 'UPLOADS', 'Logos')
|
||||
])('flags %s as publicly servable', (candidate) => {
|
||||
expect(isUnderPubliclyServableRoot(candidate)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
path.join(storage, 'backups'),
|
||||
path.join(storage, 'uploads', 'contracts', 'signed'),
|
||||
path.join(storage, 'uploads', 'transfers', '123'),
|
||||
'/data/db-backups'
|
||||
])('does not flag %s', (candidate) => {
|
||||
expect(isUnderPubliclyServableRoot(candidate)).toBe(false);
|
||||
});
|
||||
|
||||
it('backup() refuses a destination inside a publicly servable root without ever calling mkdir', async () => {
|
||||
const publicPath = path.join(storage, 'uploads', 'logos');
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_destination_path', setting_value: JSON.stringify(publicPath) }
|
||||
])
|
||||
});
|
||||
|
||||
const mkdirSpy = jest.spyOn(fs, 'mkdir');
|
||||
|
||||
await expect(service.backup({})).rejects.toThrow('publicly served directory');
|
||||
|
||||
expect(mkdirSpy).not.toHaveBeenCalled();
|
||||
mkdirSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('flags FRONTEND_DIR — the all-in-one image serves its built SPA unauthenticated', () => {
|
||||
const originalFrontendDir = process.env.FRONTEND_DIR;
|
||||
process.env.FRONTEND_DIR = '/app/frontend/dist';
|
||||
try {
|
||||
expect(isUnderPubliclyServableRoot('/app/frontend/dist')).toBe(true);
|
||||
expect(isUnderPubliclyServableRoot(path.join('/app/frontend/dist', 'assets'))).toBe(true);
|
||||
} finally {
|
||||
if (originalFrontendDir === undefined) delete process.env.FRONTEND_DIR;
|
||||
else process.env.FRONTEND_DIR = originalFrontendDir;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a symlinked alias of a public root to the same real directory (all-in-one /app/storage -> /data/storage)', async () => {
|
||||
const os = require('os');
|
||||
const realRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-real-'));
|
||||
const linkRoot = path.join(os.tmpdir(), `picpeak-link-${process.pid}-${Date.now()}`);
|
||||
await fs.mkdir(path.join(realRoot, 'uploads', 'logos'), { recursive: true });
|
||||
await fs.symlink(realRoot, linkRoot, 'dir');
|
||||
|
||||
try {
|
||||
// STORAGE_PATH (what the guard's roots are built from) is the real
|
||||
// path; the attacker-supplied destination goes through the symlink
|
||||
// — exactly the all-in-one image's /app/storage -> /data/storage.
|
||||
process.env.STORAGE_PATH = realRoot;
|
||||
const aliased = path.join(linkRoot, 'uploads', 'logos');
|
||||
|
||||
expect(isUnderPubliclyServableRoot(aliased)).toBe(true);
|
||||
} finally {
|
||||
await fs.unlink(linkRoot);
|
||||
await fs.rm(realRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('startScheduledBackups (#1365)', () => {
|
||||
// Same key-mismatch bug as backup(): getBackupConfig() returns
|
||||
// database_backup_*-prefixed keys, but this read `config.enabled` /
|
||||
// `config.schedule` / `config.retentionDays` — always undefined, so
|
||||
// the scheduler silently treated every install as disabled.
|
||||
it('does not start the schedule while database_backup_enabled is false', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'false' }
|
||||
])
|
||||
});
|
||||
|
||||
await startScheduledBackups();
|
||||
|
||||
expect(cron.schedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the schedule with the configured cron when database_backup_enabled is true', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'database_backup_schedule', setting_value: JSON.stringify('0 4 * * *') }
|
||||
])
|
||||
});
|
||||
|
||||
await startScheduledBackups();
|
||||
|
||||
expect(cron.schedule).toHaveBeenCalledWith('0 4 * * *', expect.any(Function));
|
||||
});
|
||||
|
||||
it('re-reads retention on every tick instead of the value captured at schedule start (#1365)', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(30) }
|
||||
])
|
||||
});
|
||||
|
||||
await startScheduledBackups();
|
||||
const tick = cron.schedule.mock.calls[0][1];
|
||||
|
||||
// A /config update between schedule-start and this tick raised
|
||||
// retention to 365 — the closed-over 30 must not be what runs.
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(365) }
|
||||
])
|
||||
});
|
||||
jest.spyOn(databaseBackupService, 'backup').mockResolvedValue({ success: true });
|
||||
const cleanupSpy = jest.spyOn(databaseBackupService, 'cleanupOldBackups').mockResolvedValue(undefined);
|
||||
|
||||
await tick();
|
||||
|
||||
expect(cleanupSpy).toHaveBeenCalledWith(365);
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldBackups destructive-retention guard (#1365)', () => {
|
||||
it.each([-1, 0, NaN, Infinity])('refuses retentionDays=%s without touching the database', async (bad) => {
|
||||
const dbSpy = jest.fn();
|
||||
db.mockImplementation(dbSpy);
|
||||
|
||||
await service.cleanupOldBackups(bad);
|
||||
|
||||
expect(dbSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldBackups', () => {
|
||||
it('should delete old backup files and records', async () => {
|
||||
const oldBackups = [
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
jest.mock('../../utils/logger');
|
||||
jest.mock('fluent-ffmpeg');
|
||||
jest.mock('../storage', () => ({
|
||||
getStorage: jest.fn()
|
||||
}));
|
||||
jest.mock('../imageProcessor', () => ({
|
||||
generateVideoPlaceholder: jest.fn(),
|
||||
DEFAULT_THUMBNAIL_WIDTH: 300,
|
||||
DEFAULT_THUMBNAIL_HEIGHT: 300
|
||||
}));
|
||||
|
||||
const ffmpeg = require('fluent-ffmpeg');
|
||||
const { getStorage } = require('../storage');
|
||||
const { generateVideoPlaceholder } = require('../imageProcessor');
|
||||
const {
|
||||
extractVideoMetadata,
|
||||
processUploadedVideo
|
||||
} = require('../videoProcessor');
|
||||
|
||||
describe('extractVideoMetadata (#1370)', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('returns null duration rather than 0 when ffprobe has none, so "unknown" and "a real 0s clip" stay distinguishable', async () => {
|
||||
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
|
||||
cb(null, {
|
||||
streams: [{ codec_type: 'video', width: 1920, height: 1080, codec_name: 'hevc' }],
|
||||
format: {} // no duration field at all
|
||||
});
|
||||
});
|
||||
|
||||
const metadata = await extractVideoMetadata('/tmp/video.mp4');
|
||||
|
||||
expect(metadata.duration).toBeNull();
|
||||
expect(metadata.width).toBe(1920);
|
||||
expect(metadata.videoCodec).toBe('hevc');
|
||||
});
|
||||
|
||||
it('floors a real duration', async () => {
|
||||
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
|
||||
cb(null, { streams: [], format: { duration: 12.9 } });
|
||||
});
|
||||
|
||||
const metadata = await extractVideoMetadata('/tmp/video.mp4');
|
||||
|
||||
expect(metadata.duration).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processUploadedVideo degrades gracefully instead of rejecting the whole video (#1370)', () => {
|
||||
let storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = { putFromFile: jest.fn().mockResolvedValue(undefined), exists: jest.fn().mockResolvedValue(true) };
|
||||
getStorage.mockReturnValue(storage);
|
||||
generateVideoPlaceholder.mockResolvedValue('thumbnails/thumb_placeholder.jpg');
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('keeps the thumbnail when only metadata extraction fails', async () => {
|
||||
ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('moov atom not found')));
|
||||
ffmpeg.mockImplementation(() => ({
|
||||
screenshots: jest.fn(function screenshots({ filename, folder }) {
|
||||
require('fs').writeFileSync(require('path').join(folder, filename), 'jpeg-bytes');
|
||||
return this;
|
||||
}),
|
||||
on(event, handler) {
|
||||
if (event === 'end') setImmediate(handler);
|
||||
return this;
|
||||
}
|
||||
}));
|
||||
|
||||
const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_video.jpg');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.metadata).toBeNull();
|
||||
expect(result.thumbnailKey).toBe('thumbnails/thumb_video.jpg');
|
||||
// A real thumbnail already succeeded — never touch the placeholder path.
|
||||
expect(generateVideoPlaceholder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the SVG placeholder when thumbnail generation fails, so the gallery never falls back to rendering the raw video as an <img> (codex review)', async () => {
|
||||
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
|
||||
cb(null, {
|
||||
streams: [{ codec_type: 'video', width: 1080, height: 1920, codec_name: 'h264' }],
|
||||
format: { duration: 5.4 }
|
||||
});
|
||||
});
|
||||
ffmpeg.mockImplementation(() => ({
|
||||
screenshots() { return this; },
|
||||
on(event, handler) {
|
||||
if (event === 'error') setImmediate(() => handler(new Error('ffmpeg seek failed')));
|
||||
return this;
|
||||
}
|
||||
}));
|
||||
|
||||
const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_wedding_001.jpg');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.metadata).toEqual(expect.objectContaining({ duration: 5, videoCodec: 'h264' }));
|
||||
// thumbnailKey is always thumbnails/thumb_<name>.jpg — strip the prefix
|
||||
// back to a filename so generateVideoPlaceholder recomputes the same key.
|
||||
// Explicit width/height so generateVideoPlaceholder skips its DB-backed
|
||||
// settings lookup — this can run inside an open per-file SQLite
|
||||
// transaction (chunked video upload), where that lookup deadlocks.
|
||||
expect(generateVideoPlaceholder).toHaveBeenCalledWith('wedding_001.jpg', { width: 300, height: 300 });
|
||||
expect(result.thumbnailKey).toBe('thumbnails/thumb_placeholder.jpg');
|
||||
expect(storage.putFromFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when metadata, thumbnail generation, AND the placeholder all fail, so the caller surfaces a retryable failure instead of completing with nothing to show (codex review)', async () => {
|
||||
ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('Invalid data found when processing input')));
|
||||
ffmpeg.mockImplementation(() => ({
|
||||
screenshots() { return this; },
|
||||
on(event, handler) {
|
||||
if (event === 'error') setImmediate(() => handler(new Error('ffmpeg seek failed')));
|
||||
return this;
|
||||
}
|
||||
}));
|
||||
generateVideoPlaceholder.mockRejectedValue(new Error('sharp render failed'));
|
||||
|
||||
await expect(processUploadedVideo('/tmp/corrupt.mp4', 'thumbnails/thumb_corrupt.jpg'))
|
||||
.rejects.toThrow('Unable to generate any thumbnail');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user