Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Nothaft 6cc38a6778 docs: define security support across stable and main 2026-09-07 23:12:49 +02:00
180 changed files with 8948 additions and 10781 deletions
-11
View File
@@ -125,11 +125,6 @@ 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.
#
@@ -358,9 +353,3 @@ 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
+8 -11
View File
@@ -24,20 +24,17 @@ 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 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]
- 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]
**Logs**
Please include relevant logs:
```
# 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
# Backend logs
docker-compose logs backend | tail -50
# Frontend console errors
[paste any browser console errors]
@@ -47,4 +44,4 @@ docker logs --tail=50 picpeak
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.
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 📚 Documentation
url: https://docs.picpeak.app
about: Installation, configuration and feature guides
url: https://github.com/PicPeak/picpeak/blob/main/DEPLOYMENT.md
about: Please read the documentation before opening an issue
- 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
+1 -4
View File
@@ -9,7 +9,6 @@ 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
@@ -17,8 +16,6 @@ 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?
@@ -33,4 +30,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.
+17 -21
View File
@@ -6,6 +6,11 @@ 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.
@@ -84,7 +89,18 @@ 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: npx jest --ci
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
frontend:
runs-on: ubuntu-latest
@@ -105,30 +121,10 @@ 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 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.131.3-beta.0"
".": "3.130.0-beta.0"
}
-65
View File
@@ -5,71 +5,6 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.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
View File
@@ -59,7 +59,7 @@ Unsure where to begin? You can start by looking through these issues:
### Prerequisites
- Node.js 22.12.0 or later (matches `backend/package.json`)
- Node.js 18+
- Docker & Docker Compose
- Git
+1 -1
View File
@@ -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:** [Support](SUPPORT.md) · [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
**Project meta:** [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
## 📊 Comparison with Alternatives
-33
View File
@@ -1,33 +0,0 @@
# 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 logInfo;
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
@@ -54,10 +54,6 @@ 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());
@@ -98,20 +94,8 @@ describe('admin thumbnail regeneration (#1129)', () => {
return typeof row === 'object' ? row.id : row;
}
/**
* 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));
}
};
/** The work runs in setImmediate; give it room to finish. */
const drain = () => new Promise((resolve) => setTimeout(resolve, 150));
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
const eventId = await seedEvent();
@@ -129,15 +129,8 @@ 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' });
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).toHaveLength(imageProcessor.PREVIEW_WIDTHS.length - 1);
expect(keys.some((k) => k.includes('w1920'))).toBe(false);
expect(keys.every((k) => k.includes('p5_'))).toBe(true);
});
@@ -54,7 +54,6 @@ 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');
@@ -121,24 +120,6 @@ 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 () => {
@@ -1,96 +0,0 @@
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,10 +5,9 @@ process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
const http = require('http');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
let db, cleanup, adminId;
let webhookService;
let __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker;
const { db } = require('../../src/database/db');
const webhookService = require('../../src/services/webhookService');
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
// 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
@@ -44,7 +43,7 @@ async function insertWebhook(url, events = ['event.published'], extras = {}) {
secret_preview: preview,
events: JSON.stringify(events),
active: extras.active !== false,
created_by: adminId,
created_by: 1,
}).returning('id');
const id = insert[0]?.id || insert[0];
return { id, secret: plaintext };
@@ -57,15 +56,16 @@ async function clearWebhooks() {
describe('webhook delivery worker (#327)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
webhookService = require('../../src/services/webhookService');
({ __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker'));
// 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');
}, 30000);
afterAll(async () => {
await stopWebhookDeliveryWorker();
await cleanup();
stopWebhookDeliveryWorker();
await db.destroy();
});
beforeEach(async () => {
@@ -20,7 +20,6 @@ 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 () => {
@@ -28,7 +27,6 @@ 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;
}),
};
@@ -36,7 +34,6 @@ 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) }));
@@ -78,7 +75,7 @@ describe('general rate limiter skip', () => {
});
describe('admin preview requires a live admin session', () => {
const req = (token) => ({ params: { slug: 'preview' }, query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} });
const req = (token) => ({ 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 () => {
@@ -111,23 +108,13 @@ 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(false);
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(true);
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);
@@ -1,80 +0,0 @@
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();
});
@@ -1,40 +0,0 @@
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);
});
});
@@ -1,57 +0,0 @@
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,7 +29,6 @@ jest.mock('../../src/services/productUsageService', () =>
'tick',
'status',
'dismiss',
'markPromptShown',
'enable',
'disable',
'abandon',
@@ -130,7 +129,6 @@ const ROUTES = [
['post', '/abandon'],
['post', '/retry'],
['post', '/dismiss'],
['post', '/prompt-seen'],
['get', '/preview'],
['get', '/export'],
['put', '/feedback-preferences'],
@@ -180,15 +178,6 @@ 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,82 +1,460 @@
/** Session restoration uses the same live policy as protected routes. */
/**
* 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');
const request = require('supertest');
const jwt = require('jsonwebtoken');
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);
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 };
});
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() }),
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);
});
});
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);
});
@@ -1,122 +0,0 @@
/**
* 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);
});
});
@@ -1,203 +0,0 @@
/** 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(); }
});
@@ -1,40 +0,0 @@
/**
* 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 }));
});
});
@@ -1,75 +0,0 @@
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);
});
@@ -22,38 +22,29 @@ process.env.JWT_SECRET = process.env.JWT_SECRET || 'restorepath-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
// `bootCrmDb()` hands back the process-wide `db` singleton (module cache —
// see its own comment), so it must only be called ONCE per test file: a
// second call re-runs migrations against the same connection, and the first
// call's `cleanup()` (db.destroy()) would tear down the connection both
// describe blocks below share. Boot once at file scope; each describe below
// only touches app_settings / env vars, never the connection lifecycle.
let db; let cleanup; let checkRestorePathsAllowed;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function setBackupSetting(key, value) {
const existing = await db('app_settings').where({ setting_key: key }).first();
if (existing) {
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
} else {
await db('app_settings').insert({
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
});
}
}
describe('restore path allowlist (GHSA-fw4c)', () => {
let db; let cleanup; let checkRestorePathsAllowed;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// Configure a backup root so the allowlist is actually active.
await setBackupSetting('backup_destination_path', '/backup');
});
for (const [key, value] of [['backup_destination_path', '/backup']]) {
const existing = await db('app_settings').where({ setting_key: key }).first();
if (existing) {
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
} else {
await db('app_settings').insert({
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
});
}
}
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('allows the wizard\'s source TYPE tokens', async () => {
for (const source of ['local', 's3', 'upload']) {
@@ -93,89 +84,3 @@ describe('restore path allowlist (GHSA-fw4c)', () => {
expect(err).toBeNull();
});
});
/**
* GHSA-xfvx-j447-732c: `checkRestorePathsAllowed` constrained the top-level
* `source`/`manifestPath` request fields (GHSA-fw4c above), but never looked
* INSIDE the manifest itself. `manifest.database.backup_file` — handed
* straight to restoreService's candidate resolution and eventually
* interpolated into `sqlite3 .restore '<path>'` — was unchecked, so an
* absolute path there could point the restore at an arbitrary file even
* though `source`/`manifestPath` both passed containment.
*/
describe('restore path allowlist — manifest database.backup_file containment (GHSA-xfvx)', () => {
let tmpRoot;
beforeAll(async () => {
await setBackupSetting('backup_destination_path', '/backup');
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-xfvx-manifest-'));
// Additional allowed root via the documented escape hatch — keeps this
// describe block's fixtures out of the shared '/backup' root above.
process.env.RESTORE_ALLOWED_ROOTS = tmpRoot;
});
afterAll(() => {
delete process.env.RESTORE_ALLOWED_ROOTS;
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
const writeManifest = (name, databaseSection) => {
const manifestPath = path.join(tmpRoot, name);
fs.writeFileSync(manifestPath, JSON.stringify({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 0, manifest: [] },
database: databaseSection,
verification: { total_checksum: null, checksum_algorithm: null },
}));
return manifestPath;
};
it('rejects a manifest whose database.backup_file is an absolute path outside every configured root', async () => {
const manifestPath = writeManifest('evil-1.json', { backup_file: '/etc/passwd' });
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
expect(err).toMatch(/database\.backup_file must be inside a configured backup location/i);
});
it('accepts a manifest whose database.backup_file is an absolute path inside a configured root', async () => {
const dbFile = path.join(tmpRoot, 'database', 'picpeak-db-sqlite-1.sql.gz');
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
fs.writeFileSync(dbFile, 'not a real sqlite dump, just a fixture');
const manifestPath = writeManifest('legit-1.json', { backup_file: dbFile });
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
expect(err).toBeNull();
});
it('does not choke on a manifest whose database.backup_file is a legitimate relative path', async () => {
// Relative candidates are resolved against restoreService's own
// `backupPath` (which this route-level pre-check doesn't have — it only
// sees `source`/`manifestPath`), so this layer intentionally defers
// relative-path containment to restoreService.performDatabaseRestore
// and must not false-positive here.
const manifestPath = writeManifest('legit-2.json', { backup_file: 'database/picpeak-db-sqlite-1.sql.gz' });
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
expect(err).toBeNull();
});
it('rejects everything when no backup location is configured at all (fail closed, not fail open)', async () => {
// Simulate an install that never had backup_destination_path /
// backup_manifest_path seeded/configured, and isn't using the
// RESTORE_ALLOWED_ROOTS escape hatch either.
const savedRoots = process.env.RESTORE_ALLOWED_ROOTS;
delete process.env.RESTORE_ALLOWED_ROOTS;
await db('app_settings').whereIn('setting_key', ['backup_destination_path', 'backup_manifest_path']).del();
try {
const err = await checkRestorePathsAllowed({
source: '/backup/run-1', manifestPath: '/backup/run-1/manifest.json',
});
expect(err).toMatch(/no backup location is configured/i);
} finally {
process.env.RESTORE_ALLOWED_ROOTS = savedRoots;
await setBackupSetting('backup_destination_path', '/backup');
}
});
});
@@ -89,8 +89,7 @@ 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,
galleryAccess: require('../../src/services/galleryAccessService').grant({ id: eventId }, 'public') },
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
);
const view = (slug, photoId, token) => request(app)
@@ -115,7 +114,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.code).toBe('INVALID_GALLERY_GRANT');
expect(res.body.error).toMatch(/not valid for this photo/i);
});
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
@@ -124,7 +123,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.code).toBe('INVALID_GALLERY_GRANT');
expect(res.body.error).toMatch(/not valid for this gallery/i);
});
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', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] })),
validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok' })),
}));
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', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] });
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok' });
axios.post.mockResolvedValue({ status: 200, data: streamOf('') });
});
@@ -1,497 +0,0 @@
/**
* 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(() => {});
}
});
});
});
@@ -1,148 +0,0 @@
/**
* 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',
]);
});
});
@@ -1,173 +0,0 @@
/**
* GHSA-xfvx-j447-732c: the SQLite restore path let an attacker-influenced
* `manifest.database.backup_file` replace the live database.
*
* Two independent bugs, both fixed here:
*
* 1. Candidate resolution (restoreService.js's performDatabaseRestore,
* ~L1000) tried an absolute `dbBackupFile` and a
* `path.join(backupPath, dbBackupFile)` candidate with NO check that
* the resolved path actually stayed inside the configured backup
* root — a manifest could point `.restore` at any file on disk.
*
* 2. The resolved path was interpolated unescaped into a
* `sqlite3 .restore '<path>'` dot-command string. sqlite3's CLI
* parses that string itself (not the shell), so a single quote in
* the path breaks out of the quoted argument regardless of
* spawn()'s `shell: false` argv separation.
*
* These tests pin the fix directly against the exported helpers
* (`resolveContainedDbBackupCandidates`, `assertSafeSqlitePath`,
* `isContainedInRoots`, `getConfiguredBackupRoots`) — the exact functions
* `performDatabaseRestore` calls before ever running `sqlite3 .restore` —
* rather than driving the full restore (which does a real `db.destroy()` +
* live-file swap against the shared app db and isn't worth the added
* fragility for what's fundamentally a path-validation contract).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-restoresvc-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restoresvc-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('restoreService — sqlite restore path safety (GHSA-xfvx)', () => {
let db; let cleanup; let _internal;
let backupPath;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// The restore run's resolved local backup root — analogous to
// `localBackupPath` in restoreService.restore(). Real directory with a
// real database/ subfolder, matching what a genuine backup run leaves
// on disk.
backupPath = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-xfvx-backuproot-'));
fs.mkdirSync(path.join(backupPath, 'database'), { recursive: true });
({ _internal } = require('../../src/services/restoreService'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('assertSafeSqlitePath — the sqlite3 dot-command injection gate', () => {
it.each([
['/backup/database/picpeak-db-sqlite-1.sql'],
[`${backupPath || '/backup'}/database/picpeak-db-sqlite-2024-01-01.sql.gz`],
])('accepts a normal backup path: %s', (p) => {
expect(() => _internal.assertSafeSqlitePath(p)).not.toThrow();
});
it.each([
['/backup/database/x\'; DROP TABLE admin_users; --.sql'],
['/backup/database/x\' .restore \'/etc/passwd'],
['/backup/database/x\n.shell rm -rf /'],
['/backup/database/has space.sql'],
['/backup/database/semi;colon.sql'],
[null],
[undefined],
[42],
])('rejects an unsafe/non-string path: %j', (p) => {
expect(() => _internal.assertSafeSqlitePath(p)).toThrow(/unsafe path/i);
});
});
describe('isContainedInRoots', () => {
it('accepts a path inside a root', () => {
expect(_internal.isContainedInRoots('/backup/database/x.sql', ['/backup'])).toBe(true);
});
it('accepts a root path equal to the root itself', () => {
expect(_internal.isContainedInRoots('/backup', ['/backup'])).toBe(true);
});
it('rejects a path outside every root', () => {
expect(_internal.isContainedInRoots('/etc/passwd', ['/backup'])).toBe(false);
});
it('rejects a sibling directory that merely shares a prefix', () => {
// '/backup-evil' starts with the string '/backup' but is NOT inside it.
expect(_internal.isContainedInRoots('/backup-evil/x.sql', ['/backup'])).toBe(false);
});
it('rejects a `..`-traversal path that resolves outside the root', () => {
expect(_internal.isContainedInRoots('/backup/../etc/passwd', ['/backup'])).toBe(false);
});
});
describe('getConfiguredBackupRoots', () => {
afterEach(async () => {
delete process.env.RESTORE_ALLOWED_ROOTS;
await db('app_settings').whereIn('setting_key', ['backup_destination_path', 'backup_manifest_path']).del();
});
it('always includes the trusted root even with nothing else configured', async () => {
const roots = await _internal.getConfiguredBackupRoots('/some/trusted/backup-path');
expect(roots).toContain(path.resolve('/some/trusted/backup-path'));
});
it('adds configured backup_destination_path / backup_manifest_path and RESTORE_ALLOWED_ROOTS', async () => {
await db('app_settings').insert([
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify('/backup/dest'), setting_type: 'backup' },
{ setting_key: 'backup_manifest_path', setting_value: JSON.stringify('/backup/manifests'), setting_type: 'backup' },
]);
process.env.RESTORE_ALLOWED_ROOTS = '/extra/root';
const roots = await _internal.getConfiguredBackupRoots('/trusted');
expect(roots).toEqual(expect.arrayContaining([
path.resolve('/trusted'),
path.resolve('/backup/dest'),
path.resolve('/backup/manifests'),
path.resolve('/extra/root'),
]));
});
});
describe('resolveContainedDbBackupCandidates — the manifest.database.backup_file gate', () => {
it('rejects an absolute backup_file outside every configured root, but still offers the safe legacy basename candidate', async () => {
const candidates = await _internal.resolveContainedDbBackupCandidates(
backupPath, '/etc/passwd', () => {}
);
// The raw absolute escape must NOT be present.
expect(candidates).not.toContain('/etc/passwd');
// Candidate (3), the basename-only legacy reconstruct, is inherently
// safe (can't escape backupPath) and stays available as a fallback.
expect(candidates).toContain(path.join(backupPath, 'database', 'passwd'));
});
it('rejects a `..`-traversal relative backup_file, keeping only the contained legacy candidate', async () => {
const candidates = await _internal.resolveContainedDbBackupCandidates(
backupPath, '../../../../etc/passwd', () => {}
);
const escaped = candidates.some((c) => !_internal.isContainedInRoots(c, [path.resolve(backupPath)]));
expect(escaped).toBe(false);
expect(candidates).toContain(path.join(backupPath, 'database', 'passwd'));
});
it('accepts a legitimate relative backup_file recorded by a real backup run', async () => {
const candidates = await _internal.resolveContainedDbBackupCandidates(
backupPath, 'database/picpeak-db-sqlite-2024-01-01.sql.gz', () => {}
);
expect(candidates).toContain(path.join(backupPath, 'database', 'picpeak-db-sqlite-2024-01-01.sql.gz'));
// Every returned candidate must actually be safe to use.
for (const c of candidates) {
expect(_internal.isContainedInRoots(c, [path.resolve(backupPath)])).toBe(true);
}
});
it('accepts a legitimate absolute backup_file that IS inside backupPath (the real dumper shape)', async () => {
const absFile = path.join(backupPath, 'database', 'picpeak-db-sqlite-2024-02-02.sql.gz');
const candidates = await _internal.resolveContainedDbBackupCandidates(
backupPath, absFile, () => {}
);
expect(candidates).toContain(absFile);
});
});
});
@@ -1,25 +0,0 @@
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();
});
@@ -1,15 +0,0 @@
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,7 +42,6 @@ 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,7 +32,6 @@ 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,7 +26,6 @@ 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,7 +25,6 @@ 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');
@@ -1,62 +0,0 @@
/**
* 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();
});
});
@@ -1,167 +0,0 @@
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/media.js', 'gallery/downloads.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
const routes = ['gallery.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/);
@@ -1,79 +0,0 @@
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 });
}
});
@@ -1,28 +0,0 @@
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');
});
@@ -1,13 +0,0 @@
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, merge: async () => undefined }) };
return { onConflict: () => ({ ignore: async () => undefined }) };
},
});
return { db: dbFn };
-23
View File
@@ -1,18 +1,3 @@
// 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';
@@ -23,11 +8,3 @@ 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();
});
@@ -1,16 +0,0 @@
/** 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'));
}
};
@@ -1,26 +0,0 @@
/** 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();
});
}
};
@@ -1,31 +0,0 @@
// 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'));
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.131.3-beta.0",
"version": "3.130.0-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
+48 -59
View File
@@ -218,7 +218,7 @@ app.use((req, res, next) => {
});
// CORS configuration (apply only to API routes)
const { isAllowedOrigin } = require('./src/utils/requestOrigin');
const { isAllowedOrigin, multipartOriginAllowed } = require('./src/utils/requestOrigin');
const corsOptions = {
origin: function (origin, callback) {
@@ -528,10 +528,43 @@ app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' }));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
// Validate the origin independently of body length/content type.
app.use('/api', require('./src/middleware/csrf'));
// 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();
});
app.use('/api', require('./src/middleware/apiRequestLogger'));
// 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);
// Maintenance mode middleware - add after body parsing but before routes
app.use(maintenanceMiddleware);
@@ -1065,30 +1098,6 @@ 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 {
@@ -1113,8 +1122,14 @@ async function startServer() {
const { initializeCleanupJob } = require('./src/utils/authSecurity');
initializeCleanupJob();
require('./src/utils/cleanupTempUploads').startTempUploadCleanup();
// 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');
// Start file watcher
startFileWatcher();
// External-media folder watcher (issue 1187): imports new files into
@@ -1134,10 +1149,6 @@ 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();
@@ -1295,7 +1306,7 @@ async function startServer() {
// lazy means they don't pay for a module graph they never use.
require('./src/services/faceQueue').start();
httpServer = app.listen(PORT, () => {
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'}`);
@@ -1314,32 +1325,10 @@ async function startServer() {
});
} catch (error) {
logger.error('Failed to start server:', error);
await stopServer();
process.exitCode = 1;
process.exit(1);
}
}
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;
startServer();
module.exports = app; // For testing
@@ -26,8 +26,6 @@ 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(),
}));
@@ -85,12 +83,8 @@ function mockEventAndAssignment({ event, assignment }) {
assignChain.where = jest.fn().mockReturnValue(assignChain);
assignChain.first = jest.fn().mockResolvedValue(assignment);
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);
});
db.mockImplementationOnce(() => eventsChain)
.mockImplementationOnce(() => assignChain);
return { eventsChain, assignChain };
}
@@ -107,7 +101,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', iat: Math.floor(Date.now() / 1000),
type: 'gallery',
eventId: 42,
via: 'customer',
customerId: 7,
@@ -138,7 +132,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', iat: Math.floor(Date.now() / 1000),
type: 'gallery',
eventId: 42,
via: 'customer',
customerId: 7,
@@ -168,7 +162,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
// and start 403'ing per-event-password sessions.
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery', iat: Math.floor(Date.now() / 1000),
type: 'gallery',
eventId: 42,
customerId: 7,
// intentionally no `via` claim
@@ -200,7 +194,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', iat: Math.floor(Date.now() / 1000),
type: 'gallery',
eventId: 42,
// No via, no customerId — this is the legacy per-event-password
// flow where every guest mints their own JWT after entering the
+1 -1
View File
@@ -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').nullable(); // NULL retains tokens without a known expiry
table.timestamp('expires_at').notNullable(); // When token would have expired
table.string('reason', 100); // password_change, logout, compromised, etc.
table.text('metadata'); // Additional JSON data
@@ -1,11 +0,0 @@
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();
};
+100 -9
View File
@@ -1,5 +1,9 @@
const jwt = require('jsonwebtoken');
const sessionAccess = require('../services/sessionAccessService');
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 logger = require('../utils/logger');
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
@@ -28,10 +32,100 @@ async function adminAuth(req, res, next) {
return res.status(401).json({ error: 'Invalid token' });
}
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 });
// 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'
});
}
}
// Add user info to request (enhanced with role)
@@ -52,10 +146,7 @@ async function adminAuth(req, res, next) {
next();
} catch (error) {
logger.error('Auth middleware error:', error);
res.status(error.statusCode || 401).json({
error: error.isOperational ? error.message : 'Authentication failed',
...(error.isOperational && { code: error.code }),
});
res.status(401).json({ error: 'Authentication failed' });
}
}
-15
View File
@@ -1,15 +0,0 @@
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();
};
+72 -12
View File
@@ -1,4 +1,3 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Customer Authentication Middleware
*
@@ -11,7 +10,10 @@ const { requestLogPath } = require('../utils/requestLogPath');
*/
const jwt = require('jsonwebtoken');
const sessionAccess = require('../services/sessionAccessService');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const logger = require('../utils/logger');
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
@@ -23,7 +25,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: requestLogPath(req.originalUrl),
url: req.originalUrl,
hasCookieHeader: !!req.headers?.cookie,
cookieKeys: Object.keys(req.cookies || {}),
});
@@ -40,7 +42,7 @@ async function customerAuth(req, res, next) {
decoded = verified.payload;
} catch (err) {
logger.warn('[customerAuth] jwt verification failed', {
url: requestLogPath(req.originalUrl),
url: req.originalUrl,
errorName: err.name,
errorMessage: err.message,
});
@@ -50,10 +52,71 @@ async function customerAuth(req, res, next) {
return res.status(401).json({ error: 'Invalid token', code: 'JWT_INVALID' });
}
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 });
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',
});
}
}
req.customer = {
@@ -68,10 +131,7 @@ async function customerAuth(req, res, next) {
next();
} catch (error) {
logger.error('Customer auth middleware error:', error);
res.status(error.statusCode || 401).json({
error: error.isOperational ? error.message : 'Authentication failed',
...(error.isOperational && { code: error.code }),
});
res.status(401).json({ error: 'Authentication failed' });
}
}
+2 -3
View File
@@ -1,4 +1,3 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Global error handler middleware.
* Catches all errors and returns standardized responses.
@@ -120,7 +119,7 @@ const errorHandler = (err, req, res, next) => {
// Log the error
const logContext = {
url: requestLogPath(req.originalUrl),
url: req.originalUrl,
method: req.method,
ip: req.ip,
statusCode,
@@ -162,7 +161,7 @@ const errorHandler = (err, req, res, next) => {
*/
const notFoundHandler = (req, res, next) => {
const { NotFoundError } = require('../utils/errors');
next(new NotFoundError('Route', requestLogPath(req.originalUrl)));
next(new NotFoundError('Route', req.originalUrl));
};
/**
+1 -5
View File
@@ -1,4 +1,3 @@
const cleanupTimers = new Set();
const crypto = require('crypto');
const { db } = require('../database/db');
const logger = require('../utils/logger');
@@ -212,7 +211,7 @@ function strictRateLimit(options = {}) {
const store = new Map();
// Clean up old entries periodically
const cleanupTimer = setInterval(() => {
setInterval(() => {
const now = Date.now();
for (const [key, data] of store.entries()) {
if (data.resetTime < now) {
@@ -220,8 +219,6 @@ function strictRateLimit(options = {}) {
}
}
}, windowMs);
cleanupTimer.unref();
cleanupTimers.add(cleanupTimer);
return (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress;
@@ -258,7 +255,6 @@ function strictRateLimit(options = {}) {
}
module.exports = {
dispose() { cleanupTimers.forEach(clearInterval); cleanupTimers.clear(); },
feedbackRateLimit,
strictRateLimit,
generateGuestIdentifier,
+233 -70
View File
@@ -1,104 +1,267 @@
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 access = require('../services/galleryAccessService');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
// Cookie first: a coexisting gallery Bearer must not shadow an admin preview.
/**
* 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.
*/
function decodeAdminPreview(req) {
if (req.query?.admin_preview !== '1') return null;
const candidates = [req.cookies?.admin_token];
// 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 header = req.headers?.authorization;
if (header?.startsWith('Bearer ')) candidates.push(header.slice(7));
for (const token of candidates.filter(Boolean)) {
if (header && header.startsWith('Bearer ')) candidates.push(header.slice(7));
for (const token of candidates) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth', algorithms: ['HS256'],
});
const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
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;
}
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;
/**
* 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;
const decoded = decodeAdminPreview(req);
if (!decoded) return false;
try {
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;
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 });
return false;
}
req.isAdminPreview = true;
return true;
}
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;
}
}
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
try {
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') {
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') {
return res.status(403).json({ error: 'Invalid token type for gallery access' });
}
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();
// 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();
} catch (error) {
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 }),
});
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
res.status(401).json({ error: 'Invalid token' });
}
}
+1 -7
View File
@@ -1,11 +1,6 @@
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.
@@ -28,7 +23,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 (!canAccessEvent(req.admin, event)) {
if (event.created_by && event.created_by !== req.admin.id) {
return res.status(403).json({ error: 'Access denied' });
}
next();
@@ -170,7 +165,6 @@ function requireProjectOwnership(req, res, next) {
}
module.exports = {
canAccessEvent,
requireEventOwnership,
filterOwnedEventIds,
scopeEventsQuery,
+2 -3
View File
@@ -1,4 +1,3 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Permission Checking Middleware for RBAC
* Provides role-based access control with caching for performance
@@ -144,7 +143,7 @@ function requirePermission(permissions, options = { requireAll: false }) {
userId: req.admin.id,
username: req.admin.username,
requiredPermissions: permArray,
path: requestLogPath(req.originalUrl || req.path),
path: req.path,
method: req.method
});
throw new ForbiddenError('Insufficient permissions');
@@ -181,7 +180,7 @@ function requireSuperAdmin() {
logger.warn('Super admin access denied', {
userId: req.admin.id,
username: req.admin.username,
path: requestLogPath(req.originalUrl || req.path),
path: req.path,
method: req.method
});
throw new ForbiddenError('Super Admin access required');
@@ -1,4 +1,3 @@
const { requestLogPath } = require('../utils/requestLogPath');
const { db } = require('../database/db');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
@@ -11,24 +10,12 @@ 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);
@@ -69,7 +56,7 @@ class SecureImageMiddleware {
error: error.message,
stack: error.stack,
ip: req.ip,
path: requestLogPath(req.originalUrl || req.path)
path: req.path
});
res.status(500).json({
@@ -341,7 +328,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: requestLogPath(req.originalUrl || req.path),
request_path: req.path,
request_method: req.method,
details: JSON.stringify(details),
timestamp: new Date().toISOString()
@@ -422,4 +409,9 @@ class SecureImageMiddleware {
// Create singleton instance
const secureImageMiddleware = new SecureImageMiddleware();
// Setup cleanup interval
setInterval(() => {
secureImageMiddleware.cleanup();
}, 300000); // Every 5 minutes
module.exports = secureImageMiddleware;
+1 -2
View File
@@ -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.
const cleanupTimer = setInterval(() => {
setInterval(() => {
const now = Date.now();
for (const [token, lastActivity] of sessions.entries()) {
if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) {
@@ -208,7 +208,6 @@ function getActiveSessions() {
}
module.exports = {
dispose: () => { clearInterval(cleanupTimer); sessions.clear(); cachedTimeout = null; cacheExpiry = 0; },
sessionTimeoutMiddleware,
isSessionExpired,
endSession,
+3 -4
View File
@@ -182,17 +182,16 @@ 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');
if (!await revokeToken(token, 'logout')) {
throw new Error('Token revocation failed');
}
await revokeToken(token, 'logout');
}
// Clear the auth cookie so the browser stops sending the (now revoked) JWT.
clearAdminAuthCookie(res);
// Log activity
await logActivity('admin_logout',
+2 -23
View File
@@ -2,7 +2,7 @@ const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { databaseBackupService, isUnderPubliclyServableRoot } = require('../services/databaseBackup');
const { databaseBackupService } = require('../services/databaseBackup');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
@@ -60,28 +60,7 @@ 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)) {
+578 -14
View File
@@ -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,13 +25,14 @@ 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 { KEYBIND_MODES } = require('../../services/feedbackDefaults');
const { validateHeroImageAnchor, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade } = require('./helpers');
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');
/**
* `events.slug` is UNIQUE, and both routes that mint one do a read-then-insert
@@ -173,6 +174,7 @@ 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) => {
@@ -238,6 +240,7 @@ 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(),
@@ -295,13 +298,571 @@ module.exports = (router) => {
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const created = await require('../../services/eventCreationService').createEvent(req.body, {
actor: req.admin,
frontendUrl: await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL }),
// 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()
});
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);
@@ -864,7 +1425,6 @@ 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,
@@ -881,6 +1441,7 @@ 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,
@@ -1037,6 +1598,7 @@ 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(),
@@ -1467,6 +2029,8 @@ 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
@@ -1668,12 +2232,12 @@ module.exports = (router) => {
return res.status(404).json({ error: 'Event not found' });
}
const newStatus = !parseBooleanInput(event.is_active, false);
const newStatus = !event.is_active;
await db('events')
.where('id', id)
.update({
is_active: formatBoolean(newStatus),
updated_at: new Date().toISOString()
is_active: newStatus,
updated_at: new Date()
});
// Log activity
+421 -2
View File
@@ -1,9 +1,392 @@
// 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 settings = require('../../services/eventSettings');
const { parseStringInput } = require('../../utils/parsers');
// 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) {
@@ -295,4 +678,40 @@ async function deleteEventCascade(eventId, adminContext) {
return { id: event.id, name: event.event_name };
}
module.exports = { ...settings, deleteEventCascade };
// ---------------------------------------------------------------------------
// 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,
};
+6 -54
View File
@@ -810,72 +810,24 @@ async function checkRestorePathsAllowed({ source, manifestPath }) {
if (extra.trim()) roots.push(extra.trim());
}
if (roots.length === 0) {
// GHSA-xfvx: nothing configured to compare against used to mean "a
// restore can't be scoped, so don't pretend to enforce" — returning
// null (allow). That's fail-OPEN: on a fresh install (or one where an
// operator never set backup_destination_path/backup_manifest_path) any
// authenticated `backup.restore` caller could point source/manifestPath
// — and, via the manifest, database.backup_file — at literally any path
// on disk. Require configuration instead of silently allowing
// everything; the normal restore wizard already needs one of these
// settings populated to discover backups in the first place.
logger.warn('Refusing restore: no backup location configured to scope it to', { candidates });
return 'No backup location is configured (backup_destination_path / backup_manifest_path). ' +
'Configure one before restoring.';
// Nothing configured to compare against — a restore can't be scoped, so
// don't pretend to enforce. Discovery would find nothing either.
return null;
}
const resolvedRoots = roots.map((r) => path.resolve(r));
const isInsideRoots = (candidate) => {
for (const candidate of candidates) {
const resolved = path.resolve(candidate);
return resolvedRoots.some(
const inside = resolvedRoots.some(
(root) => resolved === root || resolved.startsWith(root + path.sep)
);
};
for (const candidate of candidates) {
if (!isInsideRoots(candidate)) {
if (!inside) {
logger.warn('Refusing restore path outside the configured backup roots', {
candidate, roots,
});
return 'Backup source and manifest path must be inside a configured backup location';
}
}
// GHSA-xfvx: source/manifestPath containment alone isn't enough — the
// manifest FILE (which just passed containment above) can itself carry a
// `database.backup_file` field that restoreService's candidate resolution
// used to hand straight to `sqlite3 .restore` with no containment check at
// all. Peek at the manifest here (it's already proven to live inside an
// allowed root) and reject an ABSOLUTE backup_file that escapes the same
// roots — the case that's unambiguous to check without re-deriving
// restoreService's own `backupPath` resolution for the relative-path
// candidates. This is deliberately defense in depth, not the only gate:
// restoreService.performDatabaseRestore independently re-derives and
// enforces containment (including relative/`..` candidates) against
// `backupPath` right before ever using the resolved path, and remains the
// authoritative check for S3-sourced manifests (downloaded after this
// pre-check runs).
if (manifestPath && !isS3(manifestPath) && !isTypeToken(manifestPath)) {
try {
const raw = await fs.readFile(manifestPath, 'utf8');
const trimmed = raw.trimStart();
const parsed = (trimmed.startsWith('{') || trimmed.startsWith('['))
? JSON.parse(raw)
: null; // non-JSON (e.g. YAML) manifests are re-checked inside restoreService
const dbBackupFile = parsed?.database?.backup_file;
if (typeof dbBackupFile === 'string' && path.isAbsolute(dbBackupFile) && !isInsideRoots(dbBackupFile)) {
logger.warn('Refusing restore: manifest database.backup_file escapes configured backup roots', {
manifestPath, backupFile: dbBackupFile,
});
return 'Manifest database.backup_file must be inside a configured backup location';
}
} catch (_) {
// Unreadable/corrupt/non-JSON manifest: let the normal restore flow
// surface the real error (loadAndValidateManifest) instead of failing
// this pre-check for an unrelated reason.
}
}
return null;
}
+2 -5
View File
@@ -204,9 +204,7 @@ 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.
// `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 });
const newThumbnailPath = await ensureThumbnail({ ...photo, thumbnail_path: null });
if (newThumbnailPath) {
// Drop the superseded canonical rendition when the key MOVED.
@@ -295,8 +293,7 @@ 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);
// `force`: never adopt a lazy generation already in flight (#1020).
const newPreviewPath = await ensurePreviewImage({ ...photo, preview_path: null }, { force: true });
const newPreviewPath = await ensurePreviewImage({ ...photo, preview_path: null });
if (newPreviewPath) {
successCount++;
} else {
-7
View File
@@ -73,13 +73,6 @@ 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) =>
+102 -40
View File
@@ -1,8 +1,6 @@
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');
@@ -356,9 +354,7 @@ router.post('/logout', async (req, res) => {
if (token) {
// Revoke the token so it can't be reused, then end the session
if (!await revokeToken(token, 'user_logout')) {
throw new Error('Token revocation failed');
}
await revokeToken(token, 'user_logout');
endSession(token);
try {
@@ -402,8 +398,6 @@ 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');
}
});
@@ -426,7 +420,7 @@ router.post('/gallery/verify', [
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!isGalleryAvailable(event)) {
if (!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);
@@ -502,9 +496,6 @@ 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, {
@@ -554,7 +545,7 @@ router.post('/gallery/:slug/client-login', [
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!isGalleryAvailable(event) || !event.client_access_enabled || !event.client_password_hash) {
if (!event || !event.client_access_enabled || !event.client_password_hash) {
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid credentials' });
}
@@ -579,9 +570,6 @@ 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()
@@ -643,14 +631,14 @@ router.post('/gallery/share-login', [
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!isGalleryAvailable(event)) {
if (!event) {
const resolved = await resolveShareIdentifier(slug);
if (resolved?.event) {
event = resolved.event;
}
}
if (!isGalleryAvailable(event)) {
if (!event) {
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
return res.status(404).json({ error: 'Gallery not found' });
}
@@ -678,9 +666,6 @@ 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, {
@@ -718,14 +703,11 @@ router.post('/gallery/logout', async (req, res) => {
const { slug } = req.body || {};
const token = getGalleryTokenFromRequest(req, slug);
if (token) {
if (!await revokeToken(token, 'gallery_logout')) {
throw new Error('Token revocation failed');
}
await revokeToken(token, 'gallery_logout');
}
clearGalleryAuthCookies(res, slug);
res.json({ message: 'Logged out successfully' });
} catch (error) {
clearGalleryAuthCookies(res, req.body?.slug);
errorResponse(res, error, 500, 'Logout failed');
}
});
@@ -761,26 +743,106 @@ router.get('/session', async (req, res) => {
issuer: 'picpeak-auth'
});
const sessions = require('../services/sessionAccessService');
// 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).
let adminUser = null;
if (decoded.type === 'admin') {
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' });
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
};
}
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') {
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' });
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.
}
}
// Calculate remaining time
+3 -9
View File
@@ -1,4 +1,3 @@
const { isGalleryAvailable, isGalleryExpired } = require('../utils/galleryLifecycle');
/**
* Customer dashboard routes
*
@@ -15,7 +14,6 @@ const { isGalleryAvailable, isGalleryExpired } = require('../utils/galleryLifecy
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');
@@ -167,12 +165,10 @@ router.get('/events/:slug/access-token', [
if (event.is_archived) {
return res.status(410).json({ error: 'This gallery has been archived' });
}
if (isGalleryExpired(event)) {
if (event.expires_at && new Date(event.expires_at) < new Date()) {
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
@@ -193,12 +189,10 @@ 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(),
// Rechecked on each gallery/media request, including account status.
// Optional bookkeeping claim — surfaces the originating customer in
// logs when the token is later used. Doesn't affect authorization.
via: 'customer',
customerId: req.customer.id,
}, process.env.JWT_SECRET, {
+1 -3
View File
@@ -181,9 +181,7 @@ router.post('/logout', async (req, res) => {
try {
const token = getCustomerTokenFromRequest(req);
if (token) {
if (!await revokeToken(token, 'user_logout')) {
throw new Error('Token revocation failed');
}
await revokeToken(token, 'user_logout');
}
clearCustomerAuthCookie(res);
res.json({ message: 'Logged out successfully' });
File diff suppressed because it is too large Load Diff
-945
View File
@@ -1,945 +0,0 @@
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;
-639
View File
@@ -1,639 +0,0 @@
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;
-249
View File
@@ -1,249 +0,0 @@
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;
-192
View File
@@ -1,192 +0,0 @@
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;
-293
View File
@@ -1,293 +0,0 @@
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;
-44
View File
@@ -1,44 +0,0 @@
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;
-41
View File
@@ -1,41 +0,0 @@
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;
-211
View File
@@ -1,211 +0,0 @@
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
},
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;
+13 -19
View File
@@ -6,7 +6,6 @@ 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');
@@ -20,13 +19,16 @@ const router = express.Router();
/**
* Generate a signed URL token for image access
*/
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false, galleryAccess) {
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false) {
const secret = process.env.JWT_SECRET;
const expires = Date.now() + (expiresIn * 1000);
// 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}`;
// 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}`;
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
return `${Buffer.from(data).toString('base64')}.${signature}`;
}
@@ -39,7 +41,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, grant] = decoded.split(':');
const [photoId, expires, bypassFlag, clientFlag] = decoded.split(':');
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
@@ -48,7 +50,7 @@ function verifyImageToken(token) {
}
// Check expiration
if (!Number.isFinite(Number(expires)) || Date.now() >= Number(expires) || !grant) {
if (Date.now() > parseInt(expires)) {
return null;
}
@@ -57,7 +59,6 @@ function verifyImageToken(token) {
expires: parseInt(expires),
revealBypass: bypassFlag === '1',
clientBypass: clientFlag === '1',
galleryAccess: JSON.parse(Buffer.from(grant, 'base64url').toString()),
};
} catch (error) {
return null;
@@ -169,7 +170,6 @@ 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,7 +207,6 @@ 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,
@@ -223,7 +222,6 @@ 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' });
}
@@ -264,7 +262,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), req.galleryAccess);
const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel));
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
res.json({
@@ -273,7 +271,6 @@ 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' });
}
@@ -302,8 +299,6 @@ 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) {
@@ -343,7 +338,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Length': imageBuffer.length,
'Cache-Control': 'private, no-store',
'Cache-Control': 'private, max-age=3600',
'X-Content-Type-Options': 'nosniff'
});
@@ -351,10 +346,9 @@ 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;
-12
View File
@@ -4,7 +4,6 @@ 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');
@@ -59,7 +58,6 @@ 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,
@@ -98,7 +96,6 @@ 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,
@@ -154,10 +151,6 @@ 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
@@ -255,7 +248,6 @@ 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,
@@ -300,8 +292,6 @@ 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).
@@ -396,7 +386,6 @@ 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
@@ -425,7 +414,6 @@ 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,74 +1,283 @@
/** Persisted contracts, not a mock tied to the number/order of Knex calls. */
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../../../../__tests__/integration/helpers/crmDb');
/**
* 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.
*/
const request = require('supertest');
const express = require('express');
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');
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),
};
});
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);
}
// 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();
});
});
+259 -7
View File
@@ -30,13 +30,15 @@ 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');
@@ -193,12 +195,262 @@ router.post(
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const created = await require('../../services/eventCreationService').createEvent(req.body, {
actor: req.admin, source: 'v1',
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
});
res.status(201).json({ id: created.id, slug: created.slug, share_url: created.share_link, share_token: created.share_token });
// 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 });
} 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,10 +8,6 @@ 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;
@@ -195,213 +191,6 @@ 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 = [
@@ -1,125 +0,0 @@
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');
});
});
+7 -19
View File
@@ -29,7 +29,6 @@
const os = require('os');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { createInterruptibleSleep } = require('../utils/interruptibleSleep');
const { processPhoto } = require('./photoProcessor');
const POLL_INTERVAL_MS = parseInt(process.env.UPLOAD_PROCESSOR_POLL_MS || '1000', 10);
@@ -70,9 +69,7 @@ let running = false;
let workerHandles = [];
let janitorHandle = null;
let waits = null;
let stopping = null;
const sleep = ms => waits.sleep(ms);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function isPostgres() {
const c = db.client.config.client;
@@ -178,13 +175,12 @@ async function janitorLoop() {
}
function start() {
if (running || stopping) return;
if (running) return;
if (process.env.UPLOAD_PROCESSOR_DISABLED === 'true') {
logger.info('backgroundProcessor: disabled via UPLOAD_PROCESSOR_DISABLED');
return;
}
waits = createInterruptibleSleep();
running = true;
workerHandles = [];
for (let i = 0; i < CONCURRENCY; i++) {
@@ -203,20 +199,12 @@ function start() {
);
}
function stop() {
if (stopping) return stopping;
if (!running) return Promise.resolve();
async function stop() {
if (!running) return;
running = false;
// Interrupt idle/backoff waits only. Claims, processing and janitor work
// already in flight still drain before the database can be closed.
waits.cancel();
stopping = Promise.all([...workerHandles, janitorHandle].filter(Boolean)).finally(() => {
workerHandles = [];
janitorHandle = null;
waits = null;
stopping = null;
});
return stopping;
await Promise.all([...workerHandles, janitorHandle].filter(Boolean));
workerHandles = [];
janitorHandle = null;
}
module.exports = { start, stop, claimNextPhoto };
+1 -1
View File
@@ -1041,7 +1041,7 @@ function buildManifestFiles(backedUpFiles, allFiles) {
async function saveManifestToLocal(manifest, manifestFileName, config) {
const manifestDir = config.backup_manifest_path
|| path.join(config.backup_destination_path || path.join(getStoragePath(), 'backups'), 'manifests');
|| path.join(config.backup_destination_path || '/backup', 'manifests');
await fs.mkdir(manifestDir, { recursive: true });
const manifestPath = path.join(manifestDir, manifestFileName);
await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json');
+6 -5
View File
@@ -338,13 +338,14 @@ async function cleanupExpiredUploads() {
return expiredIds.length;
}
const cleanupTask = require('./scheduledTask').scheduledTask(cleanupExpiredUploads, {
interval: 60 * 60 * 1000
});
cleanupTask.start();
// Run cleanup every hour. unref so this module-level housekeeping timer
// never holds the process open on its own — in production the HTTP
// listener keeps the loop alive, and in Jest this exact handle kept the
// runner from exiting for every suite that requires adminPhotos (#908;
// it is why adminPhotos.reference sits on the CI ignore list).
setInterval(cleanupExpiredUploads, 60 * 60 * 1000).unref();
module.exports = {
stop: () => cleanupTask.stop(),
initializeUpload,
uploadChunk,
completeUpload,
+13 -116
View File
@@ -4,7 +4,7 @@ const crypto = require('crypto');
const { spawnAsync, spawnToFile } = require('../utils/safeExec');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { createReadStream, createWriteStream, realpathSync } = require('fs');
const { createReadStream, createWriteStream } = require('fs');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
@@ -28,76 +28,6 @@ const packageJson = require('../../package.json');
// createSQLiteBackup below.
const FACE_TABLES = ['photo_faces', 'event_people', 'event_people_merge_dismissals'];
function getStoragePath() {
return process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
}
// Public, unauthenticated static mounts (server.js) that must never become a
// backup destination — a dump landing there is downloadable by anyone who
// learns or guesses the filename, GHSA-jw8m-43r2-jqrm's exact class. Before
// #1365, `database_backup_destination_path` was silently ignored (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 that exact
// exfiltration path unless it's rejected here too.
function getPubliclyServableRoots() {
const storage = getStoragePath();
return [
path.join(storage, 'uploads', 'logos'),
path.join(storage, 'uploads', 'favicons'),
path.join(storage, 'fonts'),
// Bundled fallback fonts (server.js mounts both at /fonts, storage wins
// on overlap but express.static falls through to this one on a miss).
// COPY --chown=nodejs:nodejs in the Dockerfile makes this nodejs-owned
// and therefore writable at runtime, not just a read-only image layer.
path.resolve(__dirname, '../../assets/fonts'),
// The all-in-one image's built frontend bundle (Dockerfile.aio ships it
// nodejs-owned) — server.js serves it unauthenticated as the SPA itself.
process.env.FRONTEND_DIR || path.resolve(__dirname, '../../../frontend/dist')
];
}
// Resolves symlinks in whatever prefix of candidatePath currently exists,
// then re-appends any not-yet-created remainder literally. A plain
// fs.realpathSync would throw ENOENT for the common case where the backup
// destination doesn't exist yet; a plain path.resolve() would miss the
// all-in-one image's `/app/storage -> /data/storage` symlink (Dockerfile.aio),
// which lets `/app/storage/uploads/logos` alias the real public logos
// directory under a name that never lexically matches it.
function resolveRealish(candidatePath) {
let current = path.resolve(candidatePath);
const remainder = [];
for (;;) {
try {
const real = realpathSync(current);
return remainder.length ? path.join(real, ...remainder) : real;
} catch (error) {
if (error.code !== 'ENOENT') {
return path.resolve(candidatePath);
}
const parent = path.dirname(current);
if (parent === current) {
return path.resolve(candidatePath);
}
remainder.unshift(path.basename(current));
current = parent;
}
}
}
function isUnderPubliclyServableRoot(candidatePath) {
// Lowercased comparison: on a case-insensitive-but-preserving filesystem
// (default macOS APFS, NTFS, and Docker Desktop's bind-mount passthrough
// of either) `STORAGE_PATH/UPLOADS/logos` and `.../uploads/logos` name the
// same directory on disk even though path.resolve() never folds case.
const resolved = resolveRealish(candidatePath).toLowerCase();
return getPubliclyServableRoots().some((root) => {
const resolvedRoot = resolveRealish(root).toLowerCase();
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep);
});
}
/**
* Database Backup Service
* Supports both SQLite and PostgreSQL with proper escaping,
@@ -445,33 +375,15 @@ class DatabaseBackupService {
let backupRun = null;
try {
// Get configuration. getBackupConfig() returns the raw
// database_backup_*-prefixed setting keys, not the unprefixed
// names used internally below — map them explicitly rather than
// spreading `config` straight into the destructure, which silently
// matched nothing and always fell through to the hardcoded
// defaults (notably `/backup/database`, regardless of what was
// configured).
// Get configuration
const config = await this.getBackupConfig();
const {
destinationPath = '/backup/database',
compress = true,
validateIntegrity = true,
includeChecksums = true
} = {
destinationPath: config.database_backup_destination_path,
compress: config.database_backup_compress,
validateIntegrity: config.database_backup_validate_integrity,
includeChecksums: config.database_backup_include_checksums,
...options
};
} = { ...config, ...options };
if (isUnderPubliclyServableRoot(destinationPath)) {
throw new Error(
`Refusing to write a database backup to a publicly served directory: ${destinationPath}`
);
}
// Create backup directory
await fs.mkdir(destinationPath, { recursive: true });
@@ -590,7 +502,7 @@ class DatabaseBackupService {
logger.info(`Database backup completed: ${finalFile} (${(finalStats.size / 1024 / 1024).toFixed(2)} MB) in ${durationSeconds}s`);
// Send success notification if configured
if (config.database_backup_email_on_success) {
if (config.emailOnSuccess) {
await this.sendBackupNotification('success', {
duration: durationSeconds,
size: finalStats.size,
@@ -624,7 +536,7 @@ class DatabaseBackupService {
// Send failure notification
const config = await this.getBackupConfig();
if (config.database_backup_email_on_failure) {
if (config.emailOnFailure) {
await this.sendBackupNotification('failure', {
error: error.message
});
@@ -705,19 +617,10 @@ class DatabaseBackupService {
* Clean up old backups
*/
async cleanupOldBackups(retentionDays = 30) {
// A zero/negative/non-finite value pushes the cutoff to today or the
// future, matching (and deleting) every completed backup — including
// the one a scheduled run just created. Defense in depth: PUT /config
// already rejects such values, but this is also reachable with
// whatever database_backup_retention_days happens to be persisted.
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
logger.error(`Refusing to clean up backups with invalid retentionDays: ${retentionDays}`);
return;
}
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
// Get old backup records
const oldBackups = await db('database_backup_runs')
.where('completed_at', '<', cutoffDate)
@@ -862,30 +765,25 @@ async function startScheduledBackups() {
try {
const config = await databaseBackupService.getBackupConfig();
if (!config.database_backup_enabled) {
if (!config.enabled) {
logger.info('Database backup service is disabled');
return;
}
// Stop existing schedule
if (backupSchedule) {
backupSchedule.stop();
}
// Default schedule: 3 AM daily (offset from file backups at 2 AM)
const schedule = config.database_backup_schedule || '0 3 * * *';
const schedule = config.schedule || '0 3 * * *';
backupSchedule = cron.schedule(schedule, async () => {
logger.info('Starting scheduled database backup');
try {
await databaseBackupService.backup();
// Re-read retention on every tick rather than closing over the value
// from schedule start — a retention-only /config update doesn't
// restart the schedule (only enabled/schedule changes do), so the
// closed-over value would otherwise run stale until next restart.
const latestConfig = await databaseBackupService.getBackupConfig();
await databaseBackupService.cleanupOldBackups(latestConfig.database_backup_retention_days || 30);
await databaseBackupService.cleanupOldBackups(config.retentionDays || 30);
} catch (error) {
logger.error('Scheduled database backup failed:', error);
}
@@ -912,6 +810,5 @@ module.exports = {
databaseBackupService,
startScheduledBackups,
stopScheduledBackups,
isUnderPubliclyServableRoot,
DatabaseBackupService // Export class for testing
};
@@ -12,13 +12,21 @@
* schedulers don't all wake at once.
*/
const { scheduledTask } = require('./scheduledTask');
const cron = require('node-cron');
const logger = require('../utils/logger');
const downloadJobService = require('./downloadJobService');
const task = scheduledTask(runDownloadJobCleanup, { schedule: '7,27,47 * * * *' });
function startDownloadJobCleanup() { task.start(); }
const stopDownloadJobCleanup = () => task.stop();
function startDownloadJobCleanup() {
// A restart leaves any in-flight build with no worker. Fail those rows once
// at startup so their owners get a clear error instead of polling forever.
downloadJobService.recoverOrphanedJobs().catch((err) =>
logger.error('Download job recovery failed', { error: err.message }));
cron.schedule('7,27,47 * * * *', async () => {
await runDownloadJobCleanup();
});
logger.info('Download job cleanup scheduler started');
}
async function runDownloadJobCleanup() {
try {
@@ -29,7 +37,6 @@ async function runDownloadJobCleanup() {
}
module.exports = {
stopDownloadJobCleanup,
startDownloadJobCleanup,
// exported for tests / manual invocation
runDownloadJobCleanup,
@@ -37,13 +37,6 @@ class DownloadZipService {
this.versions = new Map(); // eventId -> generation counter
}
async stop() {
for (const timer of this.debounceTimers.values()) clearTimeout(timer);
this.debounceTimers.clear();
await Promise.allSettled([...this.activeBuilds.values()].map(build => build.promise));
this.versions.clear();
}
/**
* Relative storage key for the cached zip.
*/
+7 -4
View File
@@ -532,8 +532,11 @@ async function pollOnce() {
}
/** Start the 1-minute poll loop (mirrors the outgoing queue cadence). */
const mailPoller = require('./scheduledTask').scheduledTask(pollOnce, { interval: 60000, initialDelay: 15000 });
function startIncomingMailPoller() { mailPoller.start(); }
const stopIncomingMailPoller = () => mailPoller.stop();
function startIncomingMailPoller() {
const run = () => pollOnce().catch((e) => logger.error?.(`emailIntake: ${e.message}`));
setTimeout(run, 15000); // first run shortly after boot
setInterval(run, 60 * 1000);
logger.info?.('Incoming-mail poller started (every 60s when enabled)');
}
module.exports = { stopIncomingMailPoller, pollOnce, startIncomingMailPoller, listFolders, testConnection, roundTripTest, _internal: { getImapConfig, isEnabled, saveAttachment } };
module.exports = { pollOnce, startIncomingMailPoller, listFolders, testConnection, roundTripTest, _internal: { getImapConfig, isEnabled, saveAttachment } };
+31 -5
View File
@@ -1514,13 +1514,39 @@ async function testEmailConnection() {
}
}
const emailTask = require('./scheduledTask').scheduledTask(processEmailQueue, { interval: 60000, initialDelay: 0 });
// Start email queue processor
let emailQueueInterval = null;
function startEmailQueueProcessor() {
emailTask.start(); processorStatus.started = true;
logger.info('Email queue processor: Attempting to start...');
if (!emailQueueInterval) {
// Process immediately on start
processEmailQueue().catch(err => {
logger.error('Email queue processor: Initial processing failed:', err);
});
// Then process every minute
emailQueueInterval = setInterval(() => {
processEmailQueue().catch(err => {
logger.error('Email queue processor: Periodic processing failed:', err);
});
}, 60000);
processorStatus.started = true;
logger.info('Email queue processor started successfully');
} else {
logger.info('Email queue processor: Already running');
}
}
async function stopEmailQueueProcessor() {
await emailTask.stop(); processorStatus.started = false;
transporter?.close?.(); transporter = null;
function stopEmailQueueProcessor() {
if (emailQueueInterval) {
clearInterval(emailQueueInterval);
emailQueueInterval = null;
processorStatus.started = false;
logger.info('Email queue processor stopped');
}
}
// Initialize on module load - DISABLED for production startup
@@ -25,7 +25,6 @@ const fs = require('fs').promises;
const axios = require('axios');
const logger = require('../utils/logger');
const { signPayload } = require('./webhookService');
const { pinnedRequestOptions } = require('../utils/pinnedRequest');
const { validateExternalUrlAsync } = require('../utils/networkValidation');
const SIGNATURE_HEADER = 'X-PicPeak-Signature';
@@ -168,7 +167,6 @@ async function send(mail) {
// Vetted before every send, not once at startup: DNS answers change, and the
// check is what stops an operator-supplied URL becoming a request to link
// local metadata or a service on the host network.
let connectionOptions = {};
if (!allowPrivateUrls) {
// https for anything leaving the machine. The HMAC proves who sent the
// body, not who can read it — and these bodies carry password-reset links
@@ -192,7 +190,6 @@ async function send(mail) {
+ 'private network (a container or LAN address).'
);
}
connectionOptions = pinnedRequestOptions(check);
}
const payload = {
@@ -221,7 +218,6 @@ async function send(mail) {
let response;
try {
response = await axios.post(url, rawBody, {
...connectionOptions,
headers: {
'Content-Type': 'application/json',
[SIGNATURE_HEADER]: signature,
@@ -1,615 +0,0 @@
const { db, logActivity } = require('../database/db');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const fs = require('fs').promises;
const path = require('path');
const logger = require('../utils/logger');
const { AppError } = require('../utils/errors');
const { formatBoolean } = require('../utils/dbCompat');
const { slugify } = require('../utils/slug');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('./shareLinkService');
const { parseBooleanInput } = require('../utils/parsers');
const { normaliseEventTimeTriple } = require('./eventService');
const { hasColumnCached } = require('../utils/schemaCache');
const { getAppSetting } = require('../utils/appSettings');
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../utils/galleryPasswordVault');
const { clampIntOrUndefined } = require('../utils/numericHelpers');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const { resolveEventFeedbackDefaults, applyFeedbackDefaults } = require('./feedbackDefaults');
const { getStoragePath, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults,
getImageSecurityDefaults, resolveImageSecurityColumns, getBrandingDefaults, getCustomerNameFromPayload,
getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, hasCustomerContactColumns,
SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./eventSettings');
const { validateCreationInput } = require('./eventCreationValidation');
function creationError(body) {
const error = new AppError(body.error || 'Invalid event', 400, 'EVENT_INVALID');
error.responseBody = body;
return error;
}
/** Shared creation operation. v1 explicitly publishes immediately and accepts
* an optional absolute expiry; admin/legacy use configured field requirements.
*/
async function createEvent(data, { actor, source = 'admin', frontendUrl } = {}) {
const input = await validateCreationInput(data);
if (source === 'v1') input.is_draft = false;
if (!actor || !Number.isInteger(actor.id)) throw new AppError('Event owner required', 400, 'EVENT_OWNER_REQUIRED');
// Get field requirements from settings
const fieldRequirements = source === 'v1'
? { require_expiration: false } : 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,
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 = source !== 'v1',
// 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
} = input;
const customerName = getCustomerNameFromPayload(input);
const customerEmail = getCustomerEmailFromPayload(input);
// 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(input) : 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) {
throw creationError({ 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());
let passwordValidation = null;
if (requirePassword) {
// The v1 route validator marks password optional; the admin route's
// custom() guard is not shared, so enforce presence here for every path.
if (typeof password !== 'string' || password.length === 0) {
throw creationError({ error: 'Password is required when require_password is true' });
}
passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
throw creationError({
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 = input.expires_at ? new Date(input.expires_at) : null;
if (!expires_at && 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 = getStoragePath();
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 && (!input.header_style || !input.hero_divider_style)) {
try {
if (typeof color_theme === 'string' && color_theme.startsWith('{')) {
const parsed = JSON.parse(color_theme);
if (!input.header_style && parsed.headerStyle) {
effectiveHeaderStyle = parsed.headerStyle;
}
if (!input.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 = input.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 = input.hero_logo_size || null;
const effectiveHeroLogoPosition = input.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(
input,
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 insertData = {
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(),
updated_at: new Date().toISOString(),
created_by: actor.id,
allow_user_uploads: formatBoolean(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,
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(input.og_image_share_enabled === true),
};
// The gallery row and its feedback configuration commit together.
const eventId = await db.transaction(async trx => {
const result = await trx('events').insert(insertData).returning('id');
const eventId = result[0]?.id ?? result[0];
// Insert feedback settings if feedback is enabled
if (feedback_enabled) {
await trx('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()
});
}
return eventId;
});
// #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(input.customer_account_ids)) {
try {
const customerAccountsService = require('./customerAccountsService');
if (await customerAccountsService.isCustomerPortalEnabled()) {
await customerAccountsService.setAssignmentsForEvent(
eventId,
input.customer_account_ids,
actor.id
);
}
} catch (e) {
logger.error('Failed to set customer assignments on event create', {
eventId, error: e.message,
});
}
}
// Log activity
await logActivity('event_created',
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
eventId,
{ type: 'admin', id: actor.id, name: actor.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('./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 resolvedFrontendUrl = frontendUrl || await getFrontendBaseUrl();
emailData.client_link = `${resolvedFrontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
emailData.client_password = client_password;
}
// Best-effort, as the v1 route always was: the event, folder, activity
// log and webhook are committed by now, so a queue failure must not 500.
try {
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
});
} catch (queueError) {
logger.warn('Failed to queue gallery_created email on create', { eventId, error: queueError.message });
}
}
// 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('./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('./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 */ }
}
if (!isDraft) {
await require('./workflows').emitWorkflowEvent('gallery.published', {
entityType: 'event', entityId: eventId,
payload: { eventId, slug, eventName: event_name, eventDate: event_date,
customerEmail, adminEmail: admin_email, galleryLink: shareUrl,
expiresAt: expires_at ? expires_at.toISOString() : null },
}).catch(error => logger.warn('Failed to emit gallery.published', { eventId, error: error.message }));
}
return {
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,
share_token: shareToken,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
};
}
module.exports = { createEvent };
@@ -1,47 +0,0 @@
const Joi = require('joi');
const eventTypes = require('./eventTypeService');
const { AppError } = require('../utils/errors');
const { normaliseEventTimeTriple } = require('./eventService');
const optionalText = Joi.string().allow('', null);
const schema = Joi.object({
event_name: Joi.string().trim().min(1).max(255).required(),
event_type: Joi.string().trim().min(1).max(255).required(),
event_date: Joi.string().isoDate().raw().allow('', null),
expires_at: Joi.string().isoDate().raw().allow('', null),
expiration_days: Joi.number().integer().min(1).max(365),
customer_email: Joi.string().email({ tlds: { allow: false } }).allow('', null),
admin_email: Joi.string().email({ tlds: { allow: false } }).allow('', null),
customer_name: optionalText,
customer_phone: optionalText.max(32),
password: Joi.string().max(1024).allow('', null),
client_password: Joi.string().max(1024).allow('', null),
color_theme: optionalText,
welcome_message: optionalText,
photo_cap: Joi.number().integer().min(1).allow(null),
image_quality: Joi.number().integer().min(1).max(100),
protection_level: Joi.string().valid('basic', 'standard', 'enhanced', 'maximum'),
hero_logo_size: Joi.string().valid('small', 'medium', 'large', 'xlarge').allow(null),
hero_logo_position: Joi.string().valid('top', 'center', 'bottom'),
customer_account_ids: Joi.array().items(Joi.number().integer().min(1)),
...Object.fromEntries(['is_draft', 'require_password', 'allow_downloads', 'allow_user_uploads',
'disable_right_click', 'watermark_downloads', 'enable_devtools_protection', 'use_canvas_rendering',
'feedback_enabled', 'allow_ratings', 'allow_likes', 'allow_comments', 'allow_favorites',
'allow_reactions', 'allow_color_labels', 'require_name_email', 'moderate_comments',
'show_feedback_to_guests', 'client_access_enabled', 'og_image_share_enabled']
.map(key => [key, Joi.boolean().truthy(1, '1').falsy(0, '0')])),
hero_logo_visible: Joi.boolean().truthy(1, '1').falsy(0, '0').allow(null),
}).unknown(true);
async function validateCreationInput(data) {
const { value, error } = schema.validate(data, { abortEarly: false });
if (error) {
const err = new AppError('Invalid event', 400, 'EVENT_INVALID');
// Never return Joi's submitted value/context: it can contain passwords.
err.responseBody = { errors: error.details.map(item => ({ path: item.path.join('.'), msg: item.message })) };
throw err;
}
if (!await eventTypes.isValidEventType(value.event_type)) throw new AppError('Invalid event type', 400, 'EVENT_TYPE_INVALID');
normaliseEventTimeTriple(value); // Reject before password hashing or filesystem writes.
return value;
}
module.exports = { validateCreationInput };
+163 -7
View File
@@ -10,11 +10,11 @@ const crypto = require('crypto');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const { hasColumnCached } = require('../utils/schemaCache');
const { getBcryptRounds } = require('../utils/passwordValidation');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('./shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('./eventTypeService');
const { AppError } = require('../utils/errors');
@@ -154,11 +154,167 @@ const createEventFolders = async (slug) => {
* @param {Object} eventData - Event data
* @returns {Promise<Object>} - Created event
*/
const createEvent = async (eventData, options = {}) => {
return require('./eventCreationService').createEvent(eventData, {
...options,
actor: options.actor || (eventData.created_by ? { id: eventData.created_by } : undefined),
const createEvent = async (eventData) => {
const {
event_type,
event_name,
event_date,
customer_name,
customer_email,
admin_email,
password,
require_password = true,
welcome_message,
color_theme,
expiration_days = 30,
// Feedback settings
feedback_enabled,
allow_ratings,
allow_likes,
allow_comments,
allow_favorites,
require_name_email,
moderate_comments,
show_feedback_to_guests,
// Upload settings
allow_user_uploads,
upload_category_id,
// Photo cap
photo_cap,
// Migration 137 — calendar time fields. Defaults to full-day when
// the caller (legacy create-event form) doesn't know about them.
event_time_start,
event_time_end,
is_full_day
} = eventData;
const requirePassword = parseBooleanInput(require_password, true);
const customerColumnsAvailable = await hasCustomerContactColumns();
// Validate + normalise the calendar time triple up front so we throw
// before bcrypt + folder creation if the payload is bad.
const timeTriple = normaliseEventTimeTriple({
event_time_start, event_time_end, is_full_day,
});
// Validate password if required
if (requirePassword) {
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
const error = new Error('Password does not meet security requirements');
error.code = 'PASSWORD_INVALID';
error.details = passwordValidation.errors;
error.score = passwordValidation.score;
error.feedback = passwordValidation.feedback;
throw error;
}
}
// Generate unique slug
const slug = await generateUniqueSlug(event_type, event_name, event_date);
// Generate share link
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password
const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date
const expires_at = new Date(event_date);
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Create folder structure
await createEventFolders(slug);
// Build insert data
const insertData = {
slug,
event_type,
event_name,
event_date,
...(customerColumnsAvailable ? { customer_name, customer_email } : {}),
host_name: customer_name,
host_email: customer_email,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at,
require_password: formatBoolean(requirePassword),
// Feedback settings
feedback_enabled: feedback_enabled !== undefined ? formatBoolean(feedback_enabled) : undefined,
allow_ratings: allow_ratings !== undefined ? formatBoolean(allow_ratings) : undefined,
allow_likes: allow_likes !== undefined ? formatBoolean(allow_likes) : undefined,
allow_comments: allow_comments !== undefined ? formatBoolean(allow_comments) : undefined,
allow_favorites: allow_favorites !== undefined ? formatBoolean(allow_favorites) : undefined,
require_name_email: require_name_email !== undefined ? formatBoolean(require_name_email) : undefined,
moderate_comments: moderate_comments !== undefined ? formatBoolean(moderate_comments) : undefined,
show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined,
// Upload settings
allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined,
upload_category_id: upload_category_id || null,
// Photo cap
photo_cap: photo_cap || null
};
// Migration 137 — calendar time fields. Guarded by hasColumnCached so
// installs that haven't applied 137 yet skip the columns silently
// (per feedback_schema_drift_guards.md / feedback_cache_hasColumn_lookups.md).
if (await hasColumnCached('events', 'is_full_day')) {
insertData.event_time_start = timeTriple.event_time_start;
insertData.event_time_end = timeTriple.event_time_end;
insertData.is_full_day = formatBoolean(timeTriple.is_full_day);
}
// Remove undefined values
Object.keys(insertData).forEach(key => {
if (insertData[key] === undefined) {
delete insertData[key];
}
});
// Insert into database
const insertResult = await db('events').insert(insertData).returning('id');
const eventId = insertResult[0]?.id || insertResult[0];
// Fire gallery.published — a gallery goes live the moment it's created (active
// + share link). Best-effort; emit is fail-closed when the workflows flag is
// off and never throws into the create path.
try {
await require('./workflows').emitWorkflowEvent('gallery.published', {
entityType: 'event',
entityId: eventId,
payload: {
eventId,
slug,
eventName: event_name,
eventDate: event_date,
customerEmail: customer_email || null,
adminEmail: admin_email || null,
galleryLink: shareUrl,
expiresAt: expires_at,
},
});
} catch (err) {
logger.warn('Failed to emit gallery.published workflow event', { eventId, error: err.message });
}
return {
id: eventId,
slug,
share_link: shareUrl,
expires_at,
require_password: requirePassword,
customer_name,
customer_email
};
};
/**

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