fix: enforce gallery access and consolidate gallery workflows (#1357)
Harden gallery authentication and authorization, consolidate gallery workflows, and prevent token-bearing URLs from leaking through nginx request error logs.
This commit is contained in:
@@ -125,6 +125,11 @@ DB_NAME=picpeak_prod
|
|||||||
# address is refused by the SSRF check otherwise. Running n8n beside PicPeak is
|
# address is refused by the SSRF check otherwise. Running n8n beside PicPeak is
|
||||||
# normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that.
|
# 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
|
# A mail account with its own SMTP host (Settings -> Mail accounts) keeps
|
||||||
# sending through it; this replaces the global transport only.
|
# sending through it; this replaces the global transport only.
|
||||||
#
|
#
|
||||||
@@ -353,3 +358,9 @@ LOGS=./logs
|
|||||||
# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET.
|
# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET.
|
||||||
# Keep this value stable until participation has been deleted.
|
# Keep this value stable until participation has been deleted.
|
||||||
# USAGE_ENCRYPTION_KEY=
|
# 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
|
||||||
|
|||||||
+21
-17
@@ -6,11 +6,6 @@ name: Tests
|
|||||||
# calendar) plus the photo / settings / OG / auth surface — wiring them
|
# calendar) plus the photo / settings / OG / auth surface — wiring them
|
||||||
# into CI makes regressions visible at PR time instead of post-merge.
|
# 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
|
# Triggers on any change that could affect either suite. The backend
|
||||||
# job intentionally omits frontend paths and vice versa so unrelated
|
# job intentionally omits frontend paths and vice versa so unrelated
|
||||||
# PRs don't pay both build costs.
|
# PRs don't pay both build costs.
|
||||||
@@ -89,18 +84,7 @@ jobs:
|
|||||||
# Un-gates the real-Postgres cases in the .picpeak restore suites
|
# Un-gates the real-Postgres cases in the .picpeak restore suites
|
||||||
# (see the `services:` note above). Absent it they silently skip.
|
# (see the `services:` note above). Absent it they silently skip.
|
||||||
PICPEAK_PG_TEST_URL: 'postgres://picpeak:[email protected]:5432/picpeak_test'
|
PICPEAK_PG_TEST_URL: 'postgres://picpeak:[email protected]:5432/picpeak_test'
|
||||||
run: |
|
run: npx jest --ci
|
||||||
# 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:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -121,10 +105,30 @@ jobs:
|
|||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint frontend (including Rules of Hooks)
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
- name: Run Vitest suite
|
- name: Run Vitest suite
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: npm test -- --run
|
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
|
# Optional face-detection sidecar (#1074). Runs on every PR regardless of
|
||||||
# whether the feature is enabled anywhere — these tests need no model
|
# whether the feature is enabled anywhere — these tests need no model
|
||||||
# weights (they stub the pipeline out) and cover the auth boundary, the
|
# weights (they stub the pipeline out) and cover the auth boundary, the
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
|
|||||||
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
|
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const { db } = require('../../src/database/db');
|
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||||
const webhookService = require('../../src/services/webhookService');
|
let db, cleanup, adminId;
|
||||||
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
|
let webhookService;
|
||||||
|
let __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker;
|
||||||
|
|
||||||
// Local-only test stub: matches what dev/webhook-receiver/server.js does
|
// 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
|
// in the docker-compose flow but spun up inside the Jest process so the
|
||||||
@@ -43,7 +44,7 @@ async function insertWebhook(url, events = ['event.published'], extras = {}) {
|
|||||||
secret_preview: preview,
|
secret_preview: preview,
|
||||||
events: JSON.stringify(events),
|
events: JSON.stringify(events),
|
||||||
active: extras.active !== false,
|
active: extras.active !== false,
|
||||||
created_by: 1,
|
created_by: adminId,
|
||||||
}).returning('id');
|
}).returning('id');
|
||||||
const id = insert[0]?.id || insert[0];
|
const id = insert[0]?.id || insert[0];
|
||||||
return { id, secret: plaintext };
|
return { id, secret: plaintext };
|
||||||
@@ -56,16 +57,15 @@ async function clearWebhooks() {
|
|||||||
|
|
||||||
describe('webhook delivery worker (#327)', () => {
|
describe('webhook delivery worker (#327)', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Schema is expected to already be applied by `npm run migrate`. We
|
({ db, cleanup } = await bootCrmDb());
|
||||||
// just verify the webhooks tables exist; if not, the test harness has
|
({ adminId } = await seedMinimal(db));
|
||||||
// missed running migration 082.
|
webhookService = require('../../src/services/webhookService');
|
||||||
const ok = await db.schema.hasTable('webhooks');
|
({ __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker'));
|
||||||
if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first');
|
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
stopWebhookDeliveryWorker();
|
await stopWebhookDeliveryWorker();
|
||||||
await db.destroy();
|
await cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const fake = { maintenance: 'true', revoked: false, beforeCutoff: false, admin:
|
|||||||
jest.mock('../../src/database/db', () => {
|
jest.mock('../../src/database/db', () => {
|
||||||
const db = jest.fn((table) => {
|
const db = jest.fn((table) => {
|
||||||
const q = {
|
const q = {
|
||||||
|
leftJoin: jest.fn().mockReturnThis(),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
select: jest.fn().mockReturnThis(),
|
select: jest.fn().mockReturnThis(),
|
||||||
first: jest.fn(async () => {
|
first: jest.fn(async () => {
|
||||||
@@ -27,6 +28,7 @@ jest.mock('../../src/database/db', () => {
|
|||||||
return { setting_key: 'general_maintenance_mode', setting_value: fake.maintenance };
|
return { setting_key: 'general_maintenance_mode', setting_value: fake.maintenance };
|
||||||
}
|
}
|
||||||
if (table === 'admin_users') return fake.admin;
|
if (table === 'admin_users') return fake.admin;
|
||||||
|
if (table === 'events') return { id: 1, slug: 'preview', created_by: 1, is_active: 1 };
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -34,6 +36,7 @@ jest.mock('../../src/database/db', () => {
|
|||||||
});
|
});
|
||||||
return { db, withRetry: (fn) => fn() };
|
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/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/tokenRevocation', () => ({ isTokenRevoked: jest.fn(async () => fake.revoked) }));
|
||||||
jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn(async () => fake.beforeCutoff) }));
|
jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn(async () => fake.beforeCutoff) }));
|
||||||
@@ -75,7 +78,7 @@ describe('general rate limiter skip', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('admin preview requires a live admin session', () => {
|
describe('admin preview requires a live admin session', () => {
|
||||||
const req = (token) => ({ query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} });
|
const req = (token) => ({ params: { slug: 'preview' }, query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} });
|
||||||
beforeEach(() => { fake.revoked = false; fake.beforeCutoff = false; fake.admin = { id: 1, password_changed_at: null }; });
|
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 () => {
|
it('passes for a live session and sets req.isAdminPreview', async () => {
|
||||||
@@ -108,13 +111,23 @@ describe('multipart origin gate', () => {
|
|||||||
const req = (headers) => ({ headers: { host: 'photos.example.com', ...headers } });
|
const req = (headers) => ({ headers: { host: 'photos.example.com', ...headers } });
|
||||||
it('accepts same-origin, same-site and non-browser requests', () => {
|
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-origin' }))).toBe(true);
|
||||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(true);
|
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(false);
|
||||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'none' }))).toBe(true);
|
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'none' }))).toBe(true);
|
||||||
expect(multipartOriginAllowed(req({}))).toBe(true);
|
expect(multipartOriginAllowed(req({}))).toBe(true);
|
||||||
expect(multipartOriginAllowed(req({ origin: 'https://photos.example.com' }))).toBe(true);
|
expect(multipartOriginAllowed(req({ origin: 'https://photos.example.com' }))).toBe(true);
|
||||||
// Same-origin install without FRONTEND_URL: Origin matches the Host.
|
// Same-origin install without FRONTEND_URL: Origin matches the Host.
|
||||||
expect(multipartOriginAllowed({ headers: { host: 'gallery.local', origin: 'http://gallery.local' } })).toBe(true);
|
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', () => {
|
it('rejects cross-site form posts', () => {
|
||||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'cross-site' }))).toBe(false);
|
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'cross-site' }))).toBe(false);
|
||||||
expect(multipartOriginAllowed(req({ origin: 'https://evil.example' }))).toBe(false);
|
expect(multipartOriginAllowed(req({ origin: 'https://evil.example' }))).toBe(false);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const knex = require('knex');
|
||||||
|
const migration = require('../../migrations/core/210_events_updated_at');
|
||||||
|
const { toTimestamp } = require('../../src/utils/dateNormalize');
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
|
||||||
|
const engines = [['sqlite', null], ...(process.env.PICPEAK_PG_TEST_URL ? [['pg', process.env.PICPEAK_PG_TEST_URL]] : [])];
|
||||||
|
describe.each(engines)('event timestamp migration contract (%s)', (engine, connection) => {
|
||||||
|
let db, owner, schema;
|
||||||
|
beforeEach(async () => {
|
||||||
|
if (engine === 'pg') {
|
||||||
|
schema = `event_contract_${randomUUID().replace(/-/g, '')}`;
|
||||||
|
owner = knex({ client: 'pg', connection });
|
||||||
|
await owner.schema.createSchema(schema);
|
||||||
|
db = knex({ client: 'pg', connection, searchPath: [schema] });
|
||||||
|
} else db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.destroy();
|
||||||
|
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
|
||||||
|
});
|
||||||
|
it('upgrades legacy data, is repeatable and preserves subsequent edits', async () => {
|
||||||
|
await db.schema.createTable('events', table => {
|
||||||
|
table.increments('id'); table.timestamp('created_at').defaultTo(db.fn.now()); table.boolean('is_active').defaultTo(true);
|
||||||
|
});
|
||||||
|
const created = '2026-01-02T03:04:05.000Z';
|
||||||
|
await db('events').insert({ created_at: created });
|
||||||
|
await migration.up(db); await migration.up(db);
|
||||||
|
let row = await db('events').first();
|
||||||
|
expect(toTimestamp(row.updated_at)).toBe(Date.parse(created));
|
||||||
|
await db('events').where({ id: row.id }).update({ updated_at: db.fn.now(), is_active: engine === 'pg' ? false : 0 });
|
||||||
|
const changed = (await db('events').first()).updated_at;
|
||||||
|
await migration.up(db); row = await db('events').first();
|
||||||
|
expect(toTimestamp(row.updated_at)).toBe(toTimestamp(changed)); expect([false, 0]).toContain(row.is_active);
|
||||||
|
});
|
||||||
|
it('handles a fresh table and an already present updated_at column', async () => {
|
||||||
|
await db.schema.createTable('events', table => { table.increments('id'); table.timestamp('created_at'); table.timestamp('updated_at'); });
|
||||||
|
await migration.up(db);
|
||||||
|
expect(await db.schema.hasColumn('events', 'updated_at')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const knex = require('knex');
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
const fs = require('fs/promises');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const request = require('supertest');
|
||||||
|
const pgUrl = process.env.PICPEAK_PG_TEST_URL;
|
||||||
|
(pgUrl ? describe : describe.skip)('fresh PostgreSQL gallery contract', () => {
|
||||||
|
let owner, db, schema, tmpDir, cleanup, previousClient;
|
||||||
|
beforeAll(async () => {
|
||||||
|
schema = `fresh_gallery_${randomUUID().replace(/-/g, '')}`;
|
||||||
|
owner = knex({ client: 'pg', connection: pgUrl });
|
||||||
|
await owner.schema.createSchema(schema);
|
||||||
|
previousClient = process.env.DATABASE_CLIENT;
|
||||||
|
process.env.DATABASE_CLIENT = 'pg';
|
||||||
|
process.env.JWT_SECRET = 'fresh-pg-gallery-test-secret-at-least-32-characters';
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-fresh-pg-'));
|
||||||
|
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||||
|
jest.doMock('../../knexfile', () => ({ client: 'pg', connection: pgUrl, searchPath: [schema] }));
|
||||||
|
({ db } = require('../../src/database/db'));
|
||||||
|
// bootCrmDb runs the complete core chain against the shared db singleton.
|
||||||
|
({ cleanup } = await require('../integration/helpers/crmDb').bootCrmDb());
|
||||||
|
}, 120000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await require('../../src/services/serviceShutdown').stopServices();
|
||||||
|
if (cleanup) await cleanup(); else if (db) await db.destroy();
|
||||||
|
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
|
||||||
|
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
if (previousClient === undefined) delete process.env.DATABASE_CLIENT; else process.env.DATABASE_CLIENT = previousClient;
|
||||||
|
jest.dontMock('../../knexfile');
|
||||||
|
});
|
||||||
|
it('creates through the real admin route, then toggles a typed boolean and timestamp', async () => {
|
||||||
|
const { seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||||
|
const { adminId } = await seedMinimal(db);
|
||||||
|
await assignAdminRole(db, adminId);
|
||||||
|
const app = buildRouteApp('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||||
|
const bearer = `Bearer ${mintAdminToken(adminId)}`;
|
||||||
|
const created = await request(app).post('/api/admin/events').set('Authorization', bearer).send({
|
||||||
|
event_type: 'wedding', event_name: 'Fresh PostgreSQL', event_date: '2026-10-01',
|
||||||
|
customer_name: 'Customer', customer_email: '[email protected]', admin_email: '[email protected]',
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,460 +1,82 @@
|
|||||||
/**
|
/** 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 request = require('supertest');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
process.env.JWT_SECRET = 'session-symmetry-test-secret';
|
const { bootCrmDb, seedMinimal, assignAdminRole, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||||
|
process.env.JWT_SECRET = 'session-symmetry-test-secret-with-at-least-32-characters';
|
||||||
const fakeDb = {
|
let db, cleanup, app, adminId, customerId, eventId, cutoff;
|
||||||
adminUsers: [],
|
const slug = 'session-symmetry';
|
||||||
events: [],
|
const sign = (claims = {}) => jwt.sign({ type: 'admin', id: adminId, username: 'tester',
|
||||||
revokedTokens: [],
|
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 });
|
||||||
jest.mock('../../src/database/db', () => {
|
const session = bearer => request(app).get(`/api/auth/session?slug=${slug}`).set('Authorization', `Bearer ${bearer}`);
|
||||||
const formatBoolean = (v) => (v ? 1 : 0);
|
beforeAll(async () => {
|
||||||
void formatBoolean;
|
({ db, cleanup } = await bootCrmDb());
|
||||||
function dbFn(table) {
|
({ adminId, customerId } = await seedMinimal(db));
|
||||||
if (table === 'admin_users') {
|
await assignAdminRole(db, adminId);
|
||||||
let rowFilter = () => true;
|
const row = await require('../../src/services/eventCreationService').createEvent({
|
||||||
return {
|
event_type: 'wedding', event_name: 'Session symmetry', event_date: '2026-10-01',
|
||||||
// The session route joins roles for the adminUser payload (#798);
|
slug, password: 'Session-Strong-Password-924!', expiration_days: 30,
|
||||||
// fake rows carry no role fields, so the join is a pass-through.
|
customer_email: '[email protected]', admin_email: '[email protected]',
|
||||||
leftJoin() {
|
}, { actor: { id: adminId }, source: 'v1' });
|
||||||
return this;
|
eventId = row.id;
|
||||||
},
|
await db('events').where({ id: eventId }).update({ slug });
|
||||||
where(criteria) {
|
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
|
||||||
rowFilter = (row) => {
|
cutoff = require('../../src/utils/sessionCutoff');
|
||||||
return Object.entries(criteria).every(([rawKey, v]) => {
|
app = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||||
// Joined queries prefix columns ('admin_users.id') — the fake
|
}, 120000);
|
||||||
// rows use bare names.
|
beforeEach(async () => {
|
||||||
const k = rawKey.replace(/^admin_users\./, '');
|
await db('admin_users').where({ id: adminId }).update({ is_active: 1, password_changed_at: null });
|
||||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null });
|
||||||
return row[k] === v;
|
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);
|
||||||
});
|
});
|
||||||
};
|
afterAll(async () => {
|
||||||
return this;
|
await require('../../src/services/serviceShutdown').stopServices();
|
||||||
},
|
if (cleanup) await cleanup();
|
||||||
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;
|
it('hydrates an active admin and its role', async () => {
|
||||||
},
|
const res = await session(sign());
|
||||||
async first() {
|
expect(res.body).toMatchObject({ valid: true, type: 'admin', adminUser: { id: adminId, role: { name: 'super_admin' } } });
|
||||||
return fakeDb.events.find(rowFilter);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw new Error(`Unexpected table: ${table}`);
|
|
||||||
}
|
|
||||||
return { db: dbFn, formatBoolean: () => 1 };
|
|
||||||
});
|
});
|
||||||
|
it.each(['disabled', 'password', 'deleted', 'idle'])('rejects an admin after %s', async reason => {
|
||||||
jest.mock('../../src/utils/dbCompat', () => ({
|
let bearer = sign();
|
||||||
formatBoolean: (v) => (v ? 1 : 0),
|
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 });
|
||||||
jest.mock('../../src/utils/tokenRevocation', () => ({
|
if (reason === 'idle') bearer = sign({ iat: Math.floor(Date.now() / 1000) - 7200 });
|
||||||
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
|
expect((await session(bearer)).body.valid).toBe(false);
|
||||||
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('accepts a session issued after a previous password change', async () => {
|
||||||
it('returns valid:true for an active admin token', async () => {
|
await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date(Date.now() - 120000).toISOString() });
|
||||||
fakeDb.adminUsers.push({
|
expect((await session(sign())).body.valid).toBe(true);
|
||||||
id: 1,
|
|
||||||
username: 'admin',
|
|
||||||
email: '[email protected]',
|
|
||||||
is_active: true,
|
|
||||||
password_changed_at: null,
|
|
||||||
});
|
});
|
||||||
const token = signAdminToken({ id: 1 });
|
it.each(['archived', 'expired', 'draft', 'inactive'])('rejects a gallery that is %s', async reason => {
|
||||||
|
await db('events').where({ id: eventId }).update({
|
||||||
const res = await request(makeApp())
|
...(reason === 'archived' && { is_archived: 1 }), ...(reason === 'draft' && { is_draft: 1 }),
|
||||||
.get('/auth/session')
|
...(reason === 'inactive' && { is_active: 0 }), ...(reason === 'expired' && { expires_at: new Date(Date.now() - 1000).toISOString() }),
|
||||||
.set('Authorization', `Bearer ${token}`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.valid).toBe(true);
|
|
||||||
expect(res.body.type).toBe('admin');
|
|
||||||
});
|
});
|
||||||
|
expect((await session(gallery())).body.valid).toBe(false);
|
||||||
it('returns valid:false when the admin user has been deactivated', async () => {
|
|
||||||
fakeDb.adminUsers.push({
|
|
||||||
id: 1,
|
|
||||||
username: 'admin',
|
|
||||||
email: '[email protected]',
|
|
||||||
is_active: false,
|
|
||||||
password_changed_at: null,
|
|
||||||
});
|
});
|
||||||
const token = signAdminToken({ id: 1 });
|
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 }));
|
||||||
const res = await request(makeApp())
|
expect(res.body).toMatchObject({ valid: true, accessLevel: kind === 'client' ? 'client' : 'guest', viaCustomer: kind === 'customer' });
|
||||||
.get('/auth/session')
|
|
||||||
.set('Authorization', `Bearer ${token}`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.valid).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
it.each(['revoked', 'restore'])('invalidates both admin and gallery sessions after %s', async reason => {
|
||||||
it('returns valid:false when the admin user no longer exists', async () => {
|
const tokens = [sign(), gallery()];
|
||||||
// adminUsers is empty
|
if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||||
const token = signAdminToken({ id: 999 });
|
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);
|
||||||
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: '[email protected]',
|
|
||||||
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: '[email protected]',
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
it('refuses a deactivated customer gallery session', async () => {
|
||||||
|
const bearer = gallery({ via: 'customer', customerId });
|
||||||
|
expect((await session(bearer)).body.valid).toBe(true);
|
||||||
|
await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 });
|
||||||
|
expect((await session(bearer)).body.valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
it('refuses an unrelated JWT type', async () => {
|
||||||
|
const res = await session(sign({ type: 'password-reset' }));
|
||||||
|
expect(res.status).toBe(403); expect(res.body.valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/** Real routes + migrated SQLite: the same session policy protects lists and media. */
|
||||||
|
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs/promises');
|
||||||
|
const path = require('path');
|
||||||
|
process.env.JWT_SECRET = 'gallery-policy-regression-secret-at-least-32-characters';
|
||||||
|
|
||||||
|
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
|
||||||
|
secureImageAccess: (req, _res, next) => {
|
||||||
|
req.clientInfo = { fingerprint: 'policy-test', ip: '127.0.0.1', userAgent: 'jest' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
getSecurityStatus: (_req, res) => res.json({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let db, cleanup, app, adminId, customerId, foreignId, event, secure, cutoff, revokeToken;
|
||||||
|
const eventId = 70001, photoId = 70002, slug = 'policy-test';
|
||||||
|
const token = (claims = {}) => jwt.sign({ type: 'gallery', eventId, eventSlug: slug,
|
||||||
|
iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims },
|
||||||
|
process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '1h' });
|
||||||
|
const get = (url, bearer) => {
|
||||||
|
const req = request(app).get(url);
|
||||||
|
return bearer ? req.set('Authorization', `Bearer ${bearer}`) : req;
|
||||||
|
};
|
||||||
|
const endpoints = [`/api/gallery/${slug}/photos`, `/api/gallery/${slug}/photo/${photoId}`,
|
||||||
|
`/api/gallery/${slug}/thumbnail/${photoId}`, `/api/gallery/${slug}/download/${photoId}`];
|
||||||
|
const expectDirect = async (bearer, status, suffix = '') => {
|
||||||
|
for (const url of endpoints) expect((await get(url + suffix, bearer)).status).toBe(status);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
({ adminId, customerId } = await seedMinimal(db));
|
||||||
|
await assignAdminRole(db, adminId);
|
||||||
|
const [row] = await db('admin_users').insert({ username: 'foreign', email: '[email protected]', 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: '[email protected]', admin_email: '[email protected]', password_hash: 'unused',
|
||||||
|
share_link: '/gallery/policy-test', created_by: adminId });
|
||||||
|
const file = path.join(process.env.STORAGE_PATH, `events/active/${slug}/individual/fixture.jpg`);
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||||
|
await require('sharp')({ create: { width: 8, height: 8, channels: 3, background: '#228844' } }).jpeg().toFile(file);
|
||||||
|
await db('photos').insert({ id: photoId, event_id: eventId, filename: 'fixture.jpg', path: `${slug}/individual/fixture.jpg`,
|
||||||
|
type: 'individual', mime_type: 'image/jpeg', processing_status: 'complete', size_bytes: (await fs.stat(file)).size });
|
||||||
|
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
|
||||||
|
secure = require('../../src/services/secureImageService');
|
||||||
|
jest.spyOn(secure, 'createClientFingerprint').mockReturnValue('policy-test');
|
||||||
|
cutoff = require('../../src/utils/sessionCutoff');
|
||||||
|
({ revokeToken } = require('../../src/utils/tokenRevocation'));
|
||||||
|
app = express(); app.use(express.json()); app.use(cookieParser());
|
||||||
|
app.use('/api', require('../../src/middleware/csrf'));
|
||||||
|
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||||
|
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||||
|
app.use('/api/images', require('../../src/routes/protectedImages'));
|
||||||
|
app.use('/api/secure-images', require('../../src/routes/secureImages'));
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0, require_password: 1,
|
||||||
|
expires_at: new Date(Date.now() + 86400000).toISOString(), reveal_mode: 0 });
|
||||||
|
await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null });
|
||||||
|
if (!await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).first()) {
|
||||||
|
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
|
||||||
|
}
|
||||||
|
await cutoff.setSessionsValidAfter(0);
|
||||||
|
event = await db('events').where({ id: eventId }).first();
|
||||||
|
});
|
||||||
|
afterAll(async () => { secure?.dispose(); if (cleanup) await cleanup(); });
|
||||||
|
|
||||||
|
it('serves a valid session as a real list and JPEG', async () => {
|
||||||
|
const bearer = token();
|
||||||
|
const list = await get(endpoints[0], bearer);
|
||||||
|
expect(list.status).toBe(200);
|
||||||
|
expect(list.body.photos).toEqual(expect.arrayContaining([expect.objectContaining({ id: photoId })]));
|
||||||
|
const image = await get(endpoints[1], bearer);
|
||||||
|
expect(image.status).toBe(200); expect(image.headers['content-type']).toMatch(/image\/jpeg/);
|
||||||
|
expect(image.body.length).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
it('scopes draft previews to the owner and current read permissions', async () => {
|
||||||
|
await db('events').where({ id: eventId }).update({ is_draft: 1 });
|
||||||
|
await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1');
|
||||||
|
await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1');
|
||||||
|
// Ownership alone does not grant a user without a role read access.
|
||||||
|
const role = (await db('admin_users').where({ id: adminId }).first()).role_id;
|
||||||
|
await db('admin_users').where({ id: adminId }).update({ role_id: null });
|
||||||
|
try { await expectDirect(mintAdminToken(adminId), 403, '?admin_preview=1'); }
|
||||||
|
finally { await db('admin_users').where({ id: adminId }).update({ role_id: role }); }
|
||||||
|
});
|
||||||
|
it.each(['revocation', 'restore'])('rejects gallery sessions after %s', async (reason) => {
|
||||||
|
const bearer = token();
|
||||||
|
if (reason === 'revocation') expect(await revokeToken(bearer, 'test')).toBe(true);
|
||||||
|
else await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||||
|
await expectDirect(bearer, 401);
|
||||||
|
});
|
||||||
|
it.each(['deactivated', 'password changed'])('rejects an assigned customer when %s', async (reason) => {
|
||||||
|
const bearer = token({ via: 'customer', customerId });
|
||||||
|
await expectDirect(bearer, 200);
|
||||||
|
await db('customer_accounts').where({ id: customerId }).update(reason === 'deactivated'
|
||||||
|
? { is_active: 0 } : { password_changed_at: new Date().toISOString() });
|
||||||
|
await expectDirect(bearer, 401);
|
||||||
|
});
|
||||||
|
it.each(['ISO', 'epoch'])('enforces expiry immediately for public and JWT access (%s)', async (format) => {
|
||||||
|
const expiry = Date.now() - 1000;
|
||||||
|
await db('events').where({ id: eventId }).update({ require_password: 0, expires_at: format === 'ISO' ? new Date(expiry).toISOString() : expiry });
|
||||||
|
await expectDirect(undefined, 404); await expectDirect(token(), 404);
|
||||||
|
await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1');
|
||||||
|
});
|
||||||
|
it.each(['revocation', 'restore', 'expiry', 'customer'])('rechecks signed and secure image grants after %s', async (reason) => {
|
||||||
|
const bearer = token(reason === 'customer' ? { via: 'customer', customerId } : {});
|
||||||
|
const signed = await request(app).post(`/api/images/${slug}/photo/${photoId}/generate-url`).set('Authorization', `Bearer ${bearer}`).send({});
|
||||||
|
expect(signed.status).toBe(200);
|
||||||
|
const minted = await request(app).post(`/api/secure-images/${slug}/generate-token`).set('Authorization', `Bearer ${bearer}`).send({ photoId });
|
||||||
|
expect(minted.status).toBe(200);
|
||||||
|
const secureUrl = `/api/secure-images/${slug}/secure/${photoId}/${minted.body.token}`;
|
||||||
|
expect((await get(signed.body.url)).status).toBe(200);
|
||||||
|
expect((await get(secureUrl)).status).toBe(200);
|
||||||
|
if (reason === 'revocation') await revokeToken(bearer, 'test');
|
||||||
|
if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||||
|
if (reason === 'expiry') await db('events').where({ id: eventId }).update({ expires_at: new Date(Date.now() - 1000).toISOString() });
|
||||||
|
if (reason === 'customer') await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 });
|
||||||
|
const status = reason === 'expiry' ? 404 : 401;
|
||||||
|
expect((await get(signed.body.url)).status).toBe(status);
|
||||||
|
expect((await get(secureUrl)).status).toBe(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks an empty cross-site cookie POST before the reveal state changes', async () => {
|
||||||
|
await db('events').where({ id: eventId }).update({ reveal_mode: 1, revealed_at: null });
|
||||||
|
const cookie = `admin_token=${mintAdminToken(adminId)}`;
|
||||||
|
const url = `/api/admin/events/${eventId}/reveal`;
|
||||||
|
const blocked = await request(app).post(url).set('Cookie', cookie).set('Origin', 'https://attacker.example')
|
||||||
|
.set('Sec-Fetch-Site', 'cross-site').set('Content-Type', 'application/x-www-form-urlencoded').send('');
|
||||||
|
expect(blocked.status).toBe(403);
|
||||||
|
expect((await db('events').where({ id: eventId }).first()).revealed_at).toBeNull();
|
||||||
|
process.env.ADMIN_URL = 'https://admin.example.test';
|
||||||
|
try {
|
||||||
|
const allowed = await request(app).post(url).set('Cookie', cookie).set('Origin', process.env.ADMIN_URL)
|
||||||
|
.set('Sec-Fetch-Site', 'cross-site').send({});
|
||||||
|
expect(allowed.status).toBe(200);
|
||||||
|
expect((await db('events').where({ id: eventId }).first()).revealed_at).not.toBeNull();
|
||||||
|
} finally { delete process.env.ADMIN_URL; }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles status on a fully migrated fresh database and records updated_at', async () => {
|
||||||
|
const response = await request(app).post(`/api/admin/events/${eventId}/toggle-status`)
|
||||||
|
.set('Authorization', `Bearer ${mintAdminToken(adminId)}`).send({});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const row = await db('events').where({ id: eventId }).first();
|
||||||
|
expect([false, 0]).toContain(row.is_active);
|
||||||
|
expect(Number.isFinite(require('../../src/utils/dateNormalize').toTimestamp(row.updated_at))).toBe(true);
|
||||||
|
});
|
||||||
|
it('paginates after feedback filtering, with a total independent of page size', async () => {
|
||||||
|
const ids = [70003, 70004, 70005];
|
||||||
|
await db('photos').insert(ids.map(id => ({ id, event_id: eventId, filename: `${id}.jpg`, path: 'unused',
|
||||||
|
type: 'individual', like_count: 1, processing_status: 'complete' })));
|
||||||
|
await db('event_feedback_settings').insert({ event_id: eventId, feedback_enabled: 1, show_feedback_to_guests: 1 });
|
||||||
|
try {
|
||||||
|
const bearer = token();
|
||||||
|
const first = await get(`${endpoints[0]}?filter=liked&limit=2&page=1&sort=filename&order=asc`, bearer);
|
||||||
|
const second = await get(`${endpoints[0]}?filter=liked&limit=2&page=2&sort=filename&order=asc`, bearer);
|
||||||
|
expect(first.status).toBe(200); expect(second.status).toBe(200);
|
||||||
|
expect(first.body.pagination).toMatchObject({ total: 3, has_more: true });
|
||||||
|
expect(second.body.pagination).toMatchObject({ total: 3, has_more: false });
|
||||||
|
expect([...first.body.photos, ...second.body.photos].map(photo => photo.id)).toEqual(ids);
|
||||||
|
} finally {
|
||||||
|
await db('photos').whereIn('id', ids).del();
|
||||||
|
await db('event_feedback_settings').where({ event_id: eventId }).del();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['assignment removed', 'anonymized'])('rejects an existing customer grant after %s', async reason => {
|
||||||
|
const bearer = token({ via: 'customer', customerId });
|
||||||
|
await expectDirect(bearer, 200);
|
||||||
|
if (reason === 'assignment removed') await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).del();
|
||||||
|
else await require('../../src/services/customerAccountsService').eraseCustomer(customerId, adminId);
|
||||||
|
await expectDirect(bearer, reason === 'assignment removed' ? 403 : 401);
|
||||||
|
});
|
||||||
|
it('denies foreign editors and allows the editor who owns the gallery', async () => {
|
||||||
|
await assignAdminRole(db, foreignId, 'editor');
|
||||||
|
try {
|
||||||
|
await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1');
|
||||||
|
await db('events').where({ id: eventId }).update({ created_by: foreignId });
|
||||||
|
await expectDirect(mintAdminToken(foreignId), 200, '?admin_preview=1');
|
||||||
|
} finally {
|
||||||
|
await db('events').where({ id: eventId }).update({ created_by: adminId });
|
||||||
|
await assignAdminRole(db, foreignId, 'viewer');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('bounds a large gallery response while retaining the complete count', async () => {
|
||||||
|
const rows = Array.from({ length: 5000 }, (_, index) => ({ id: 80000 + index, event_id: eventId,
|
||||||
|
filename: `large-${index}.jpg`, path: 'unused', type: 'individual', processing_status: 'complete' }));
|
||||||
|
try {
|
||||||
|
await db.batchInsert('photos', rows, 100);
|
||||||
|
const response = await get(`${endpoints[0]}?limit=999999&page=1`, token());
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.photos).toHaveLength(250);
|
||||||
|
expect(response.body.pagination).toMatchObject({ total: 5001, limit: 250, has_more: true });
|
||||||
|
} finally { await db('photos').where('id', '>=', 80000).where({ event_id: eventId }).del(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Gallery tokens must carry a per-token `jti`. tokenRevocation falls back to
|
||||||
|
* `${eventId}-${iat}-gallery` without one, so a guest logging out would revoke
|
||||||
|
* every other guest whose token was minted for the same event in the same
|
||||||
|
* second (QR-code share links at an event make that routine).
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const mintSites = [
|
||||||
|
'src/routes/auth.js',
|
||||||
|
'src/routes/customer.js',
|
||||||
|
'src/routes/gallery/slideshow.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('gallery token mint sites', () => {
|
||||||
|
it.each(mintSites)('%s sets a unique jti on every gallery token', (file) => {
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '../../', file), 'utf8');
|
||||||
|
const payloads = source.split('jwt.sign(').slice(1)
|
||||||
|
.map((chunk) => chunk.split('process.env.JWT_SECRET')[0])
|
||||||
|
.filter((payload) => payload.includes("type: 'gallery'"));
|
||||||
|
expect(payloads.length).toBeGreaterThan(0);
|
||||||
|
for (const payload of payloads) expect(payload).toContain('jti: crypto.randomUUID()');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revocation key', () => {
|
||||||
|
beforeAll(() => { process.env.JWT_SECRET = process.env.JWT_SECRET || 'jti-regression-secret-at-least-32-characters-long'; });
|
||||||
|
it('is distinct for two same-second gallery logins of the same event', () => {
|
||||||
|
const { buildTokenId } = require('../../src/utils/tokenRevocation');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const iat = Math.floor(Date.now() / 1000);
|
||||||
|
const mint = () => jwt.decode(jwt.sign({ eventId: 7, type: 'gallery', iat, jti: crypto.randomUUID() }, process.env.JWT_SECRET));
|
||||||
|
expect(buildTokenId(mint())).not.toBe(buildTokenId(mint()));
|
||||||
|
// Without a jti the key collapses to eventId + login second.
|
||||||
|
const bare = jwt.decode(jwt.sign({ eventId: 7, type: 'gallery', iat }, process.env.JWT_SECRET));
|
||||||
|
expect(buildTokenId(bare)).toBe(buildTokenId({ ...bare }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -89,7 +89,8 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
|
|||||||
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
|
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
|
||||||
photoId,
|
photoId,
|
||||||
`gallery_public_${eventId}_${Date.now()}`,
|
`gallery_public_${eventId}_${Date.now()}`,
|
||||||
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
|
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600,
|
||||||
|
galleryAccess: require('../../src/services/galleryAccessService').grant({ id: eventId }, 'public') },
|
||||||
);
|
);
|
||||||
|
|
||||||
const view = (slug, photoId, token) => request(app)
|
const view = (slug, photoId, token) => request(app)
|
||||||
@@ -114,7 +115,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
|
|||||||
const token = mint(photoA, galleryA);
|
const token = mint(photoA, galleryA);
|
||||||
const res = await view('secimg-private-b', photoB, token);
|
const res = await view('secimg-private-b', photoB, token);
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.error).toMatch(/not valid for this photo/i);
|
expect(res.body.code).toBe('INVALID_GALLERY_GRANT');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
|
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
|
||||||
@@ -123,7 +124,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
|
|||||||
// check (sessionId gallery A != URL gallery B) must catch it.
|
// check (sessionId gallery A != URL gallery B) must catch it.
|
||||||
const res = await view('secimg-private-b', photoA, token);
|
const res = await view('secimg-private-b', photoA, token);
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.error).toMatch(/not valid for this gallery/i);
|
expect(res.body.code).toBe('INVALID_GALLERY_GRANT');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets a token read its own gallery + photo (binding passes)', async () => {
|
it('lets a token read its own gallery + photo (binding passes)', async () => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
jest.mock('axios', () => ({ post: jest.fn() }));
|
jest.mock('axios', () => ({ post: jest.fn() }));
|
||||||
jest.mock('../../src/utils/networkValidation', () => ({
|
jest.mock('../../src/utils/networkValidation', () => ({
|
||||||
validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok' })),
|
validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] })),
|
||||||
}));
|
}));
|
||||||
jest.mock('../../src/utils/logger', () => ({
|
jest.mock('../../src/utils/logger', () => ({
|
||||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||||
@@ -48,7 +48,7 @@ beforeEach(() => {
|
|||||||
process.env.EMAIL_WEBHOOK_SECRET = SECRET;
|
process.env.EMAIL_WEBHOOK_SECRET = SECRET;
|
||||||
transport.__testing.setAllowPrivateUrls(false);
|
transport.__testing.setAllowPrivateUrls(false);
|
||||||
transport.__testing.resetSecretWarning();
|
transport.__testing.resetSecretWarning();
|
||||||
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok' });
|
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] });
|
||||||
axios.post.mockResolvedValue({ status: 200, data: streamOf('') });
|
axios.post.mockResolvedValue({ status: 200, data: streamOf('') });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
jest.mock('../../src/utils/logger', () => ({ error: jest.fn() }));
|
||||||
|
const { scheduledTask } = require('../../src/services/scheduledTask');
|
||||||
|
beforeEach(() => jest.useFakeTimers());
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
it('starts once, skips overlap and drains the accepted run on stop', async () => {
|
||||||
|
let finish;
|
||||||
|
const work = jest.fn(() => new Promise(resolve => { finish = resolve; }));
|
||||||
|
const task = scheduledTask(work, { interval: 100 });
|
||||||
|
task.start(); task.start();
|
||||||
|
expect(jest.getTimerCount()).toBe(1);
|
||||||
|
await jest.advanceTimersByTimeAsync(300);
|
||||||
|
expect(work).toHaveBeenCalledTimes(1);
|
||||||
|
let stopped = false;
|
||||||
|
const stop = task.stop().then(() => { stopped = true; });
|
||||||
|
await Promise.resolve(); expect(stopped).toBe(false);
|
||||||
|
finish(); await stop; expect(stopped).toBe(true);
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
await jest.advanceTimersByTimeAsync(1000); expect(work).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('cancels a delayed first run and can restart cleanly', async () => {
|
||||||
|
const work = jest.fn(); const task = scheduledTask(work, { interval: 100, initialDelay: 10 });
|
||||||
|
task.start(); await task.stop(); await jest.advanceTimersByTimeAsync(200); expect(work).not.toHaveBeenCalled();
|
||||||
|
task.start(); await jest.advanceTimersByTimeAsync(10); expect(work).toHaveBeenCalledTimes(1);
|
||||||
|
await task.stop();
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||||
|
const secure = require('../../src/services/secureImageService');
|
||||||
|
beforeEach(() => { jest.useFakeTimers(); secure.dispose(); });
|
||||||
|
afterEach(() => { secure.dispose(); jest.useRealTimers(); });
|
||||||
|
it('owns one timer for many tokens and sweeps expired capabilities', () => {
|
||||||
|
for (let i = 0; i < 100; i++) secure.generateSecureToken(i, 'gallery_public_1_1', { expiresIn: 1 });
|
||||||
|
expect(jest.getTimerCount()).toBe(1); expect(secure.tokenCache.size).toBe(100);
|
||||||
|
jest.advanceTimersByTime(60000); expect(secure.tokenCache.size).toBe(0);
|
||||||
|
secure.dispose(); expect(jest.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
it('disposes all session/rate caches and restarts on demand', () => {
|
||||||
|
secure.generateSecureToken(1, 'session'); secure.sessionTokens.set('a', 'b'); secure.rateLimitCache.set('a', 'b');
|
||||||
|
secure.dispose(); expect(secure.sessionTokens.size + secure.rateLimitCache.size + secure.tokenCache.size).toBe(0);
|
||||||
|
secure.generateSecureToken(1, 'session'); expect(jest.getTimerCount()).toBe(1);
|
||||||
|
});
|
||||||
@@ -31,7 +31,7 @@ describe('resolvePhotoContentType', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('serving routes use the resolver', () => {
|
describe('serving routes use the resolver', () => {
|
||||||
const routes = ['gallery.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
|
const routes = ['gallery/media.js', 'gallery/downloads.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
|
||||||
it.each(routes)('%s sets no Content-Type from photo.mime_type directly', (name) => {
|
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');
|
const src = fs.readFileSync(path.join(__dirname, '../../src/routes', name), 'utf8');
|
||||||
expect(src).not.toMatch(/'Content-Type':\s*photo\.mime_type/);
|
expect(src).not.toMatch(/'Content-Type':\s*photo\.mime_type/);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
const dns = require('dns');
|
||||||
|
const http = require('http');
|
||||||
|
const axios = require('axios');
|
||||||
|
const { validateExternalUrlAsync } = require('../../src/utils/networkValidation');
|
||||||
|
const { pinnedRequestOptions } = require('../../src/utils/pinnedRequest');
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
it('never performs a second DNS lookup that could reach a private listener', async () => {
|
||||||
|
const received = jest.fn();
|
||||||
|
const server = http.createServer((req, res) => { received(); res.end('private'); });
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const url = `http://rebind.example:${server.address().port}/hook`;
|
||||||
|
const preflight = jest.spyOn(dns.promises, 'lookup').mockResolvedValue([{ address: '192.0.2.1', family: 4 }]);
|
||||||
|
const unsafeLookup = jest.spyOn(dns, 'lookup').mockImplementation((_host, opts, cb) => {
|
||||||
|
if (typeof opts === 'function') { cb = opts; opts = {}; }
|
||||||
|
cb(null, ...(opts.all ? [[{ address: '127.0.0.1', family: 4 }]] : ['127.0.0.1', 4]));
|
||||||
|
});
|
||||||
|
let options;
|
||||||
|
try {
|
||||||
|
const check = await validateExternalUrlAsync(url);
|
||||||
|
options = pinnedRequestOptions(check);
|
||||||
|
await expect(axios.post(url, 'private-data', { ...options, timeout: 200 })).rejects.toThrow();
|
||||||
|
expect(preflight).toHaveBeenCalledTimes(1);
|
||||||
|
expect(unsafeLookup).not.toHaveBeenCalled(); expect(received).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
options?.httpAgent.destroy(); options?.httpsAgent.destroy();
|
||||||
|
await new Promise(resolve => server.close(resolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('preserves the original Host and refuses redirects while using the pinned address', async () => {
|
||||||
|
const hosts = [];
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
hosts.push(req.headers.host); res.writeHead(302, { Location: 'http://localhost/private' }); res.end();
|
||||||
|
});
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const host = `pinned.example:${server.address().port}`;
|
||||||
|
// Test the transport in isolation: production checks reject this private IP.
|
||||||
|
const options = pinnedRequestOptions({ valid: true, hostname: 'pinned.example', addresses: [{ address: '127.0.0.1', family: 4 }] });
|
||||||
|
try {
|
||||||
|
const res = await axios.post(`http://${host}/hook`, 'body', { ...options, validateStatus: () => true });
|
||||||
|
expect(res.status).toBe(302); expect(hosts).toEqual([host]); expect(options.proxy).toBe(false);
|
||||||
|
} finally { options.httpAgent.destroy(); options.httpsAgent.destroy(); await new Promise(resolve => server.close(resolve)); }
|
||||||
|
});
|
||||||
|
it('fails closed for missing DNS results', () => {
|
||||||
|
expect(() => pinnedRequestOptions({ valid: true })).toThrow('validated destination');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves TLS SNI and certificate hostname verification for a pinned connection', async () => {
|
||||||
|
const fs = require('fs/promises');
|
||||||
|
const path = require('path');
|
||||||
|
const dir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'picpeak-tls-pin-'));
|
||||||
|
const key = path.join(dir, 'key.pem'), cert = path.join(dir, 'cert.pem');
|
||||||
|
require('child_process').execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes',
|
||||||
|
'-keyout', key, '-out', cert, '-days', '1', '-subj', '/CN=pinned.example',
|
||||||
|
'-addext', 'subjectAltName=DNS:pinned.example'], { stdio: 'ignore' });
|
||||||
|
const certificate = await fs.readFile(cert);
|
||||||
|
const seen = [];
|
||||||
|
const server = require('https').createServer({ key: await fs.readFile(key), cert: certificate }, (req, res) => {
|
||||||
|
seen.push({ host: req.headers.host, servername: req.socket.servername }); res.end('ok');
|
||||||
|
});
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const makeOptions = hostname => {
|
||||||
|
const options = pinnedRequestOptions({ valid: true, hostname, addresses: [{ address: '127.0.0.1', family: 4 }] });
|
||||||
|
options.httpsAgent.options.ca = certificate;
|
||||||
|
return options;
|
||||||
|
};
|
||||||
|
const allowed = makeOptions('pinned.example'), wrong = makeOptions('wrong.example');
|
||||||
|
try {
|
||||||
|
const host = `pinned.example:${server.address().port}`;
|
||||||
|
expect((await axios.post(`https://${host}/hook`, 'data', { ...allowed, timeout: 2000 })).status).toBe(200);
|
||||||
|
expect(seen).toEqual([{ host, servername: 'pinned.example' }]);
|
||||||
|
await expect(axios.post(`https://wrong.example:${server.address().port}/hook`, 'data', { ...wrong, timeout: 2000 }))
|
||||||
|
.rejects.toMatchObject({ code: 'ERR_TLS_CERT_ALTNAME_INVALID' });
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
for (const options of [allowed, wrong]) { options.httpAgent.destroy(); options.httpsAgent.destroy(); }
|
||||||
|
await new Promise(resolve => server.close(resolve));
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
|
jest.mock('../../src/utils/logger', () => ({ info: jest.fn() }));
|
||||||
|
const logger = require('../../src/utils/logger');
|
||||||
|
const middleware = require('../../src/middleware/apiRequestLogger');
|
||||||
|
const { requestLogPath } = require('../../src/utils/requestLogPath');
|
||||||
|
const marker = 'SECRET_TEST_CAPABILITY';
|
||||||
|
it.each([
|
||||||
|
`/api/gallery/g/photos?token=${marker}&password=${marker}`,
|
||||||
|
`/api/gallery/g/verify-token/${marker}`,
|
||||||
|
`/api/gallery/g/show/${marker}/state`,
|
||||||
|
`/api/images/g/photo/1/signed/${marker}`,
|
||||||
|
`/api/secure-images/g/secure/1/${marker}`,
|
||||||
|
`/api/secure-images/g/secure-download/1/${marker}`,
|
||||||
|
`/api/public/contracts/${marker}/sign`,
|
||||||
|
`/api/customer/auth/password-reset/${marker}`,
|
||||||
|
`/api/public/newsletter/unsubscribe/${marker}`,
|
||||||
|
])('does not log capabilities on request or response: %s', (originalUrl) => {
|
||||||
|
logger.info.mockClear();
|
||||||
|
const res = new EventEmitter(); res.statusCode = 200;
|
||||||
|
middleware({ originalUrl, method: 'GET' }, res, jest.fn());
|
||||||
|
res.emit('finish');
|
||||||
|
expect(logger.info).toHaveBeenCalledTimes(2);
|
||||||
|
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(marker);
|
||||||
|
});
|
||||||
|
it('retains useful non-secret routes and removes control characters', () => {
|
||||||
|
expect(requestLogPath('/api/admin/events/12?search=private')).toBe('/api/admin/events/12');
|
||||||
|
expect(requestLogPath('/api/admin/events\nforged')).not.toContain('\n');
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const http = require('http');
|
||||||
|
it('directs Supertest to the actual IPv6 listener instead of an unrelated IPv4 port', async () => {
|
||||||
|
jest.resetModules();
|
||||||
|
const request = require('supertest');
|
||||||
|
const server = http.createServer((_req, res) => { res.end('actual test listener'); });
|
||||||
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '::1', resolve); });
|
||||||
|
try {
|
||||||
|
const response = await request(server).get('/');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.text).toBe('actual test listener');
|
||||||
|
expect(response.request.url).toContain('://[::1]:');
|
||||||
|
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||||
|
});
|
||||||
@@ -1,3 +1,18 @@
|
|||||||
|
// Supertest 6 binds an IPv6 wildcard listener but hardcodes an IPv4 URL.
|
||||||
|
// macOS can allocate that IPv6 port while a different IPv4 service owns it.
|
||||||
|
// Address the listener's actual family so a test cannot reach that service.
|
||||||
|
jest.mock('supertest/lib/test', () => {
|
||||||
|
const Test = jest.requireActual('supertest/lib/test');
|
||||||
|
const serverAddress = Test.prototype.serverAddress;
|
||||||
|
Test.prototype.serverAddress = function(app, path) {
|
||||||
|
const url = serverAddress.call(this, app, path);
|
||||||
|
return app.address()?.family === 'IPv6'
|
||||||
|
? url.replace('://127.0.0.1:', '://[::1]:')
|
||||||
|
: url;
|
||||||
|
};
|
||||||
|
return Test;
|
||||||
|
});
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
process.env.JWT_SECRET = 'test-secret';
|
process.env.JWT_SECRET = 'test-secret';
|
||||||
@@ -8,3 +23,11 @@ beforeAll(() => {
|
|||||||
process.env.STORAGE_PATH = '/storage';
|
process.env.STORAGE_PATH = '/storage';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dispose resources loaded by this suite using the application's draining
|
||||||
|
// shutdown. Individual fixtures still own temporary files and other DB pools.
|
||||||
|
afterAll(async () => {
|
||||||
|
await require('./src/services/serviceShutdown').stopServices();
|
||||||
|
const loadedDb = require.cache[require.resolve('./src/database/db')];
|
||||||
|
if (typeof loadedDb?.exports.db?.destroy === 'function') await loadedDb.exports.db.destroy();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** Fresh installs and upgraded databases expose the same event timestamp. */
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
if (!await knex.schema.hasTable('events')) return;
|
||||||
|
if (!await knex.schema.hasColumn('events', 'updated_at')) {
|
||||||
|
await knex.schema.alterTable('events', table => {
|
||||||
|
table.timestamp('updated_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Do not replace existing modification times on a repeated migration.
|
||||||
|
await knex('events').whereNull('updated_at').update({ updated_at: knex.ref('created_at') });
|
||||||
|
};
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
if (await knex.schema.hasTable('events') && await knex.schema.hasColumn('events', 'updated_at')) {
|
||||||
|
await knex.schema.alterTable('events', table => table.dropColumn('updated_at'));
|
||||||
|
}
|
||||||
|
};
|
||||||
+57
-46
@@ -218,7 +218,7 @@ app.use((req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// CORS configuration (apply only to API routes)
|
// CORS configuration (apply only to API routes)
|
||||||
const { isAllowedOrigin, multipartOriginAllowed } = require('./src/utils/requestOrigin');
|
const { isAllowedOrigin } = require('./src/utils/requestOrigin');
|
||||||
|
|
||||||
const corsOptions = {
|
const corsOptions = {
|
||||||
origin: function (origin, callback) {
|
origin: function (origin, callback) {
|
||||||
@@ -528,43 +528,10 @@ app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' }));
|
|||||||
app.use(express.json({ limit: '2mb' }));
|
app.use(express.json({ limit: '2mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||||||
|
|
||||||
// CSRF protection: require JSON Content-Type on mutating API requests
|
// Validate the origin independently of body length/content type.
|
||||||
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
|
app.use('/api', require('./src/middleware/csrf'));
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Request logging for API routes (with timestamps)
|
app.use('/api', require('./src/middleware/apiRequestLogger'));
|
||||||
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
|
// Maintenance mode middleware - add after body parsing but before routes
|
||||||
app.use(maintenanceMiddleware);
|
app.use(maintenanceMiddleware);
|
||||||
@@ -1098,6 +1065,30 @@ if (spaCatchAll) {
|
|||||||
// Global error handler (must be last)
|
// Global error handler (must be last)
|
||||||
app.use(errorHandler);
|
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
|
// Initialize services
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
try {
|
try {
|
||||||
@@ -1122,13 +1113,7 @@ async function startServer() {
|
|||||||
const { initializeCleanupJob } = require('./src/utils/authSecurity');
|
const { initializeCleanupJob } = require('./src/utils/authSecurity');
|
||||||
initializeCleanupJob();
|
initializeCleanupJob();
|
||||||
|
|
||||||
// Initialize temp upload cleanup job
|
require('./src/utils/cleanupTempUploads').startTempUploadCleanup();
|
||||||
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
|
// Start file watcher
|
||||||
startFileWatcher();
|
startFileWatcher();
|
||||||
@@ -1149,6 +1134,10 @@ async function startServer() {
|
|||||||
startTransferCleanup();
|
startTransferCleanup();
|
||||||
// Custom-resolution download archives (#858) are disposable renditions —
|
// Custom-resolution download archives (#858) are disposable renditions —
|
||||||
// sweep them once their TTL passes so .download-cache doesn't grow forever.
|
// 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();
|
startDownloadJobCleanup();
|
||||||
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
|
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
|
||||||
startRevealScheduler();
|
startRevealScheduler();
|
||||||
@@ -1306,7 +1295,7 @@ async function startServer() {
|
|||||||
// lazy means they don't pay for a module graph they never use.
|
// lazy means they don't pay for a module graph they never use.
|
||||||
require('./src/services/faceQueue').start();
|
require('./src/services/faceQueue').start();
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
httpServer = app.listen(PORT, () => {
|
||||||
logger.info(`Server running on port ${PORT}`);
|
logger.info(`Server running on port ${PORT}`);
|
||||||
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
|
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
|
||||||
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
|
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
|
||||||
@@ -1325,10 +1314,32 @@ async function startServer() {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to start server:', error);
|
logger.error('Failed to start server:', error);
|
||||||
|
await stopServer();
|
||||||
|
process.exitCode = 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);
|
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();
|
startServer();
|
||||||
|
}
|
||||||
|
app.startServer = startServer;
|
||||||
|
app.stopServer = stopServer;
|
||||||
|
|
||||||
module.exports = app; // For testing
|
module.exports = app; // For testing
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ jest.mock('../database/db', () => {
|
|||||||
return { db: mockDb, withRetry };
|
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', () => ({
|
jest.mock('../utils/logger', () => ({
|
||||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -83,8 +85,12 @@ function mockEventAndAssignment({ event, assignment }) {
|
|||||||
assignChain.where = jest.fn().mockReturnValue(assignChain);
|
assignChain.where = jest.fn().mockReturnValue(assignChain);
|
||||||
assignChain.first = jest.fn().mockResolvedValue(assignment);
|
assignChain.first = jest.fn().mockResolvedValue(assignment);
|
||||||
|
|
||||||
db.mockImplementationOnce(() => eventsChain)
|
db.mockImplementation((table) => {
|
||||||
.mockImplementationOnce(() => assignChain);
|
if (table === 'events') return eventsChain;
|
||||||
|
if (table === 'customer_accounts') return { ...eventsChain, first: jest.fn().mockResolvedValue({ id: 7 }) };
|
||||||
|
if (table === 'event_customer_assignments') return assignChain;
|
||||||
|
throw new Error('Unexpected table: ' + table);
|
||||||
|
});
|
||||||
|
|
||||||
return { eventsChain, assignChain };
|
return { eventsChain, assignChain };
|
||||||
}
|
}
|
||||||
@@ -101,7 +107,7 @@ describe('verifyGalleryAccess — customer-minted JWT with active assignment', (
|
|||||||
it('allows access when the event_customer_assignments row exists', async () => {
|
it('allows access when the event_customer_assignments row exists', async () => {
|
||||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||||
jwt.verify.mockReturnValue({
|
jwt.verify.mockReturnValue({
|
||||||
type: 'gallery',
|
type: 'gallery', iat: Math.floor(Date.now() / 1000),
|
||||||
eventId: 42,
|
eventId: 42,
|
||||||
via: 'customer',
|
via: 'customer',
|
||||||
customerId: 7,
|
customerId: 7,
|
||||||
@@ -132,7 +138,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
|
|||||||
it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => {
|
it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => {
|
||||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||||
jwt.verify.mockReturnValue({
|
jwt.verify.mockReturnValue({
|
||||||
type: 'gallery',
|
type: 'gallery', iat: Math.floor(Date.now() / 1000),
|
||||||
eventId: 42,
|
eventId: 42,
|
||||||
via: 'customer',
|
via: 'customer',
|
||||||
customerId: 7,
|
customerId: 7,
|
||||||
@@ -162,7 +168,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
|
|||||||
// and start 403'ing per-event-password sessions.
|
// and start 403'ing per-event-password sessions.
|
||||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||||
jwt.verify.mockReturnValue({
|
jwt.verify.mockReturnValue({
|
||||||
type: 'gallery',
|
type: 'gallery', iat: Math.floor(Date.now() / 1000),
|
||||||
eventId: 42,
|
eventId: 42,
|
||||||
customerId: 7,
|
customerId: 7,
|
||||||
// intentionally no `via` claim
|
// intentionally no `via` claim
|
||||||
@@ -194,7 +200,7 @@ describe('verifyGalleryAccess — per-event-password JWT', () => {
|
|||||||
it('does NOT touch event_customer_assignments and passes through', async () => {
|
it('does NOT touch event_customer_assignments and passes through', async () => {
|
||||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||||
jwt.verify.mockReturnValue({
|
jwt.verify.mockReturnValue({
|
||||||
type: 'gallery',
|
type: 'gallery', iat: Math.floor(Date.now() / 1000),
|
||||||
eventId: 42,
|
eventId: 42,
|
||||||
// No via, no customerId — this is the legacy per-event-password
|
// No via, no customerId — this is the legacy per-event-password
|
||||||
// flow where every guest mints their own JWT after entering the
|
// flow where every guest mints their own JWT after entering the
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { requestLogPath } = require('../utils/requestLogPath');
|
||||||
|
module.exports = function apiRequestLogger(req, res, next) {
|
||||||
|
const started = Date.now();
|
||||||
|
const path = requestLogPath(req.originalUrl);
|
||||||
|
logger.info(`${req.method} ${path}`);
|
||||||
|
res.once('finish', () => {
|
||||||
|
logger.info(`${req.method} ${path} -> ${res.statusCode} (${Date.now() - started}ms)`);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
};
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const sessionAccess = require('../services/sessionAccessService');
|
||||||
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 logger = require('../utils/logger');
|
||||||
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
|
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
|
|
||||||
@@ -32,100 +28,10 @@ async function adminAuth(req, res, next) {
|
|||||||
return res.status(401).json({ error: 'Invalid token' });
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is revoked
|
const admin = await sessionAccess.admin(decoded);
|
||||||
if (await isTokenRevoked(decoded)) {
|
const requestIp = req.ip || req.connection?.remoteAddress;
|
||||||
logger.warn('Revoked token used', {
|
if (decoded.ip && requestIp && decoded.ip !== requestIp) {
|
||||||
userId: decoded.id,
|
logger.info('admin session IP changed', { accountId: admin.id, tokenIp: decoded.ip, requestIp });
|
||||||
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)
|
// Add user info to request (enhanced with role)
|
||||||
@@ -146,7 +52,10 @@ async function adminAuth(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Auth middleware error:', error);
|
logger.error('Auth middleware error:', error);
|
||||||
res.status(401).json({ error: 'Authentication failed' });
|
res.status(error.statusCode || 401).json({
|
||||||
|
error: error.isOperational ? error.message : 'Authentication failed',
|
||||||
|
...(error.isOperational && { code: error.code }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const { mutationOriginAllowed } = require('../utils/requestOrigin');
|
||||||
|
|
||||||
|
module.exports = function csrfProtection(req, res, next) {
|
||||||
|
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return next();
|
||||||
|
if (!mutationOriginAllowed(req)) {
|
||||||
|
return res.status(403).json({ error: 'Cross-site request rejected' });
|
||||||
|
}
|
||||||
|
const contentType = (req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
||||||
|
const hasBody = Number(req.headers['content-length']) > 0 || !!req.headers['transfer-encoding'];
|
||||||
|
const jsonLike = contentType === 'application/json' || contentType.endsWith('+json');
|
||||||
|
if (hasBody && !jsonLike && contentType !== 'multipart/form-data') {
|
||||||
|
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { requestLogPath } = require('../utils/requestLogPath');
|
||||||
/**
|
/**
|
||||||
* Customer Authentication Middleware
|
* Customer Authentication Middleware
|
||||||
*
|
*
|
||||||
@@ -10,10 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const sessionAccess = require('../services/sessionAccessService');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
|
||||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
|
||||||
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
|
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
|
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
|
|
||||||
@@ -25,7 +23,7 @@ async function customerAuth(req, res, next) {
|
|||||||
// normal (page polling, pre-login session probes). Bump to debug
|
// normal (page polling, pre-login session probes). Bump to debug
|
||||||
// for noisy investigations only.
|
// for noisy investigations only.
|
||||||
logger.debug('[customerAuth] no token on request', {
|
logger.debug('[customerAuth] no token on request', {
|
||||||
url: req.originalUrl,
|
url: requestLogPath(req.originalUrl),
|
||||||
hasCookieHeader: !!req.headers?.cookie,
|
hasCookieHeader: !!req.headers?.cookie,
|
||||||
cookieKeys: Object.keys(req.cookies || {}),
|
cookieKeys: Object.keys(req.cookies || {}),
|
||||||
});
|
});
|
||||||
@@ -42,7 +40,7 @@ async function customerAuth(req, res, next) {
|
|||||||
decoded = verified.payload;
|
decoded = verified.payload;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('[customerAuth] jwt verification failed', {
|
logger.warn('[customerAuth] jwt verification failed', {
|
||||||
url: req.originalUrl,
|
url: requestLogPath(req.originalUrl),
|
||||||
errorName: err.name,
|
errorName: err.name,
|
||||||
errorMessage: err.message,
|
errorMessage: err.message,
|
||||||
});
|
});
|
||||||
@@ -52,71 +50,10 @@ async function customerAuth(req, res, next) {
|
|||||||
return res.status(401).json({ error: 'Invalid token', code: 'JWT_INVALID' });
|
return res.status(401).json({ error: 'Invalid token', code: 'JWT_INVALID' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await isTokenRevoked(decoded)) {
|
const customer = await sessionAccess.customer(decoded);
|
||||||
logger.warn('[customerAuth] token revoked', {
|
const requestIp = req.ip || req.connection?.remoteAddress;
|
||||||
url: req.originalUrl,
|
if (decoded.ip && requestIp && decoded.ip !== requestIp) {
|
||||||
customerId: decoded.customerId,
|
logger.info('customer session IP changed', { accountId: customer.id, tokenIp: decoded.ip, requestIp });
|
||||||
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 = {
|
req.customer = {
|
||||||
@@ -131,7 +68,10 @@ async function customerAuth(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Customer auth middleware error:', error);
|
logger.error('Customer auth middleware error:', error);
|
||||||
res.status(401).json({ error: 'Authentication failed' });
|
res.status(error.statusCode || 401).json({
|
||||||
|
error: error.isOperational ? error.message : 'Authentication failed',
|
||||||
|
...(error.isOperational && { code: error.code }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { requestLogPath } = require('../utils/requestLogPath');
|
||||||
/**
|
/**
|
||||||
* Global error handler middleware.
|
* Global error handler middleware.
|
||||||
* Catches all errors and returns standardized responses.
|
* Catches all errors and returns standardized responses.
|
||||||
@@ -119,7 +120,7 @@ const errorHandler = (err, req, res, next) => {
|
|||||||
|
|
||||||
// Log the error
|
// Log the error
|
||||||
const logContext = {
|
const logContext = {
|
||||||
url: req.originalUrl,
|
url: requestLogPath(req.originalUrl),
|
||||||
method: req.method,
|
method: req.method,
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
statusCode,
|
statusCode,
|
||||||
@@ -161,7 +162,7 @@ const errorHandler = (err, req, res, next) => {
|
|||||||
*/
|
*/
|
||||||
const notFoundHandler = (req, res, next) => {
|
const notFoundHandler = (req, res, next) => {
|
||||||
const { NotFoundError } = require('../utils/errors');
|
const { NotFoundError } = require('../utils/errors');
|
||||||
next(new NotFoundError('Route', req.originalUrl));
|
next(new NotFoundError('Route', requestLogPath(req.originalUrl)));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const cleanupTimers = new Set();
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -211,7 +212,7 @@ function strictRateLimit(options = {}) {
|
|||||||
const store = new Map();
|
const store = new Map();
|
||||||
|
|
||||||
// Clean up old entries periodically
|
// Clean up old entries periodically
|
||||||
setInterval(() => {
|
const cleanupTimer = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [key, data] of store.entries()) {
|
for (const [key, data] of store.entries()) {
|
||||||
if (data.resetTime < now) {
|
if (data.resetTime < now) {
|
||||||
@@ -219,6 +220,8 @@ function strictRateLimit(options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, windowMs);
|
}, windowMs);
|
||||||
|
cleanupTimer.unref();
|
||||||
|
cleanupTimers.add(cleanupTimer);
|
||||||
|
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const ip = req.ip || req.connection.remoteAddress;
|
const ip = req.ip || req.connection.remoteAddress;
|
||||||
@@ -255,6 +258,7 @@ function strictRateLimit(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
dispose() { cleanupTimers.forEach(clearInterval); cleanupTimers.clear(); },
|
||||||
feedbackRateLimit,
|
feedbackRateLimit,
|
||||||
strictRateLimit,
|
strictRateLimit,
|
||||||
generateGuestIdentifier,
|
generateGuestIdentifier,
|
||||||
|
|||||||
@@ -1,267 +1,104 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db, withRetry } = require('../database/db');
|
const { db, withRetry } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
|
||||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
const access = require('../services/galleryAccessService');
|
||||||
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) {
|
function decodeAdminPreview(req) {
|
||||||
if (req.query?.admin_preview !== '1') return null;
|
if (req.query?.admin_preview !== '1') return null;
|
||||||
// Cookie first, then a Bearer — but only an admin-typed token satisfies it.
|
const candidates = [req.cookies?.admin_token];
|
||||||
const candidates = [];
|
|
||||||
if (req.cookies?.admin_token) candidates.push(req.cookies.admin_token);
|
|
||||||
const header = req.headers?.authorization;
|
const header = req.headers?.authorization;
|
||||||
if (header && header.startsWith('Bearer ')) candidates.push(header.slice(7));
|
if (header?.startsWith('Bearer ')) candidates.push(header.slice(7));
|
||||||
for (const token of candidates) {
|
for (const token of candidates.filter(Boolean)) {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||||
|
issuer: 'picpeak-auth', algorithms: ['HS256'],
|
||||||
|
});
|
||||||
if (decoded.type === 'admin') return decoded;
|
if (decoded.type === 'admin') return decoded;
|
||||||
} catch { /* try the next candidate */ }
|
} catch { /* try the next candidate */ }
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signature-only predicate retained for UI-intent callers. It never authorizes.
|
||||||
function isAdminPreview(req) {
|
function isAdminPreview(req) {
|
||||||
return decodeAdminPreview(req) !== null;
|
return decodeAdminPreview(req) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function attachAccess(req, event, grant) {
|
||||||
* The full session check behind the preview bypass. A verified signature is
|
req.event = event;
|
||||||
* not a live session: adminAuth also rejects revoked tokens, tokens issued
|
req.galleryAccess = grant;
|
||||||
* before the restore cutoff, deactivated admins and tokens minted before the
|
req.isAdminPreview = grant.kind === 'admin';
|
||||||
* admin's last password change. Without those a logged-out or deactivated
|
req.accessLevel = grant.session?.accessLevel || 'guest';
|
||||||
* admin token kept unlocking every draft and password gallery until `exp`
|
req.viaCustomer = grant.session?.via === 'customer';
|
||||||
* (30 days with remember-me). Sets req.isAdminPreview on success so the
|
req.sessionID = req.isAdminPreview ? `gallery_admin_preview_${event.id}`
|
||||||
* downstream reveal-mode and logging checks read one verified flag.
|
: `gallery_${grant.kind === 'public' ? 'public_' : ''}${event.id}_${Date.now()}`;
|
||||||
*/
|
const ip = req.ip || req.connection?.remoteAddress || 'unknown';
|
||||||
async function verifyAdminPreview(req) {
|
const userAgent = req.get?.('User-Agent') || 'unknown';
|
||||||
if (req.isAdminPreview === true) return true;
|
req.clientInfo = {
|
||||||
|
ip, userAgent, fingerprint: `${ip}-${userAgent}`.substring(0, 32), timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAdminPreview(req, event) {
|
||||||
|
if (req.isAdminPreview && req.galleryAccess && (!event || event.id === req.event?.id)) return true;
|
||||||
const decoded = decodeAdminPreview(req);
|
const decoded = decodeAdminPreview(req);
|
||||||
if (!decoded) return false;
|
if (!decoded) return false;
|
||||||
try {
|
try {
|
||||||
if (await isTokenRevoked(decoded) || await isTokenBeforeCutoff(decoded)) return false;
|
const slug = req.params?.slug || req.requestedSlug;
|
||||||
const admin = await withRetry(async () => db('admin_users')
|
if (!event && !slug) return false;
|
||||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
event = event || await db('events').where({ slug }).select('*').first();
|
||||||
.select('id', 'password_changed_at')
|
if (!event) return false;
|
||||||
.first());
|
const grant = access.grant(event, 'admin', decoded);
|
||||||
if (!admin) return false;
|
await access.authorize(event, grant);
|
||||||
if (admin.password_changed_at) {
|
attachAccess(req, event, grant);
|
||||||
const changedSeconds = Math.floor(new Date(admin.password_changed_at).getTime() / 1000);
|
return true;
|
||||||
if (decoded.iat < changedSeconds) return false;
|
} catch (error) {
|
||||||
}
|
logger.debug('Admin gallery preview denied', { code: error.code });
|
||||||
} catch (err) {
|
req.adminPreviewDenied = error;
|
||||||
logger.warn('Admin preview session check failed', { error: err.message });
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
req.isAdminPreview = true;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware to verify gallery access
|
function decodeGalleryToken(token) {
|
||||||
async function verifyGalleryAccess(req, res, next) {
|
|
||||||
try {
|
try {
|
||||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'], issuer: 'picpeak-auth' });
|
||||||
|
|
||||||
// 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) {
|
} catch (error) {
|
||||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
// 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')) {
|
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
|
||||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
} else {
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
|
|
||||||
|
|
||||||
// Only gallery-scoped tokens grant gallery access. Every legitimate
|
async function verifyGalleryAccess(req, res, next) {
|
||||||
// path (password login, share link, client access, customer-minted,
|
try {
|
||||||
// slideshow) mints type:'gallery'. Reject anything else — e.g. a guest
|
if (await verifyAdminPreview(req)) return next();
|
||||||
// identity token (type:'guest', for feedback attribution) that carries a
|
// The caller asked for a preview explicitly: report why it was refused
|
||||||
// matching eventId — instead of relying on other token types incidentally
|
// instead of falling through to a misleading guest-token error.
|
||||||
// lacking an eventId to fail the id match below.
|
if (req.adminPreviewDenied?.isOperational) throw req.adminPreviewDenied;
|
||||||
if (decoded.type !== 'gallery') {
|
const slug = req.params.slug || req.requestedSlug;
|
||||||
|
const token = getGalleryTokenFromRequest(req, slug);
|
||||||
|
const decoded = token ? decodeGalleryToken(token) : null;
|
||||||
|
if (decoded && decoded.type !== 'gallery') {
|
||||||
return res.status(403).json({ error: 'Invalid token type for gallery access' });
|
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' });
|
||||||
// If we have a slug in the URL params or from pre-middleware, verify it matches.
|
const event = await withRetry(() => db('events').where(slug ? { slug } : { id: decoded.eventId }).select('*').first());
|
||||||
// (Admin preview never reaches here — it returns above — so drafts stay
|
if (!event) return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
// filtered for every real gallery-token request.)
|
const grant = access.grant(event, decoded ? 'gallery' : 'public', decoded);
|
||||||
if (requestedSlug) {
|
await access.authorize(event, grant);
|
||||||
// Verify by slug and ensure it matches the token's event
|
attachAccess(req, event, grant);
|
||||||
event = await withRetry(async () => db('events')
|
return next();
|
||||||
.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) {
|
} catch (error) {
|
||||||
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
|
if (!error.isOperational) logger.error('Error verifying gallery access', { error: error.message });
|
||||||
res.status(401).json({ error: 'Invalid token' });
|
return res.status(error.statusCode || 401).json({
|
||||||
|
error: error.isOperational ? error.message : 'Invalid token',
|
||||||
|
...(error.code && error.isOperational && { code: error.code }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const logger = require('../utils/logger');
|
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.
|
* Middleware to enforce event ownership for non-super_admin users.
|
||||||
* Super admins bypass the check. Other admins can only access events they created.
|
* Super admins bypass the check. Other admins can only access events they created.
|
||||||
@@ -23,7 +28,7 @@ function requireEventOwnership(req, res, next) {
|
|||||||
return res.status(404).json({ error: 'Event not found' });
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
}
|
}
|
||||||
// Allow access if: event has no owner (legacy/system), or admin owns it
|
// Allow access if: event has no owner (legacy/system), or admin owns it
|
||||||
if (event.created_by && event.created_by !== req.admin.id) {
|
if (!canAccessEvent(req.admin, event)) {
|
||||||
return res.status(403).json({ error: 'Access denied' });
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
@@ -165,6 +170,7 @@ function requireProjectOwnership(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
canAccessEvent,
|
||||||
requireEventOwnership,
|
requireEventOwnership,
|
||||||
filterOwnedEventIds,
|
filterOwnedEventIds,
|
||||||
scopeEventsQuery,
|
scopeEventsQuery,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { requestLogPath } = require('../utils/requestLogPath');
|
||||||
/**
|
/**
|
||||||
* Permission Checking Middleware for RBAC
|
* Permission Checking Middleware for RBAC
|
||||||
* Provides role-based access control with caching for performance
|
* Provides role-based access control with caching for performance
|
||||||
@@ -143,7 +144,7 @@ function requirePermission(permissions, options = { requireAll: false }) {
|
|||||||
userId: req.admin.id,
|
userId: req.admin.id,
|
||||||
username: req.admin.username,
|
username: req.admin.username,
|
||||||
requiredPermissions: permArray,
|
requiredPermissions: permArray,
|
||||||
path: req.path,
|
path: requestLogPath(req.originalUrl || req.path),
|
||||||
method: req.method
|
method: req.method
|
||||||
});
|
});
|
||||||
throw new ForbiddenError('Insufficient permissions');
|
throw new ForbiddenError('Insufficient permissions');
|
||||||
@@ -180,7 +181,7 @@ function requireSuperAdmin() {
|
|||||||
logger.warn('Super admin access denied', {
|
logger.warn('Super admin access denied', {
|
||||||
userId: req.admin.id,
|
userId: req.admin.id,
|
||||||
username: req.admin.username,
|
username: req.admin.username,
|
||||||
path: req.path,
|
path: requestLogPath(req.originalUrl || req.path),
|
||||||
method: req.method
|
method: req.method
|
||||||
});
|
});
|
||||||
throw new ForbiddenError('Super Admin access required');
|
throw new ForbiddenError('Super Admin access required');
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { requestLogPath } = require('../utils/requestLogPath');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const secureImageService = require('../services/secureImageService');
|
const secureImageService = require('../services/secureImageService');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -10,12 +11,24 @@ class SecureImageMiddleware {
|
|||||||
this.suspiciousIPs = new Set();
|
this.suspiciousIPs = new Set();
|
||||||
this.blockedFingerprints = new Set();
|
this.blockedFingerprints = new Set();
|
||||||
this.rateLimitViolations = new Map();
|
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
|
* Main security middleware for image access
|
||||||
*/
|
*/
|
||||||
secureImageAccess = async (req, res, next) => {
|
secureImageAccess = async (req, res, next) => {
|
||||||
|
this.start();
|
||||||
try {
|
try {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const clientIP = this.getClientIP(req);
|
const clientIP = this.getClientIP(req);
|
||||||
@@ -56,7 +69,7 @@ class SecureImageMiddleware {
|
|||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
path: req.path
|
path: requestLogPath(req.originalUrl || req.path)
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -328,7 +341,7 @@ class SecureImageMiddleware {
|
|||||||
client_ip: req.clientInfo?.ip || req.ip,
|
client_ip: req.clientInfo?.ip || req.ip,
|
||||||
client_fingerprint: req.clientInfo?.fingerprint,
|
client_fingerprint: req.clientInfo?.fingerprint,
|
||||||
user_agent: req.get('User-Agent')?.substring(0, 255),
|
user_agent: req.get('User-Agent')?.substring(0, 255),
|
||||||
request_path: req.path,
|
request_path: requestLogPath(req.originalUrl || req.path),
|
||||||
request_method: req.method,
|
request_method: req.method,
|
||||||
details: JSON.stringify(details),
|
details: JSON.stringify(details),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
@@ -409,9 +422,4 @@ class SecureImageMiddleware {
|
|||||||
// Create singleton instance
|
// Create singleton instance
|
||||||
const secureImageMiddleware = new SecureImageMiddleware();
|
const secureImageMiddleware = new SecureImageMiddleware();
|
||||||
|
|
||||||
// Setup cleanup interval
|
|
||||||
setInterval(() => {
|
|
||||||
secureImageMiddleware.cleanup();
|
|
||||||
}, 300000); // Every 5 minutes
|
|
||||||
|
|
||||||
module.exports = secureImageMiddleware;
|
module.exports = secureImageMiddleware;
|
||||||
@@ -26,7 +26,7 @@ const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries
|
|||||||
// behaviour is unchanged: the timer fires every 5 min as long as
|
// behaviour is unchanged: the timer fires every 5 min as long as
|
||||||
// the server has anything else keeping the loop alive (HTTP server,
|
// the server has anything else keeping the loop alive (HTTP server,
|
||||||
// other intervals), which is always.
|
// other intervals), which is always.
|
||||||
setInterval(() => {
|
const cleanupTimer = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [token, lastActivity] of sessions.entries()) {
|
for (const [token, lastActivity] of sessions.entries()) {
|
||||||
if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) {
|
if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) {
|
||||||
@@ -208,6 +208,7 @@ function getActiveSessions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
dispose: () => { clearInterval(cleanupTimer); sessions.clear(); cachedTimeout = null; cacheExpiry = 0; },
|
||||||
sessionTimeoutMiddleware,
|
sessionTimeoutMiddleware,
|
||||||
isSessionExpired,
|
isSessionExpired,
|
||||||
endSession,
|
endSession,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../../database/db');
|
const { db, logActivity } = require('../../database/db');
|
||||||
const { formatBoolean } = require('../../utils/dbCompat');
|
const { formatBoolean } = require('../../utils/dbCompat');
|
||||||
const { slugify } = require('../../utils/slug');
|
|
||||||
const { adminAuth } = require('../../middleware/auth');
|
const { adminAuth } = require('../../middleware/auth');
|
||||||
const { requirePermission, userHasAllPermissions } = require('../../middleware/permissions');
|
const { requirePermission, userHasAllPermissions } = require('../../middleware/permissions');
|
||||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../../utils/emailNormalization');
|
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../../utils/emailNormalization');
|
||||||
@@ -25,14 +25,13 @@ const eventTypeService = require('../../services/eventTypeService');
|
|||||||
const { normaliseEventTimeTriple } = require('../../services/eventService');
|
const { normaliseEventTimeTriple } = require('../../services/eventService');
|
||||||
const { hasColumnCached } = require('../../utils/schemaCache');
|
const { hasColumnCached } = require('../../utils/schemaCache');
|
||||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||||
const { getAppSetting } = require('../../utils/appSettings');
|
|
||||||
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
|
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
|
||||||
const { clampIntOrUndefined } = require('../../utils/numericHelpers');
|
|
||||||
const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl');
|
const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl');
|
||||||
const downloadZipService = require('../../services/downloadZipService');
|
const downloadZipService = require('../../services/downloadZipService');
|
||||||
const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults');
|
const { KEYBIND_MODES } = require('../../services/feedbackDefaults');
|
||||||
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults,
|
const { validateHeroImageAnchor, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade } = require('./helpers');
|
||||||
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
|
* `events.slug` is UNIQUE, and both routes that mint one do a read-then-insert
|
||||||
@@ -174,7 +173,6 @@ async function queueGalleryCreatedEmail(event, { password, requirePassword } = {
|
|||||||
|
|
||||||
module.exports = (router) => {
|
module.exports = (router) => {
|
||||||
|
|
||||||
|
|
||||||
// Create new event
|
// Create new event
|
||||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||||
body('event_type').notEmpty().trim().custom(async (value) => {
|
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||||
@@ -240,7 +238,6 @@ module.exports = (router) => {
|
|||||||
body('watermark_text').optional().trim(),
|
body('watermark_text').optional().trim(),
|
||||||
// #328 follow-up: per-event opt-in for presigned-URL "Download All".
|
// #328 follow-up: per-event opt-in for presigned-URL "Download All".
|
||||||
// Bypasses watermarks; admin must enable knowingly.
|
// Bypasses watermarks; admin must enable knowingly.
|
||||||
body('allow_presigned_download').optional().isBoolean(),
|
|
||||||
// Feedback sub-toggles (#1044). Optional: omitting them inherits the
|
// Feedback sub-toggles (#1044). Optional: omitting them inherits the
|
||||||
// global Settings > Events defaults.
|
// global Settings > Events defaults.
|
||||||
body('allow_ratings').optional().isBoolean(),
|
body('allow_ratings').optional().isBoolean(),
|
||||||
@@ -298,571 +295,13 @@ module.exports = (router) => {
|
|||||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get field requirements from settings
|
const created = await require('../../services/eventCreationService').createEvent(req.body, {
|
||||||
const fieldRequirements = await getEventFieldRequirements();
|
actor: req.admin,
|
||||||
|
frontendUrl: await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL }),
|
||||||
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) {
|
} catch (error) {
|
||||||
|
if (error.isOperational) return res.status(error.statusCode).json(error.responseBody || { error: error.message, code: error.code });
|
||||||
if (isDuplicateSlugError(error)) {
|
if (isDuplicateSlugError(error)) {
|
||||||
logger.warn('Event creation lost the slug race', { error: error.message });
|
logger.warn('Event creation lost the slug race', { error: error.message });
|
||||||
return res.status(409).json(DUPLICATE_SLUG_RESPONSE);
|
return res.status(409).json(DUPLICATE_SLUG_RESPONSE);
|
||||||
@@ -1425,6 +864,7 @@ module.exports = (router) => {
|
|||||||
share_token: shareToken,
|
share_token: shareToken,
|
||||||
expires_at: newExpiresAt ? newExpiresAt.toISOString() : null,
|
expires_at: newExpiresAt ? newExpiresAt.toISOString() : null,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
created_by: req.admin.id,
|
created_by: req.admin.id,
|
||||||
allow_user_uploads: source.allow_user_uploads,
|
allow_user_uploads: source.allow_user_uploads,
|
||||||
upload_category_id: source.upload_category_id,
|
upload_category_id: source.upload_category_id,
|
||||||
@@ -1441,7 +881,6 @@ module.exports = (router) => {
|
|||||||
use_canvas_rendering: source.use_canvas_rendering,
|
use_canvas_rendering: source.use_canvas_rendering,
|
||||||
watermark_downloads: source.watermark_downloads,
|
watermark_downloads: source.watermark_downloads,
|
||||||
watermark_text: source.watermark_text,
|
watermark_text: source.watermark_text,
|
||||||
allow_presigned_download: source.allow_presigned_download,
|
|
||||||
require_password: source.require_password,
|
require_password: source.require_password,
|
||||||
css_template_id: source.css_template_id || null,
|
css_template_id: source.css_template_id || null,
|
||||||
hero_logo_visible: source.hero_logo_visible,
|
hero_logo_visible: source.hero_logo_visible,
|
||||||
@@ -1598,7 +1037,6 @@ module.exports = (router) => {
|
|||||||
body('disable_right_click').optional().isBoolean(),
|
body('disable_right_click').optional().isBoolean(),
|
||||||
body('watermark_downloads').optional().isBoolean(),
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
body('watermark_text').optional().trim(),
|
body('watermark_text').optional().trim(),
|
||||||
body('allow_presigned_download').optional().isBoolean(),
|
|
||||||
body('source_mode').optional().isIn(['managed', 'reference']),
|
body('source_mode').optional().isIn(['managed', 'reference']),
|
||||||
body('external_path').optional({ nullable: true }).isString().trim(),
|
body('external_path').optional({ nullable: true }).isString().trim(),
|
||||||
body('external_watch').optional().isBoolean(),
|
body('external_watch').optional().isBoolean(),
|
||||||
@@ -2029,8 +1467,6 @@ module.exports = (router) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Sync header_style / hero_divider_style from color_theme JSON when not
|
// Sync header_style / hero_divider_style from color_theme JSON when not
|
||||||
// explicitly provided in the request body (#158). This ensures the
|
// explicitly provided in the request body (#158). This ensures the
|
||||||
// database columns stay in sync even if the frontend only sends the
|
// database columns stay in sync even if the frontend only sends the
|
||||||
@@ -2232,12 +1668,12 @@ module.exports = (router) => {
|
|||||||
return res.status(404).json({ error: 'Event not found' });
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const newStatus = !event.is_active;
|
const newStatus = !parseBooleanInput(event.is_active, false);
|
||||||
await db('events')
|
await db('events')
|
||||||
.where('id', id)
|
.where('id', id)
|
||||||
.update({
|
.update({
|
||||||
is_active: newStatus,
|
is_active: formatBoolean(newStatus),
|
||||||
updated_at: new Date()
|
updated_at: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
|
|||||||
@@ -1,392 +1,9 @@
|
|||||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
|
||||||
// Shared helpers + module-level caches used across the adminEvents sub-routers.
|
|
||||||
|
|
||||||
const { db, logActivity } = require('../../database/db');
|
const { db, logActivity } = require('../../database/db');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const logger = require('../../utils/logger');
|
const logger = require('../../utils/logger');
|
||||||
const { parseStringInput } = require('../../utils/parsers');
|
const settings = require('../../services/eventSettings');
|
||||||
|
|
||||||
// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point
|
|
||||||
const validateHeroImageAnchor = (value) => {
|
|
||||||
if (['top', 'center', 'bottom'].includes(value)) return true;
|
|
||||||
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
|
|
||||||
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
|
|
||||||
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
|
|
||||||
}
|
|
||||||
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get storage path from environment or default
|
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
|
|
||||||
|
|
||||||
// Helper to get event field requirements from settings
|
|
||||||
const getEventFieldRequirements = async () => {
|
|
||||||
try {
|
|
||||||
const settings = await db('app_settings')
|
|
||||||
.whereIn('setting_key', [
|
|
||||||
'event_require_customer_name',
|
|
||||||
'event_require_customer_email',
|
|
||||||
'event_require_admin_email',
|
|
||||||
'event_require_event_date',
|
|
||||||
'event_require_expiration'
|
|
||||||
])
|
|
||||||
.select('setting_key', 'setting_value');
|
|
||||||
|
|
||||||
const requirements = {
|
|
||||||
require_customer_name: true,
|
|
||||||
require_customer_email: true,
|
|
||||||
require_admin_email: true,
|
|
||||||
require_event_date: true,
|
|
||||||
require_expiration: true
|
|
||||||
};
|
|
||||||
|
|
||||||
settings.forEach(s => {
|
|
||||||
let value = s.setting_value;
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
try {
|
|
||||||
value = JSON.parse(value);
|
|
||||||
} catch (e) {
|
|
||||||
value = value === 'true';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
|
|
||||||
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
|
|
||||||
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
|
|
||||||
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
|
|
||||||
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return requirements;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get event field requirements', { error: error.message });
|
|
||||||
return {
|
|
||||||
require_customer_name: true,
|
|
||||||
require_customer_email: true,
|
|
||||||
require_admin_email: true,
|
|
||||||
require_event_date: true,
|
|
||||||
require_expiration: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to read app_settings booleans by key, used to inherit per-setting
|
|
||||||
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
|
|
||||||
// so callers can fall back to a legacy default.
|
|
||||||
/**
|
|
||||||
* Decode an app_settings value into the JS value it represents.
|
|
||||||
*
|
|
||||||
* setting_value is JSON text on SQLite and may already be decoded by the
|
|
||||||
* driver on a PG json column, so one parse does not normalise both. On top
|
|
||||||
* of that, the Image Security tab used to PUT back values it had read
|
|
||||||
* undecoded, wrapping another layer of quoting around each one on every
|
|
||||||
* save — the GET handler decodes now, but installs carry however many
|
|
||||||
* layers they accumulated before that.
|
|
||||||
*
|
|
||||||
* Every reader of app_settings has to agree about this, or the admin UI
|
|
||||||
* shows one thing while event creation does another.
|
|
||||||
*
|
|
||||||
* Terminates: each parse of a string is strictly shorter than its input.
|
|
||||||
*/
|
|
||||||
const decodeSettingValue = (raw) => {
|
|
||||||
let value = raw;
|
|
||||||
while (typeof value === 'string') {
|
|
||||||
let parsed;
|
|
||||||
try { parsed = JSON.parse(value); } catch { break; }
|
|
||||||
if (parsed === value) break;
|
|
||||||
value = parsed;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const readBooleanSetting = async (key) => {
|
|
||||||
try {
|
|
||||||
const setting = await db('app_settings').where('setting_key', key).first();
|
|
||||||
if (!setting) return undefined;
|
|
||||||
const value = decodeSettingValue(setting.setting_value);
|
|
||||||
return typeof value === 'boolean' ? value : undefined;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to read app setting', { key, error: error.message });
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to read the global "enable_devtools_protection" admin setting so
|
|
||||||
// new events inherit it instead of always falling back to the DB column default
|
|
||||||
// (#317 — admin disabled it globally but new events still got it ON).
|
|
||||||
const getDownloadProtectionDefaults = async () => {
|
|
||||||
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The rest of Settings → Image security, as creation defaults (#1296).
|
|
||||||
*
|
|
||||||
* Four settings in that panel were written, reloaded and rendered as
|
|
||||||
* controls, and read by nothing:
|
|
||||||
*
|
|
||||||
* default_protection_level → events.protection_level
|
|
||||||
* default_image_quality → events.image_quality
|
|
||||||
* enable_canvas_rendering → events.use_canvas_rendering
|
|
||||||
*
|
|
||||||
* Each maps onto a column migration 038 already created, and each is
|
|
||||||
* labelled "… by default", so applying them at creation is what the panel
|
|
||||||
* has always claimed to do. `enable_devtools_protection` above is the only
|
|
||||||
* one of the five that was ever wired.
|
|
||||||
*
|
|
||||||
* Creation-time only, deliberately. Applying them to EXISTING events would
|
|
||||||
* silently change live galleries on upgrade — an install with
|
|
||||||
* enable_canvas_rendering already on would switch every grid to canvas
|
|
||||||
* rendering, which is memory-expensive at scale and is the profile under
|
|
||||||
* investigation in #1287. New events only; existing rows untouched.
|
|
||||||
*
|
|
||||||
* Any value that is missing or malformed comes back undefined so the caller
|
|
||||||
* falls through to the column default, exactly as before this existed.
|
|
||||||
*/
|
|
||||||
const PROTECTION_LEVELS = ['basic', 'standard', 'enhanced', 'maximum'];
|
|
||||||
|
|
||||||
// parseInt would rescue malformed settings instead of rejecting them:
|
|
||||||
// parseInt('72oops') is 72, parseInt(72.5) is 72, parseInt([72]) is 72.
|
|
||||||
// That matters because the settings PUT stores whatever JSON it is handed
|
|
||||||
// without validating the value (adminImageSecurity.js writes
|
|
||||||
// JSON.stringify(value) for any allow-listed key), so those shapes really
|
|
||||||
// can be sitting in app_settings. Accept only a genuine integer, or a
|
|
||||||
// string that is exactly one.
|
|
||||||
const toInteger = (value) => {
|
|
||||||
if (typeof value === 'number') return Number.isInteger(value) ? value : undefined;
|
|
||||||
if (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim())) return Number(value.trim());
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getImageSecurityDefaults = async (trx = null) => {
|
|
||||||
const defaults = {};
|
|
||||||
try {
|
|
||||||
// Accepts a transaction the way getAppSetting does. It matters on
|
|
||||||
// sqlite3, whose pool holds a single connection: a caller already inside
|
|
||||||
// db.transaction() that read through the global `db` would block on the
|
|
||||||
// connection its own transaction holds until the acquire timeout, and
|
|
||||||
// the catch below would then quietly swallow it and drop the defaults.
|
|
||||||
const query = trx || db;
|
|
||||||
const rows = await query('app_settings')
|
|
||||||
.whereIn('setting_key', [
|
|
||||||
'default_protection_level',
|
|
||||||
'default_image_quality',
|
|
||||||
'enable_canvas_rendering',
|
|
||||||
])
|
|
||||||
.select('setting_key', 'setting_value');
|
|
||||||
|
|
||||||
// app_settings holds JSON text on SQLite, while a PG json column comes
|
|
||||||
// back already decoded — so one parse is not enough to normalise both.
|
|
||||||
// Worse, GET /api/admin/image-security/settings returns setting_value
|
|
||||||
// without decoding it and the settings tab PUTs the whole fetched object
|
|
||||||
// straight back through JSON.stringify, so opening the tab and saving
|
|
||||||
// re-encodes every value it read as text. After one such round trip
|
|
||||||
// `true` is stored as "\"true\"" and a single parse yields the string
|
|
||||||
// 'true', which the type checks below reject — the settings would go
|
|
||||||
// quietly dead again, which is the bug this whole change exists to fix.
|
|
||||||
// The GET handler now decodes, so this stops accumulating — but installs
|
|
||||||
// that already stacked N layers have to keep working, and N is however
|
|
||||||
// many times someone opened that tab. So unwrap until it stops being a
|
|
||||||
// JSON string rather than to a fixed depth; this terminates because each
|
|
||||||
// parse of a string is strictly shorter than its input.
|
|
||||||
const read = (key) => {
|
|
||||||
const row = rows.find((r) => r.setting_key === key);
|
|
||||||
if (!row) return undefined;
|
|
||||||
return decodeSettingValue(row.setting_value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const level = read('default_protection_level');
|
|
||||||
if (typeof level === 'string' && PROTECTION_LEVELS.includes(level)) {
|
|
||||||
defaults.protection_level = level;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The column is an integer percentage; anything outside 1..100 is a
|
|
||||||
// misconfiguration and falls through rather than being clamped into
|
|
||||||
// something the operator did not choose.
|
|
||||||
const quality = toInteger(read('default_image_quality'));
|
|
||||||
if (quality !== undefined && quality >= 1 && quality <= 100) {
|
|
||||||
defaults.image_quality = quality;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = read('enable_canvas_rendering');
|
|
||||||
if (typeof canvas === 'boolean') {
|
|
||||||
defaults.use_canvas_rendering = canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// A settings read must never block event creation; the column defaults
|
|
||||||
// are a correct fallback.
|
|
||||||
logger.error('Failed to read image-security defaults', { error: error.message });
|
|
||||||
}
|
|
||||||
return defaults;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the image-security columns for a NEW event: an explicit request
|
|
||||||
* value wins, then the global default, then the column default (the key is
|
|
||||||
* omitted entirely so the database supplies it).
|
|
||||||
*
|
|
||||||
* Shared by the admin create route and POST /api/v1/events so the configured
|
|
||||||
* security level cannot depend on which entry point created the gallery —
|
|
||||||
* the same split that made #592 (devtools) a separate bug from #317.
|
|
||||||
*
|
|
||||||
* `body` values are already validated by the route's express-validator
|
|
||||||
* chain; `defaults` come from getImageSecurityDefaults(), which validates
|
|
||||||
* them itself.
|
|
||||||
*/
|
|
||||||
const resolveImageSecurityColumns = (body = {}, defaults = {}) => {
|
|
||||||
const { formatBoolean } = require('../../utils/dbCompat');
|
|
||||||
const columns = {};
|
|
||||||
// express-validator runs isInt/isIn/isBoolean element-wise on arrays, so a
|
|
||||||
// single-element array like `image_quality: [72]` passes the route's chain
|
|
||||||
// and arrives here still an array. The routes reject those with
|
|
||||||
// .not().isArray(); this guard means any future caller cannot write one
|
|
||||||
// into a scalar column (a PG insert error, or `[false]` coerced to true).
|
|
||||||
const scalar = (v) => (v !== null && typeof v === 'object' ? undefined : v);
|
|
||||||
const pick = (key) => {
|
|
||||||
const fromBody = scalar(body[key]);
|
|
||||||
return fromBody !== undefined ? fromBody : defaults[key];
|
|
||||||
};
|
|
||||||
|
|
||||||
const level = pick('protection_level');
|
|
||||||
if (level !== undefined) columns.protection_level = level;
|
|
||||||
|
|
||||||
const quality = pick('image_quality');
|
|
||||||
if (quality !== undefined) columns.image_quality = quality;
|
|
||||||
|
|
||||||
const canvas = pick('use_canvas_rendering');
|
|
||||||
if (canvas !== undefined) columns.use_canvas_rendering = formatBoolean(canvas);
|
|
||||||
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
|
|
||||||
//
|
|
||||||
// Note: `branding_logo_position` (header bar — left/center/right) is a
|
|
||||||
// different concept from `hero_logo_position` (hero block — top/center/
|
|
||||||
// bottom) and must NOT be mapped here. A previous version copied the
|
|
||||||
// branding value over, which wrote 'left'/'right' into per-event
|
|
||||||
// hero_logo_position columns and broke any subsequent PUT validation
|
|
||||||
// (#357). Migration 084 heals existing rows.
|
|
||||||
const getBrandingDefaults = async () => {
|
|
||||||
try {
|
|
||||||
const settings = await db('app_settings')
|
|
||||||
.whereIn('setting_key', [
|
|
||||||
'branding_logo_display_hero',
|
|
||||||
'branding_logo_size'
|
|
||||||
])
|
|
||||||
.select('setting_key', 'setting_value');
|
|
||||||
|
|
||||||
const defaults = {
|
|
||||||
hero_logo_visible: true,
|
|
||||||
hero_logo_size: 'medium',
|
|
||||||
hero_logo_position: 'top'
|
|
||||||
};
|
|
||||||
|
|
||||||
settings.forEach(s => {
|
|
||||||
let value = s.setting_value;
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
|
|
||||||
}
|
|
||||||
if (s.setting_key === 'branding_logo_display_hero') {
|
|
||||||
defaults.hero_logo_visible = value !== false;
|
|
||||||
}
|
|
||||||
if (s.setting_key === 'branding_logo_size' && value) {
|
|
||||||
defaults.hero_logo_size = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return defaults;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get branding defaults', { error: error.message });
|
|
||||||
return {
|
|
||||||
hero_logo_visible: true,
|
|
||||||
hero_logo_size: 'medium',
|
|
||||||
hero_logo_position: 'top'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use parseStringInput from shared parsers for customer data extraction
|
|
||||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
|
||||||
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
|
|
||||||
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
|
|
||||||
|
|
||||||
// Whether the global "phone field" toggle (#322) is enabled. Cached for
|
|
||||||
// the request via a module-level read; drift is acceptable since this
|
|
||||||
// only governs whether to persist the field, not security boundaries.
|
|
||||||
const isPhoneFieldEnabled = async () => {
|
|
||||||
try {
|
|
||||||
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
|
|
||||||
if (!row) return false;
|
|
||||||
let value = row.setting_value;
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
try { value = JSON.parse(value); } catch { /* keep raw */ }
|
|
||||||
}
|
|
||||||
return value === true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug('Failed to read event_phone_field_enabled', { error: error.message });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const RECOVERABLE_PASSWORD_COLUMNS = ['password_recoverable', 'client_password_recoverable'];
|
|
||||||
|
|
||||||
const mapEventForApi = (event) => {
|
|
||||||
if (!event || typeof event !== 'object') {
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
host_name,
|
|
||||||
host_email,
|
|
||||||
customer_name,
|
|
||||||
customer_email,
|
|
||||||
customer_phone,
|
|
||||||
// Bound only to exclude the secrets from `...rest` — never read.
|
|
||||||
password_hash: _ph, client_password_hash: _cph,
|
|
||||||
...rest
|
|
||||||
} = event;
|
|
||||||
// #1271 — the encrypted copies never leave the server except via
|
|
||||||
// /:id/password. Removed by name (not destructured) so a secret scanner
|
|
||||||
// does not read the binding as a hard-coded password.
|
|
||||||
for (const column of RECOVERABLE_PASSWORD_COLUMNS) delete rest[column];
|
|
||||||
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
customer_name: customer_name ?? host_name ?? null,
|
|
||||||
customer_email: customer_email ?? host_email ?? null,
|
|
||||||
customer_phone: customer_phone ?? null
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
let customerColumnCache = null;
|
|
||||||
const hasCustomerContactColumns = async () => {
|
|
||||||
if (customerColumnCache === true) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
|
||||||
if (hasColumn) {
|
|
||||||
customerColumnCache = true;
|
|
||||||
}
|
|
||||||
return hasColumn;
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug('Failed to detect customer_email column', { error: error.message });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cascade-delete a single event: photos, audit/access logs, queued emails,
|
|
||||||
// the event row itself (in one transaction), then the on-disk folder /
|
|
||||||
// archive zip / hero logo (best-effort — file failures don't unwind the DB
|
|
||||||
// changes since the source of truth is the database). Used by both the
|
|
||||||
// per-event DELETE /:id route and the bulk-delete route to avoid drift.
|
|
||||||
//
|
|
||||||
// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the
|
|
||||||
// bulk-delete loop can report it as a per-id failure without aborting the
|
|
||||||
// whole batch. Any other error propagates and is the caller's problem.
|
|
||||||
async function deleteEventCascade(eventId, adminContext) {
|
async function deleteEventCascade(eventId, adminContext) {
|
||||||
const event = await db('events').where('id', eventId).first();
|
const event = await db('events').where('id', eventId).first();
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@@ -678,40 +295,4 @@ async function deleteEventCascade(eventId, adminContext) {
|
|||||||
return { id: event.id, name: event.event_name };
|
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,
|
|
||||||
};
|
|
||||||
|
|||||||
+26
-95
@@ -1,6 +1,8 @@
|
|||||||
|
const { isGalleryAvailable } = require('../utils/galleryLifecycle');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
|
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
@@ -420,7 +422,7 @@ router.post('/gallery/verify', [
|
|||||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!isGalleryAvailable(event)) {
|
||||||
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
|
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
|
||||||
await bcrypt.compare(password || '', DUMMY_BCRYPT_HASH);
|
await bcrypt.compare(password || '', DUMMY_BCRYPT_HASH);
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
@@ -496,6 +498,9 @@ router.post('/gallery/verify', [
|
|||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventSlug: event.slug,
|
eventSlug: event.slug,
|
||||||
type: 'gallery',
|
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,
|
ip: ipAddress,
|
||||||
loginTime: Date.now()
|
loginTime: Date.now()
|
||||||
}, process.env.JWT_SECRET, {
|
}, process.env.JWT_SECRET, {
|
||||||
@@ -545,7 +550,7 @@ router.post('/gallery/:slug/client-login', [
|
|||||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!event || !event.client_access_enabled || !event.client_password_hash) {
|
if (!isGalleryAvailable(event) || !event.client_access_enabled || !event.client_password_hash) {
|
||||||
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
|
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
@@ -570,6 +575,9 @@ router.post('/gallery/:slug/client-login', [
|
|||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventSlug: event.slug,
|
eventSlug: event.slug,
|
||||||
type: 'gallery',
|
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',
|
accessLevel: 'client',
|
||||||
ip: ipAddress,
|
ip: ipAddress,
|
||||||
loginTime: Date.now()
|
loginTime: Date.now()
|
||||||
@@ -631,14 +639,14 @@ router.post('/gallery/share-login', [
|
|||||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!isGalleryAvailable(event)) {
|
||||||
const resolved = await resolveShareIdentifier(slug);
|
const resolved = await resolveShareIdentifier(slug);
|
||||||
if (resolved?.event) {
|
if (resolved?.event) {
|
||||||
event = resolved.event;
|
event = resolved.event;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event) {
|
if (!isGalleryAvailable(event)) {
|
||||||
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
|
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
|
||||||
return res.status(404).json({ error: 'Gallery not found' });
|
return res.status(404).json({ error: 'Gallery not found' });
|
||||||
}
|
}
|
||||||
@@ -666,6 +674,9 @@ router.post('/gallery/share-login', [
|
|||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventSlug: event.slug,
|
eventSlug: event.slug,
|
||||||
type: 'gallery',
|
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,
|
ip: ipAddress,
|
||||||
loginTime: Date.now()
|
loginTime: Date.now()
|
||||||
}, process.env.JWT_SECRET, {
|
}, process.env.JWT_SECRET, {
|
||||||
@@ -743,106 +754,26 @@ router.get('/session', async (req, res) => {
|
|||||||
issuer: 'picpeak-auth'
|
issuer: 'picpeak-auth'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if token has been revoked (e.g. after logout)
|
const sessions = require('../services/sessionAccessService');
|
||||||
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;
|
let adminUser = null;
|
||||||
|
|
||||||
if (decoded.type === 'admin') {
|
if (decoded.type === 'admin') {
|
||||||
let admin = null;
|
const admin = await sessions.admin(decoded, { includeProfile: true });
|
||||||
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');
|
const { isSessionExpired } = require('../middleware/sessionTimeout');
|
||||||
if (await isSessionExpired(token, decoded)) {
|
if (await isSessionExpired(token, decoded)) {
|
||||||
return res.json({ valid: false, error: 'Session expired' });
|
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 = {
|
adminUser = {
|
||||||
id: admin.id,
|
id: admin.id, username: admin.username, email: admin.email,
|
||||||
username: admin.username,
|
mustChangePassword: !!admin.must_change_password,
|
||||||
email: admin.email,
|
role: admin.role_name ? { name: admin.role_name, displayName: admin.role_display_name } : null,
|
||||||
mustChangePassword: admin.must_change_password || false,
|
|
||||||
role: admin.role_name ? {
|
|
||||||
name: admin.role_name,
|
|
||||||
displayName: admin.role_display_name
|
|
||||||
} : null
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
} else if (decoded.type === 'gallery') {
|
} else if (decoded.type === 'gallery') {
|
||||||
try {
|
const access = require('../services/galleryAccessService');
|
||||||
const event = await db('events')
|
const event = await db('events').where({ id: decoded.eventId }).first();
|
||||||
.where({
|
if (!event) return res.json({ valid: false, error: 'Gallery no longer available' });
|
||||||
id: decoded.eventId,
|
await access.authorize(event, access.grant(event, 'gallery', decoded));
|
||||||
is_active: formatBoolean(true),
|
} else {
|
||||||
is_archived: formatBoolean(false),
|
return res.status(403).json({ valid: false, error: 'Invalid token type' });
|
||||||
})
|
|
||||||
.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
|
// Calculate remaining time
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { isGalleryAvailable, isGalleryExpired } = require('../utils/galleryLifecycle');
|
||||||
/**
|
/**
|
||||||
* Customer dashboard routes
|
* Customer dashboard routes
|
||||||
*
|
*
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { body, param, validationResult } = require('express-validator');
|
const { body, param, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { getBcryptRounds, MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
|
const { getBcryptRounds, MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
|
||||||
@@ -165,10 +167,12 @@ router.get('/events/:slug/access-token', [
|
|||||||
if (event.is_archived) {
|
if (event.is_archived) {
|
||||||
return res.status(410).json({ error: 'This gallery has been archived' });
|
return res.status(410).json({ error: 'This gallery has been archived' });
|
||||||
}
|
}
|
||||||
if (event.expires_at && new Date(event.expires_at) < new Date()) {
|
if (isGalleryExpired(event)) {
|
||||||
return res.status(410).json({ error: 'This gallery has expired' });
|
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(
|
const hasAccess = await customerAccountsService.customerHasAccessToEvent(
|
||||||
req.customer.id,
|
req.customer.id,
|
||||||
event.id
|
event.id
|
||||||
@@ -189,10 +193,12 @@ router.get('/events/:slug/access-token', [
|
|||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventSlug: event.slug,
|
eventSlug: event.slug,
|
||||||
type: 'gallery',
|
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,
|
ip: ipAddress,
|
||||||
loginTime: Date.now(),
|
loginTime: Date.now(),
|
||||||
// Optional bookkeeping claim — surfaces the originating customer in
|
// Rechecked on each gallery/media request, including account status.
|
||||||
// logs when the token is later used. Doesn't affect authorization.
|
|
||||||
via: 'customer',
|
via: 'customer',
|
||||||
customerId: req.customer.id,
|
customerId: req.customer.id,
|
||||||
}, process.env.JWT_SECRET, {
|
}, process.env.JWT_SECRET, {
|
||||||
|
|||||||
+10
-3359
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,945 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { db, logActivity } = require('../../database/db');
|
||||||
|
const { parseBooleanInput } = require('../../utils/parsers');
|
||||||
|
const archiver = require('archiver');
|
||||||
|
const path = require('path');
|
||||||
|
const { resolvePhotoContentType } = require('../../utils/photoContentType');
|
||||||
|
const router = express.Router();
|
||||||
|
const watermarkService = require('../../services/watermarkService');
|
||||||
|
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
|
||||||
|
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||||
|
const logger = require('../../utils/logger');
|
||||||
|
const { pipeStreamToResponse } = require('../../utils/streamResponse');
|
||||||
|
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../../services/photoResolver');
|
||||||
|
const { errorResponse } = require('../../utils/routeHelpers');
|
||||||
|
const { blockHiddenGallery } = require('../../utils/revealMode');
|
||||||
|
const downloadZipService = require('../../services/downloadZipService');
|
||||||
|
const { renderPhotoForDownload, resolveWatermarkSettings } = require('../../services/downloadRendition');
|
||||||
|
const downloadJobService = require('../../services/downloadJobService');
|
||||||
|
const {
|
||||||
|
resolveEventDownloadPolicy,
|
||||||
|
pickRequestedResolution,
|
||||||
|
parseResolution,
|
||||||
|
} = require('../../utils/downloadResolutions');
|
||||||
|
const { applyPhotoVisibilityFilter, canSeeHiddenPhotos } = require('../../utils/photoVisibility');
|
||||||
|
const {
|
||||||
|
getUseOriginalFilenames,
|
||||||
|
pickRawDownloadName,
|
||||||
|
getZipEntryNames,
|
||||||
|
} = require('../../services/downloadFilenameService');
|
||||||
|
const { buildContentDisposition } = require('../../utils/filenameSanitizer');
|
||||||
|
const { getStorage } = require('../../services/storage');
|
||||||
|
const fs = require('fs');
|
||||||
|
function parseByteRange(header, size) {
|
||||||
|
if (!header || typeof header !== 'string' || !size) return null;
|
||||||
|
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, rawStart, rawEnd] = match;
|
||||||
|
if (rawStart === '' && rawEnd === '') return null;
|
||||||
|
|
||||||
|
let start;
|
||||||
|
let end;
|
||||||
|
if (rawStart === '') {
|
||||||
|
// Suffix form: the last N bytes.
|
||||||
|
const suffix = parseInt(rawEnd, 10);
|
||||||
|
if (!suffix) return null;
|
||||||
|
start = Math.max(0, size - suffix);
|
||||||
|
end = size - 1;
|
||||||
|
} else {
|
||||||
|
start = parseInt(rawStart, 10);
|
||||||
|
end = rawEnd === '' ? size - 1 : parseInt(rawEnd, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||||
|
if (start > end || start >= size) return null;
|
||||||
|
return { start, end: Math.min(end, size - 1) };
|
||||||
|
}
|
||||||
|
function galleryActor(req) {
|
||||||
|
// Portal tokens run as accessLevel 'guest' but carry via:'customer'
|
||||||
|
// (req.viaCustomer); PIN-client logins carry accessLevel 'client'.
|
||||||
|
// Both are customers, not guests (codex review of #849, final round).
|
||||||
|
const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client'));
|
||||||
|
return { type: isCustomer ? 'customer' : 'guest' };
|
||||||
|
}
|
||||||
|
const SINGLE_DOWNLOAD_DEBOUNCE_MS = 60 * 60 * 1000;
|
||||||
|
const singleDownloadNotifiedAt = new Map();
|
||||||
|
function notifySinglePhotoDownload(event, req) {
|
||||||
|
const now = Date.now();
|
||||||
|
const last = singleDownloadNotifiedAt.get(event.id) || 0;
|
||||||
|
if (now - last < SINGLE_DOWNLOAD_DEBOUNCE_MS) return;
|
||||||
|
singleDownloadNotifiedAt.set(event.id, now);
|
||||||
|
logActivity('gallery_downloaded', { scope: 'single' }, event.id, galleryActor(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { photoId } = req.params;
|
||||||
|
|
||||||
|
// Check if downloads are allowed for this event
|
||||||
|
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||||
|
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const photo = await db('photos')
|
||||||
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block guest access to hidden photos
|
||||||
|
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Photo not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-category download permission (#640). Photos without a category are
|
||||||
|
// always downloadable when the event allows downloads — only categorised
|
||||||
|
// photos can opt out per-category.
|
||||||
|
if (photo.category_id) {
|
||||||
|
const cat = await db('photo_categories')
|
||||||
|
.where('id', photo.category_id)
|
||||||
|
.first('allow_downloads');
|
||||||
|
if (cat && !parseBooleanInput(cat.allow_downloads, true)) {
|
||||||
|
return res.status(403).json({ error: 'Downloads are disabled for this category' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download resolution (#858). Resolved BEFORE the counters below: a
|
||||||
|
// rejected resolution must not inflate download stats, which a guest
|
||||||
|
// could otherwise do by replaying ?resolution=bogus.
|
||||||
|
const isVideo = photo.media_type === 'video'
|
||||||
|
|| (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||||
|
const policy = await resolveEventDownloadPolicy(req.event);
|
||||||
|
const requested = pickRequestedResolution(policy, req.query.resolution);
|
||||||
|
if (requested === null) {
|
||||||
|
return res.status(400).json({ error: 'Resolution not available for this gallery' });
|
||||||
|
}
|
||||||
|
const box = isVideo ? null : parseResolution(requested);
|
||||||
|
|
||||||
|
// A HEAD is a metadata probe, not a download. Answering it below the
|
||||||
|
// counters recorded every probe as a real download, and answering it below
|
||||||
|
// renderPhotoForDownload fetched and watermarked an image whose body Node
|
||||||
|
// then discards. Both happen before this point in a GET, so HEAD leaves
|
||||||
|
// here — with no side effects and no bytes read.
|
||||||
|
if (req.method === 'HEAD') {
|
||||||
|
const headUseOriginal = await getUseOriginalFilenames();
|
||||||
|
const headHeaders = {
|
||||||
|
'Content-Type': resolvePhotoContentType(photo),
|
||||||
|
'Content-Disposition': buildContentDisposition(pickRawDownloadName(photo, headUseOriginal)),
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Content-Length only when the bytes ship untransformed AND the size can
|
||||||
|
// be read without fetching them. A watermark or resize changes the
|
||||||
|
// length, and the only way to learn the new one is to do the work this
|
||||||
|
// branch exists to avoid — HEAD is allowed to omit it.
|
||||||
|
const headWatermark = await resolveWatermarkSettings(req.event);
|
||||||
|
if (!box && !headWatermark) {
|
||||||
|
try {
|
||||||
|
const headKey = resolvePhotoStorageKey(req.event, photo);
|
||||||
|
const headStorage = getStorage();
|
||||||
|
if (headKey && headStorage.kind() !== 'local') {
|
||||||
|
const headStat = await headStorage.stat(headKey);
|
||||||
|
if (!headStat) return res.status(404).json({ error: 'Photo file not found' });
|
||||||
|
headHeaders['Content-Length'] = headStat.size;
|
||||||
|
if (headStat.mtime) headHeaders['Last-Modified'] = new Date(headStat.mtime).toUTCString();
|
||||||
|
}
|
||||||
|
} catch (headErr) {
|
||||||
|
// No length is a valid HEAD; not worth failing the probe over.
|
||||||
|
logger.debug('HEAD probe could not stat the object', { photoId, error: headErr.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set(headHeaders);
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin preview (#868) downloads are excluded from the download count +
|
||||||
|
// guest analytics — kept out of client-facing stats.
|
||||||
|
if (!req.isAdminPreview) {
|
||||||
|
// Update download count
|
||||||
|
await db('photos').where('id', photoId).increment('download_count', 1);
|
||||||
|
|
||||||
|
// Log download
|
||||||
|
await db('access_logs').insert({
|
||||||
|
event_id: req.event.id,
|
||||||
|
ip_address: req.ip,
|
||||||
|
user_agent: req.headers['user-agent'],
|
||||||
|
action: 'download',
|
||||||
|
photo_id: photoId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Surface in the admin notification bell (#746) — debounced, and only
|
||||||
|
// once the response actually finished: notifying up-front would log a
|
||||||
|
// download that then 404s/fails and the debounce would suppress the
|
||||||
|
// next real one for an hour (codex review of #849).
|
||||||
|
res.on('finish', () => {
|
||||||
|
if (res.statusCode < 400 && !req.isAdminPreview) notifySinglePhotoDownload(req.event, req);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #493: if the admin enabled "use original filenames", surface the
|
||||||
|
// pre-rename camera filename in Content-Disposition. Storage path is
|
||||||
|
// unchanged — only the user-visible download name is swapped.
|
||||||
|
const useOriginal = await getUseOriginalFilenames();
|
||||||
|
const downloadName = pickRawDownloadName(photo, useOriginal);
|
||||||
|
const contentDisposition = buildContentDisposition(downloadName);
|
||||||
|
|
||||||
|
// The gallery's standard applies to EVERY ordinary download, single photos
|
||||||
|
// included — otherwise a lowered standard is trivially bypassed by
|
||||||
|
// downloading photos one at a time. `box` was resolved above, before the
|
||||||
|
// counters. Videos have no resize path and always ship as-is.
|
||||||
|
//
|
||||||
|
// renderPhotoForDownload (#858) owns the resize-then-watermark ordering
|
||||||
|
// and the storage fetch, and is what the zip builders below already use.
|
||||||
|
// It returns null when the photo needs no transformation at all, which is
|
||||||
|
// the default gallery's common case and lets us ship the stored bytes
|
||||||
|
// without buffering a full-size original into memory.
|
||||||
|
const effectiveSettings = await resolveWatermarkSettings(req.event);
|
||||||
|
|
||||||
|
let rendered;
|
||||||
|
try {
|
||||||
|
rendered = await renderPhotoForDownload(req.event, photo, box, effectiveSettings);
|
||||||
|
} catch (renderError) {
|
||||||
|
// Classify, the same way the pass-through branch below does. This can
|
||||||
|
// reject because the source object is gone, but equally because
|
||||||
|
// getToFile timed out, the tmp filesystem filled up, or sharp failed —
|
||||||
|
// and reporting an operational failure as 404 tells the guest their
|
||||||
|
// photo does not exist and tells us nothing.
|
||||||
|
const gone = renderError.code === 'ENOENT'
|
||||||
|
|| renderError.name === 'NoSuchKey'
|
||||||
|
|| renderError.name === 'NotFound'
|
||||||
|
|| renderError.$metadata?.httpStatusCode === 404;
|
||||||
|
logger.error('Failed to render photo for download', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: renderError.message,
|
||||||
|
});
|
||||||
|
return gone
|
||||||
|
? res.status(404).json({ error: 'Photo file not found' })
|
||||||
|
: res.status(500).json({ error: 'Failed to download photo' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rendered) {
|
||||||
|
res.set({
|
||||||
|
'Content-Type': resolvePhotoContentType(photo),
|
||||||
|
'Content-Disposition': contentDisposition,
|
||||||
|
'Content-Length': rendered.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.send(rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untransformed: ship the stored bytes.
|
||||||
|
//
|
||||||
|
// Managed photos live behind the storage abstraction and on an S3/R2
|
||||||
|
// deployment are not on local disk at all — resolving a filesystem path
|
||||||
|
// unconditionally here is what made every single-photo download 404 with
|
||||||
|
// ENOENT in S3 mode (#1048), while download-all and secure-images worked
|
||||||
|
// because they already went through getStorage().
|
||||||
|
//
|
||||||
|
// resolvePhotoStorageKey returns null for external/reference photos: those
|
||||||
|
// live on a local mount and keep the sendFile path.
|
||||||
|
let storageKey = null;
|
||||||
|
try {
|
||||||
|
storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||||
|
} catch (resolveError) {
|
||||||
|
logger.error('Failed to resolve photo storage key for download', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: resolveError.message,
|
||||||
|
});
|
||||||
|
return res.status(404).json({ error: 'Photo file not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
if (storageKey && storage.kind() !== 'local') {
|
||||||
|
// Deliberately NOT the local path: res.sendFile emits Content-Length,
|
||||||
|
// Accept-Ranges, ETag and Last-Modified and answers Range requests with
|
||||||
|
// a 206, and a bare stream.pipe(res) has none of that. On local disk
|
||||||
|
// sendFile stays the better implementation, so it stays the branch.
|
||||||
|
//
|
||||||
|
// On S3 we reproduce the parts that matter for a download: the length
|
||||||
|
// (browsers need it for the progress indicator, which matters most on
|
||||||
|
// exactly the large files this route serves) and Range, so an
|
||||||
|
// interrupted download resumes instead of appending a second full body
|
||||||
|
// onto the partial file. Conditional requests are not reproduced —
|
||||||
|
// there is no ETag here, so a client revalidating gets the whole body,
|
||||||
|
// same as it does today.
|
||||||
|
const stat = await storage.stat(storageKey);
|
||||||
|
if (!stat) {
|
||||||
|
logger.error('Photo not found in storage backend for download', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
storageKey,
|
||||||
|
});
|
||||||
|
return res.status(404).json({ error: 'Photo file not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastModified = stat.mtime ? new Date(stat.mtime).toUTCString() : null;
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': resolvePhotoContentType(photo),
|
||||||
|
'Content-Disposition': contentDisposition,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
};
|
||||||
|
if (lastModified) headers['Last-Modified'] = lastModified;
|
||||||
|
|
||||||
|
// If-Range: a client resuming an interrupted download sends back the
|
||||||
|
// validator it was given last time. If the object has been replaced
|
||||||
|
// since — the watcher re-importing a swapped file, an admin re-upload —
|
||||||
|
// answering 206 from the NEW bytes lets the client splice two different
|
||||||
|
// versions into one corrupt file. A validator that doesn't match means
|
||||||
|
// a full 200, which is the whole point of the header.
|
||||||
|
const ifRange = req.headers['if-range'];
|
||||||
|
const staleValidator = !!ifRange && (!lastModified || ifRange.trim() !== lastModified);
|
||||||
|
const range = staleValidator ? null : parseByteRange(req.headers.range, stat.size);
|
||||||
|
|
||||||
|
// Open the stream BEFORE any header is staged or sent. stat() succeeding
|
||||||
|
// does not mean get() will: a concurrent delete or replace, or a
|
||||||
|
// transient backend error, lands here. Once writeHead(206) has gone out
|
||||||
|
// the outer catch can do nothing but throw ERR_HTTP_HEADERS_SENT, and in
|
||||||
|
// the non-range case it would send its 500 JSON underneath the staged
|
||||||
|
// image/jpeg attachment headers — a .jpg file full of JSON.
|
||||||
|
let stream;
|
||||||
|
try {
|
||||||
|
stream = range
|
||||||
|
? await storage.getRange(storageKey, range.start, range.end)
|
||||||
|
: await storage.get(storageKey);
|
||||||
|
} catch (fetchError) {
|
||||||
|
const gone = fetchError.code === 'ENOENT'
|
||||||
|
|| fetchError.name === 'NoSuchKey'
|
||||||
|
|| fetchError.name === 'NotFound'
|
||||||
|
|| fetchError.$metadata?.httpStatusCode === 404;
|
||||||
|
logger.error('Failed to open photo stream for download', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
storageKey,
|
||||||
|
error: fetchError.message,
|
||||||
|
});
|
||||||
|
return gone
|
||||||
|
? res.status(404).json({ error: 'Photo file not found' })
|
||||||
|
: res.status(500).json({ error: 'Failed to download photo' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
// status()+set() rather than writeHead(): writeHead commits the
|
||||||
|
// response immediately, so a stream that resolves and THEN errors
|
||||||
|
// before its first chunk would leave pipeStreamToResponse able only to
|
||||||
|
// destroy the connection. Staged headers are flushed by the first body
|
||||||
|
// write, which means an error at byte zero can still clear them and
|
||||||
|
// return a clean, retryable status instead of a transport reset.
|
||||||
|
res.status(206).set({
|
||||||
|
...headers,
|
||||||
|
'Content-Range': `bytes ${range.start}-${range.end}/${stat.size}`,
|
||||||
|
'Content-Length': (range.end - range.start) + 1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.set({ ...headers, 'Content-Length': stat.size });
|
||||||
|
}
|
||||||
|
pipeStreamToResponse(stream, res, {
|
||||||
|
context: range ? `download range for photo ${photo.id}` : `download for photo ${photo.id}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filePath;
|
||||||
|
try {
|
||||||
|
filePath = resolvePhotoFilePath(req.event, photo);
|
||||||
|
} catch (resolveError) {
|
||||||
|
logger.error('Failed to resolve photo path for download', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: resolveError.message,
|
||||||
|
});
|
||||||
|
return res.status(404).json({ error: 'Photo file not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// res.download() builds Content-Disposition itself but doesn't emit the
|
||||||
|
// RFC 5987 filename* parameter, so unicode camera filenames would lose
|
||||||
|
// their bytes on download. Set the header explicitly and stream the
|
||||||
|
// file with res.sendFile-equivalent semantics.
|
||||||
|
res.set({
|
||||||
|
'Content-Type': resolvePhotoContentType(photo),
|
||||||
|
'Content-Disposition': contentDisposition,
|
||||||
|
});
|
||||||
|
res.sendFile(filePath, (downloadError) => {
|
||||||
|
if (downloadError) {
|
||||||
|
logger.error('Error streaming gallery download', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: downloadError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to download photo');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download all photos as ZIP
|
||||||
|
// Zip downloads count toward each contained photo's download_count (#895)
|
||||||
|
// — previously only single-photo downloads did, so galleries whose guests
|
||||||
|
// grab the zip showed 0 per-photo downloads forever. Used by the
|
||||||
|
// pre-generated-zip branches only: it mirrors downloadZipService._build,
|
||||||
|
// which zips EVERY event photo with no per-category allow_downloads
|
||||||
|
// filter — the counter has to reflect what actually shipped. (That the
|
||||||
|
// prebuilt zip ignores per-category download opt-outs is a separate,
|
||||||
|
// pre-existing issue.) Known approximation: _build skips entries whose
|
||||||
|
// WATERMARK step fails and still publishes the zip; counting those
|
||||||
|
// would need a persisted archive manifest, which isn't worth it for
|
||||||
|
// that tail case. Fire-and-forget at the call sites: counters must
|
||||||
|
// never fail a download.
|
||||||
|
async function bumpEventDownloadCounts(eventId) {
|
||||||
|
await db('photos').where('event_id', eventId).increment('download_count', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if downloads are allowed for this event
|
||||||
|
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||||
|
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to serve pre-generated zip (instant download with Content-Length).
|
||||||
|
// Guests may use the prebuilt cache ONLY when the event has no hidden
|
||||||
|
// photos: a cache built before a photo was hidden — or before this
|
||||||
|
// visibility-aware builder shipped — could otherwise still leak it, and
|
||||||
|
// getZipInfo only checks the DB pointer + file stat, not freshness. When
|
||||||
|
// hidden photos exist, guests fall through to the visibility-filtered
|
||||||
|
// stream below. PIN-clients always stream a full archive.
|
||||||
|
const isClient = canSeeHiddenPhotos(req.accessLevel);
|
||||||
|
const eventHasHidden = await db('photos')
|
||||||
|
.where({ event_id: req.event.id, visibility: 'hidden' })
|
||||||
|
.first()
|
||||||
|
.then(Boolean);
|
||||||
|
const zipInfo = (isClient || eventHasHidden)
|
||||||
|
? null
|
||||||
|
: await downloadZipService.getZipInfo(req.event.id);
|
||||||
|
if (zipInfo) {
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
// Stream via the authenticated route so logout, restore and account
|
||||||
|
// changes are checked on every download, including S3-backed archives.
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Length', zipInfo.size);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
|
||||||
|
const stream = await storage.get(zipInfo.key);
|
||||||
|
pipeStreamToResponse(stream, res, { context: `prepared zip for event ${req.event.id}`, missingStatus: 410 });
|
||||||
|
|
||||||
|
// Log bulk download (admin preview #868 excluded — stats stay client-only).
|
||||||
|
if (!req.isAdminPreview) {
|
||||||
|
db('access_logs').insert({
|
||||||
|
event_id: req.event.id,
|
||||||
|
ip_address: req.ip,
|
||||||
|
user_agent: req.headers['user-agent'],
|
||||||
|
action: 'download_all'
|
||||||
|
}).catch(() => {});
|
||||||
|
bumpEventDownloadCounts(req.event.id).catch(() => {});
|
||||||
|
// Surface in the admin notification bell (#746) — only once the
|
||||||
|
// stream actually finished; logging at pipe-time would report
|
||||||
|
// downloads that then broke mid-transfer (codex review of #849).
|
||||||
|
res.on('finish', () => {
|
||||||
|
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: on-the-fly streaming (existing behavior). Only pre-build the
|
||||||
|
// guest cache when it will actually be served next time — a guest
|
||||||
|
// download of an event with no hidden photos. Client bypasses and
|
||||||
|
// hidden-photo events always stream, so rebuilding the guest archive on
|
||||||
|
// those requests is wasted I/O (codex review).
|
||||||
|
if (!isClient && !eventHasHidden) {
|
||||||
|
downloadZipService.generateZip(req.event.id).catch(err =>
|
||||||
|
logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch photos — exclude photos in categories that disabled downloads (#640).
|
||||||
|
// Uncategorised photos are always included; categories without the column
|
||||||
|
// (pre-migration-135) fall through the LEFT JOIN's null and are included.
|
||||||
|
const photos = await applyPhotoVisibilityFilter(
|
||||||
|
db('photos')
|
||||||
|
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||||
|
.where('photos.event_id', req.event.id)
|
||||||
|
.where(function () {
|
||||||
|
this.whereNull('photos.category_id')
|
||||||
|
.orWhere('photo_categories.allow_downloads', true)
|
||||||
|
.orWhereNull('photo_categories.allow_downloads');
|
||||||
|
}),
|
||||||
|
req.accessLevel
|
||||||
|
)
|
||||||
|
.select('photos.*')
|
||||||
|
.orderBy('photos.type', 'asc')
|
||||||
|
.orderBy('photos.uploaded_at', 'desc');
|
||||||
|
|
||||||
|
if (photos.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'No photos found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count unique types
|
||||||
|
const uniqueTypes = new Set(photos.map(p => p.type)).size;
|
||||||
|
const hasMultipleTypes = uniqueTypes > 1;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
|
||||||
|
|
||||||
|
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||||
|
archive.on('error', (err) => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(res);
|
||||||
|
|
||||||
|
// Get watermark settings - apply if global setting OR event-level setting is enabled
|
||||||
|
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||||
|
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
|
||||||
|
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
|
||||||
|
const effectiveSettings = shouldApplyWatermark ? {
|
||||||
|
...watermarkSettings,
|
||||||
|
enabled: true,
|
||||||
|
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
// The gallery's standard resolution applies to the streamed archive too,
|
||||||
|
// not only the cached one (#858).
|
||||||
|
const { standardBox: bulkBox } = await resolveEventDownloadPolicy(req.event);
|
||||||
|
|
||||||
|
// Add photos to archive — managed photos via storage backend, external via local path.
|
||||||
|
const { resolvePhotoStorageKey } = require('../../services/photoResolver');
|
||||||
|
const storage = getStorage();
|
||||||
|
// #493: resolve a unique display filename per photo up-front so collisions
|
||||||
|
// get a deterministic `_1` suffix before the entries hit the archive.
|
||||||
|
const useOriginalBulk = await getUseOriginalFilenames();
|
||||||
|
const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk);
|
||||||
|
// Only photos whose append succeeded count as downloaded (#895) — the
|
||||||
|
// catch below deliberately skips missing/corrupt sources, and those
|
||||||
|
// never make it into the archive.
|
||||||
|
const appendedIds = [];
|
||||||
|
for (let i = 0; i < photos.length; i += 1) {
|
||||||
|
const photo = photos[i];
|
||||||
|
const storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||||
|
const entryName = bulkEntryNames[i];
|
||||||
|
let archiveName;
|
||||||
|
if (hasMultipleTypes) {
|
||||||
|
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
||||||
|
archiveName = path.join(folderName, entryName);
|
||||||
|
} else {
|
||||||
|
archiveName = entryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify the source exists BEFORE appending — but only for local
|
||||||
|
// sources: fs.createReadStream is lazy, so its error fires outside
|
||||||
|
// this try/catch and the archive 'error' handler then kills the
|
||||||
|
// whole response instead of skipping one photo (#895 review). S3's
|
||||||
|
// get() awaits GetObject and rejects right here on a missing key,
|
||||||
|
// so a preflight HEAD per entry would just be a redundant serial
|
||||||
|
// round trip (500-photo zip = 500 extra HEADs).
|
||||||
|
if (storageKey && storage.kind() === 'local') {
|
||||||
|
const srcStat = await storage.stat(storageKey);
|
||||||
|
if (!srcStat) {
|
||||||
|
throw new Error(`Photo missing in storage: ${storageKey}`);
|
||||||
|
}
|
||||||
|
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
|
||||||
|
throw new Error('Photo file missing on disk');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize to the gallery's standard resolution (#858) and/or watermark.
|
||||||
|
// This branch runs whenever the cached zip isn't usable — the first
|
||||||
|
// download after an invalidation, PIN clients, and galleries with
|
||||||
|
// hidden photos all land here, so skipping the cap would leak
|
||||||
|
// full-resolution files for exactly those cases.
|
||||||
|
const rendered = await renderPhotoForDownload(req.event, photo, bulkBox, effectiveSettings);
|
||||||
|
if (rendered) {
|
||||||
|
archive.append(rendered, { name: archiveName });
|
||||||
|
} else if (storageKey) {
|
||||||
|
const stream = await storage.get(storageKey);
|
||||||
|
archive.append(stream, { name: archiveName });
|
||||||
|
} else {
|
||||||
|
archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName });
|
||||||
|
}
|
||||||
|
appendedIds.push(photo.id);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Skipping photo in bulk download due to error', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId: photo.id,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification only after the response actually finished — finalize()
|
||||||
|
// ends Archiver's input, not the HTTP transfer (codex review of #849,
|
||||||
|
// confirmation round). Registered before finalize so it can't be missed.
|
||||||
|
// Admin preview (#868) streams the archive but is excluded from stats.
|
||||||
|
if (!req.isAdminPreview) {
|
||||||
|
res.on('finish', () => {
|
||||||
|
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await archive.finalize();
|
||||||
|
|
||||||
|
if (!req.isAdminPreview) {
|
||||||
|
// Log bulk download
|
||||||
|
await db('access_logs').insert({
|
||||||
|
event_id: req.event.id,
|
||||||
|
ip_address: req.ip,
|
||||||
|
user_agent: req.headers['user-agent'],
|
||||||
|
action: 'download_all'
|
||||||
|
});
|
||||||
|
// Exactly the photos that made it into this archive (#895) — skipped
|
||||||
|
// (missing/corrupt) sources don't count.
|
||||||
|
if (appendedIds.length > 0) {
|
||||||
|
db('photos').whereIn('id', appendedIds)
|
||||||
|
.increment('download_count', 1).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to create download archive');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download selected photos as ZIP
|
||||||
|
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if downloads are allowed for this event
|
||||||
|
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||||
|
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = Array.isArray(req.body?.photo_ids) ? req.body.photo_ids : [];
|
||||||
|
if (!ids.length) {
|
||||||
|
return res.status(400).json({ error: 'photo_ids is required (non-empty array)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean IDs
|
||||||
|
const photoIds = ids
|
||||||
|
.map((v) => parseInt(v, 10))
|
||||||
|
.filter((v) => Number.isInteger(v))
|
||||||
|
.slice(0, 500);
|
||||||
|
|
||||||
|
if (photoIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No valid photo IDs provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch photos — exclude photos in categories that disabled downloads (#640).
|
||||||
|
// Same LEFT JOIN pattern as the download-all endpoint.
|
||||||
|
const photos = await applyPhotoVisibilityFilter(
|
||||||
|
db('photos')
|
||||||
|
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||||
|
.where('photos.event_id', req.event.id)
|
||||||
|
.whereIn('photos.id', photoIds)
|
||||||
|
.where(function () {
|
||||||
|
this.whereNull('photos.category_id')
|
||||||
|
.orWhere('photo_categories.allow_downloads', true)
|
||||||
|
.orWhereNull('photo_categories.allow_downloads');
|
||||||
|
}),
|
||||||
|
req.accessLevel
|
||||||
|
)
|
||||||
|
.select('photos.*')
|
||||||
|
.orderBy('photos.uploaded_at', 'desc');
|
||||||
|
|
||||||
|
if (photos.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'No photos found for selected IDs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download resolution (#858). Resolve BEFORE any header goes out — once
|
||||||
|
// the archive starts streaming we can no longer return a JSON error.
|
||||||
|
const selectedPolicy = await resolveEventDownloadPolicy(req.event);
|
||||||
|
const selectedResolution = pickRequestedResolution(selectedPolicy, req.body?.resolution);
|
||||||
|
if (selectedResolution === null) {
|
||||||
|
return res.status(400).json({ error: 'Resolution not available for this gallery' });
|
||||||
|
}
|
||||||
|
const selectedBox = parseResolution(selectedResolution);
|
||||||
|
|
||||||
|
const archiveName = `${req.event.slug}-selected.zip`;
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
|
||||||
|
|
||||||
|
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||||
|
archive.on('error', (err) => {
|
||||||
|
logger.error('Zip error generating selected download', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
eventId: req.event?.id,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
res.status(500).end();
|
||||||
|
} catch (_) {
|
||||||
|
// ignore double-send errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
archive.pipe(res);
|
||||||
|
|
||||||
|
// Check watermark settings - apply if global setting OR event-level setting is enabled
|
||||||
|
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||||
|
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
|
||||||
|
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
|
||||||
|
const effectiveSettings = shouldApplyWatermark ? {
|
||||||
|
...watermarkSettings,
|
||||||
|
enabled: true,
|
||||||
|
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../../services/photoResolver');
|
||||||
|
const selectedStorage = getStorage();
|
||||||
|
// #493: same display-name resolution as bulk download, with dedup.
|
||||||
|
const useOriginalSelected = await getUseOriginalFilenames();
|
||||||
|
const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected);
|
||||||
|
// Only photos whose append succeeded count as downloaded (#895).
|
||||||
|
const appendedIds = [];
|
||||||
|
for (let i = 0; i < photos.length; i += 1) {
|
||||||
|
const photo = photos[i];
|
||||||
|
const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`;
|
||||||
|
const storageKey = resolveSelectedKey(req.event, photo);
|
||||||
|
try {
|
||||||
|
// Same pre-append source check as download-all (#895 review),
|
||||||
|
// local backend only: a lazy fs stream's async error would kill
|
||||||
|
// the response instead of skipping the photo; S3's get() rejects
|
||||||
|
// at the await below, so no redundant per-entry HEAD there.
|
||||||
|
if (storageKey && selectedStorage.kind() === 'local') {
|
||||||
|
const srcStat = await selectedStorage.stat(storageKey);
|
||||||
|
if (!srcStat) {
|
||||||
|
throw new Error(`Photo missing in storage: ${storageKey}`);
|
||||||
|
}
|
||||||
|
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
|
||||||
|
throw new Error('Photo file missing on disk');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize (#858) and/or watermark. renderPhotoForDownload returns null
|
||||||
|
// when neither applies, so the untransformed case still streams from
|
||||||
|
// storage rather than buffering the whole photo.
|
||||||
|
const rendered = await renderPhotoForDownload(req.event, photo, selectedBox, effectiveSettings);
|
||||||
|
if (rendered) {
|
||||||
|
archive.append(rendered, { name });
|
||||||
|
} else if (storageKey) {
|
||||||
|
const stream = await selectedStorage.get(storageKey);
|
||||||
|
archive.append(stream, { name });
|
||||||
|
} else {
|
||||||
|
archive.file(resolvePhotoFilePath(req.event, photo), { name });
|
||||||
|
}
|
||||||
|
appendedIds.push(photo.id);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Skipping selected photo due to error', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId: photo.id,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// See download-all: notify only on response 'finish'.
|
||||||
|
// Admin preview (#868) streams the archive but is excluded from stats.
|
||||||
|
if (!req.isAdminPreview) {
|
||||||
|
res.on('finish', () => {
|
||||||
|
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'selected', photo_count: photoIds.length }, req.event.id, galleryActor(req));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await archive.finalize();
|
||||||
|
|
||||||
|
if (!req.isAdminPreview) {
|
||||||
|
await db('access_logs').insert({
|
||||||
|
event_id: req.event.id,
|
||||||
|
ip_address: req.ip,
|
||||||
|
user_agent: req.headers['user-agent'],
|
||||||
|
action: 'download_selected'
|
||||||
|
});
|
||||||
|
// Exactly the photos that made it into this archive (#895) — skipped
|
||||||
|
// (missing/corrupt) sources don't count.
|
||||||
|
if (appendedIds.length > 0) {
|
||||||
|
db('photos').whereIn('id', appendedIds)
|
||||||
|
.increment('download_count', 1).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to download selected photos');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Custom-resolution download jobs (#858).
|
||||||
|
//
|
||||||
|
// The plain download-all is served from the pre-built cache at the gallery's
|
||||||
|
// STANDARD resolution. Picking a different size has nothing to cache against,
|
||||||
|
// and resizing a whole gallery inside one request would sit far past any
|
||||||
|
// reverse-proxy timeout — so those archives are built as a job the client
|
||||||
|
// polls. Same access rules as the download routes above.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Kick off (or join) a build. Returns the polling token.
|
||||||
|
router.post('/:slug/download-jobs', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||||
|
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = await resolveEventDownloadPolicy(req.event);
|
||||||
|
if (!policy.pickerEnabled) {
|
||||||
|
return res.status(403).json({ error: 'Resolution choice is not enabled for this gallery' });
|
||||||
|
}
|
||||||
|
const resolution = pickRequestedResolution(policy, req.body?.resolution);
|
||||||
|
if (resolution === null) {
|
||||||
|
return res.status(400).json({ error: 'Resolution not available for this gallery' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional subset. Absent = the whole visible gallery.
|
||||||
|
let photoIds = null;
|
||||||
|
if (Array.isArray(req.body?.photo_ids) && req.body.photo_ids.length) {
|
||||||
|
photoIds = req.body.photo_ids
|
||||||
|
.map((v) => parseInt(v, 10))
|
||||||
|
.filter((v) => Number.isInteger(v))
|
||||||
|
.slice(0, 500);
|
||||||
|
if (photoIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No valid photo IDs provided' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let job;
|
||||||
|
try {
|
||||||
|
job = await downloadJobService.createJob({
|
||||||
|
event: req.event,
|
||||||
|
resolution,
|
||||||
|
photoIds,
|
||||||
|
accessLevel: req.accessLevel,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'NO_PHOTOS') {
|
||||||
|
return res.status(404).json({ error: 'No photos available for this selection' });
|
||||||
|
}
|
||||||
|
if (err.code === 'BUSY') {
|
||||||
|
return res.status(429).json({ error: 'Too many downloads are being prepared right now — please try again shortly' });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(202).json({
|
||||||
|
token: job.token,
|
||||||
|
status: job.status,
|
||||||
|
resolution: job.resolution,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to start download preparation');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll. The token is unguessable, but it is never sufficient on its own —
|
||||||
|
// verifyGalleryAccess still runs and the job must belong to THIS event.
|
||||||
|
// no-store: a cached 'preparing' would strand the caller in a poll that can
|
||||||
|
// never observe the job finishing.
|
||||||
|
router.get('/:slug/download-jobs/:token', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, noStoreCache, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const job = await downloadJobService.getStatus(req.params.token);
|
||||||
|
if (!job || job.event_id !== req.event.id) {
|
||||||
|
return res.status(404).json({ error: 'Download job not found' });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
status: job.status,
|
||||||
|
resolution: job.resolution,
|
||||||
|
photo_count: job.photo_count || 0,
|
||||||
|
size_bytes: job.size_bytes || null,
|
||||||
|
error: job.status === 'failed' ? (job.error || 'Preparation failed') : undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to read download job');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deliver the finished archive.
|
||||||
|
router.get('/:slug/download-jobs/:token/file', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Downloads can be switched off after a job was created — every other
|
||||||
|
// download route re-checks this per request, so this one must too.
|
||||||
|
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||||
|
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await downloadJobService.getStatus(req.params.token);
|
||||||
|
if (!job || job.event_id !== req.event.id) {
|
||||||
|
return res.status(404).json({ error: 'Download job not found' });
|
||||||
|
}
|
||||||
|
// The token alone never grants access: the archive was built under one
|
||||||
|
// visibility scope, and only a requester still in that scope may take it.
|
||||||
|
// Without this, a leaked client token would hand hidden photos to a guest.
|
||||||
|
if (job.visibility_scope !== downloadJobService.visibilityScopeFor(req.accessLevel)) {
|
||||||
|
return res.status(404).json({ error: 'Download job not found' });
|
||||||
|
}
|
||||||
|
if (job.status !== 'ready' || !job.zip_path) {
|
||||||
|
return res.status(409).json({ error: 'Download is not ready yet', status: job.status });
|
||||||
|
}
|
||||||
|
if (new Date(job.expires_at).getTime() <= Date.now()) {
|
||||||
|
return res.status(410).json({ error: 'This download has expired — please request it again' });
|
||||||
|
}
|
||||||
|
// A photo hidden AFTER this archive was built is still inside it, and the
|
||||||
|
// scope check above can't see that — both sides remain 'public'. Re-run
|
||||||
|
// the visibility query over the packaged set before handing it over.
|
||||||
|
if (!(await downloadJobService.isStillDeliverable(job, req.event, req.accessLevel))) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'This gallery changed since the download was prepared — please request it again',
|
||||||
|
status: 'stale',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
const stat = await storage.stat(job.zip_path);
|
||||||
|
if (!stat) {
|
||||||
|
return res.status(410).json({ error: 'This download is no longer available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats parity with the other bulk paths (#895): only count once the
|
||||||
|
// response actually completed, and keep admin previews out of guest stats.
|
||||||
|
res.on('finish', () => {
|
||||||
|
if (res.statusCode >= 400 || req.isAdminPreview) return;
|
||||||
|
// The DELIVERED set, not the requested one: a photo whose source was
|
||||||
|
// missing at build time isn't in the zip and must not be counted.
|
||||||
|
let ids = [];
|
||||||
|
try {
|
||||||
|
ids = JSON.parse(job.delivered_photo_ids || job.photo_ids || '[]');
|
||||||
|
} catch (_) { /* malformed row — skip counting rather than fail */ }
|
||||||
|
if (ids.length > 0) {
|
||||||
|
db('photos').whereIn('id', ids).increment('download_count', 1).catch(() => {});
|
||||||
|
}
|
||||||
|
db('access_logs').insert({
|
||||||
|
event_id: req.event.id,
|
||||||
|
ip_address: req.ip,
|
||||||
|
user_agent: req.headers['user-agent'],
|
||||||
|
action: 'download',
|
||||||
|
photo_id: null,
|
||||||
|
}).catch(() => {});
|
||||||
|
logActivity('gallery_downloaded', { scope: 'all', resolution: job.resolution },
|
||||||
|
req.event.id, galleryActor(req));
|
||||||
|
});
|
||||||
|
|
||||||
|
const suffix = job.resolution === 'original' ? 'original' : job.resolution;
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Length', stat.size);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}-${suffix}.zip"`);
|
||||||
|
const stream = await storage.get(job.zip_path);
|
||||||
|
pipeStreamToResponse(stream, res, { context: `download job ${job.id}`, missingStatus: 410 });
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to serve prepared download');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Explicit per-photo view beacon (#895). Counting views on the image-
|
||||||
|
// serving routes is wrong in both directions: the lightbox preloads the
|
||||||
|
// prev/next neighbours (three fetches per open), while a preloaded
|
||||||
|
// neighbour that becomes the current slide is never re-fetched (#505
|
||||||
|
// keeps the DOM node alive across the swipe) — so request-level counters
|
||||||
|
// overcount preloads AND undercount swipe-throughs. Instead the lightbox
|
||||||
|
// pings this endpoint exactly when a photo becomes the visible slide.
|
||||||
|
// This also covers enhanced/maximum-protection galleries, whose bytes
|
||||||
|
// are served by /api/secure-images and never pass the routes below.
|
||||||
|
// The slideshow kiosk is excluded (denySlideshowToken; migration 138).
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { db } = require('../../database/db');
|
||||||
|
const path = require('path');
|
||||||
|
const { resolvePhotoContentType } = require('../../utils/photoContentType');
|
||||||
|
const router = express.Router();
|
||||||
|
const watermarkService = require('../../services/watermarkService');
|
||||||
|
const watermarkGeneratorService = require('../../services/watermarkGeneratorService');
|
||||||
|
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
|
||||||
|
const withPreview = (req, url) => (req.isAdminPreview ? `${url}${url.includes('?') ? '&' : '?'}admin_preview=1` : url);
|
||||||
|
const secureImageService = require('../../services/secureImageService');
|
||||||
|
const logger = require('../../utils/logger');
|
||||||
|
const { pipeStreamToResponse } = require('../../utils/streamResponse');
|
||||||
|
|
||||||
|
const { errorResponse } = require('../../utils/routeHelpers');
|
||||||
|
const { blockHiddenGallery } = require('../../utils/revealMode');
|
||||||
|
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../../services/imageProcessor');
|
||||||
|
const { getStorage } = require('../../services/storage');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { getStoragePath } = require('../../config/storage');
|
||||||
|
|
||||||
|
router.post('/:slug/photo/:photoId/view',
|
||||||
|
verifyGalleryAccess,
|
||||||
|
denySlideshowToken,
|
||||||
|
blockHiddenGallery,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const photo = await db('photos')
|
||||||
|
.where({ id: req.params.photoId, event_id: req.event.id })
|
||||||
|
.first('id', 'visibility');
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Photo not available' });
|
||||||
|
}
|
||||||
|
// Admin preview (#981 review) is excluded from per-photo view analytics.
|
||||||
|
if (!req.isAdminPreview) {
|
||||||
|
await db('photos').where('id', photo.id).increment('view_count', 1);
|
||||||
|
}
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to record view');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// View single photo (with watermark if enabled)
|
||||||
|
router.get('/:slug/photo/:photoId',
|
||||||
|
verifyGalleryAccess,
|
||||||
|
blockHiddenGallery,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { photoId } = req.params;
|
||||||
|
|
||||||
|
const photo = await db('photos')
|
||||||
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block guest access to hidden photos
|
||||||
|
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Photo not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a video
|
||||||
|
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||||
|
|
||||||
|
// Check protection level - basic and standard protection allow direct JWT access
|
||||||
|
const protectionLevel = req.event.protection_level || 'standard';
|
||||||
|
|
||||||
|
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
|
||||||
|
// For enhanced/maximum protection, redirect to secure endpoint
|
||||||
|
return res.status(302).json({
|
||||||
|
error: 'Secure access required',
|
||||||
|
secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`,
|
||||||
|
photoId: photoId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve where to read the photo bytes from. For external/reference
|
||||||
|
// photos the source is always a local mount path. For managed photos
|
||||||
|
// we go through the storage abstraction so S3 deployments work too
|
||||||
|
// (#432 — previously this route did fs.* directly and 500'd in S3
|
||||||
|
// mode because the file wasn't on the container's local fs).
|
||||||
|
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../../services/photoResolver');
|
||||||
|
const storage = getStorage();
|
||||||
|
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
|
||||||
|
const useStorageBackend = !isExternal;
|
||||||
|
|
||||||
|
let filePath = null; // Local fs path (external photos OR LocalFs storage)
|
||||||
|
let storageKey = null; // Relative storage key (managed photos via storage abstraction)
|
||||||
|
let stat;
|
||||||
|
let fileSize;
|
||||||
|
|
||||||
|
if (useStorageBackend) {
|
||||||
|
try {
|
||||||
|
storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||||
|
} catch (resolveError) {
|
||||||
|
logger.error('Failed to resolve photo storage key', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: resolveError.message,
|
||||||
|
photoPath: photo.path,
|
||||||
|
photoFilename: photo.filename
|
||||||
|
});
|
||||||
|
return res.status(404).json({ error: 'Photo file not found' });
|
||||||
|
}
|
||||||
|
stat = await storage.stat(storageKey);
|
||||||
|
if (!stat) {
|
||||||
|
logger.error('Photo not found in storage backend', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
storageKey
|
||||||
|
});
|
||||||
|
return res.status(404).json({ error: 'Photo file not found' });
|
||||||
|
}
|
||||||
|
fileSize = stat.size;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
filePath = resolvePhotoFilePath(req.event, photo);
|
||||||
|
} catch (resolveError) {
|
||||||
|
logger.error('Failed to resolve photo path', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: resolveError.message,
|
||||||
|
photoPath: photo.path,
|
||||||
|
photoFilename: photo.filename
|
||||||
|
});
|
||||||
|
return res.status(404).json({ error: 'Photo file not found' });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
logger.error('Photo file does not exist at resolved path', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
resolvedPath: filePath,
|
||||||
|
photoPath: photo.path
|
||||||
|
});
|
||||||
|
return res.status(404).json({ error: 'Photo file not found' });
|
||||||
|
}
|
||||||
|
stat = fs.statSync(filePath);
|
||||||
|
fileSize = stat.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle video streaming with range requests
|
||||||
|
if (isVideo) {
|
||||||
|
const range = req.headers.range;
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
const parts = range.replace(/bytes=/, '').split('-');
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||||
|
// Validate before writing the 206: a NaN, inverted or out-of-file
|
||||||
|
// range used to be committed to the headers and then throw while
|
||||||
|
// streaming (or read past the end).
|
||||||
|
if (!Number.isInteger(start) || !Number.isInteger(end)
|
||||||
|
|| start < 0 || end < start || start >= fileSize) {
|
||||||
|
res.set('Content-Range', `bytes */${fileSize}`);
|
||||||
|
return res.status(416).end();
|
||||||
|
}
|
||||||
|
const boundedEnd = Math.min(end, fileSize - 1);
|
||||||
|
const chunksize = (boundedEnd - start) + 1;
|
||||||
|
|
||||||
|
res.writeHead(206, {
|
||||||
|
'Content-Range': `bytes ${start}-${boundedEnd}/${fileSize}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': chunksize,
|
||||||
|
'Content-Type': resolvePhotoContentType(photo),
|
||||||
|
'Cache-Control': 'private, max-age=1800',
|
||||||
|
'X-Protection-Level': 'basic'
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = useStorageBackend
|
||||||
|
? await storage.getRange(storageKey, start, boundedEnd)
|
||||||
|
: fs.createReadStream(filePath, { start, end: boundedEnd });
|
||||||
|
pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` });
|
||||||
|
} else {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Length': fileSize,
|
||||||
|
'Content-Type': resolvePhotoContentType(photo),
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Cache-Control': 'private, max-age=1800',
|
||||||
|
'X-Protection-Level': 'basic'
|
||||||
|
});
|
||||||
|
const file = useStorageBackend
|
||||||
|
? await storage.get(storageKey)
|
||||||
|
: fs.createReadStream(filePath);
|
||||||
|
pipeStreamToResponse(file, res, { context: `video for photo ${photo.id}` });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image path
|
||||||
|
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||||
|
|
||||||
|
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||||
|
const watermarkHash = watermarkSettings?.enabled
|
||||||
|
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||||
|
: '-nowm';
|
||||||
|
// orientation_checked_at participates because the backfill (#1198) can
|
||||||
|
// change these bytes without touching either of the other two inputs:
|
||||||
|
// it rewrites the derived renditions while the ORIGINAL's mtime and the
|
||||||
|
// watermark settings both stay exactly as they were. Without it a guest
|
||||||
|
// holding a pre-fix ETag keeps getting 304 and keeps their cached
|
||||||
|
// sideways image, however many times the backfill succeeds.
|
||||||
|
const orientationVersion = photo.orientation_checked_at
|
||||||
|
? `-o${new Date(photo.orientation_checked_at).getTime()}`
|
||||||
|
: '';
|
||||||
|
const etag = `"${photoId}-${mtimeMs}${watermarkHash}${orientationVersion}"`;
|
||||||
|
|
||||||
|
if (req.headers['if-none-match'] === etag) {
|
||||||
|
return res.status(304).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watermarkSettings && watermarkSettings.enabled) {
|
||||||
|
// Pre-generated watermarked file: served via the storage backend
|
||||||
|
// (managed) or directly from local fs (external).
|
||||||
|
if (photo.watermark_path) {
|
||||||
|
try {
|
||||||
|
if (useStorageBackend) {
|
||||||
|
const wmStat = await storage.stat(photo.watermark_path);
|
||||||
|
if (wmStat) {
|
||||||
|
res.set({
|
||||||
|
'Content-Type': resolvePhotoContentType(photo),
|
||||||
|
'Content-Length': wmStat.size,
|
||||||
|
'Cache-Control': 'private, max-age=1800',
|
||||||
|
'ETag': etag,
|
||||||
|
'X-Protection-Level': 'basic'
|
||||||
|
});
|
||||||
|
const wmStream = await storage.get(photo.watermark_path);
|
||||||
|
return pipeStreamToResponse(wmStream, res, { context: `watermarked photo ${photo.id}` });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
|
||||||
|
if (fs.existsSync(watermarkFilePath)) {
|
||||||
|
res.set({
|
||||||
|
'Content-Type': resolvePhotoContentType(photo),
|
||||||
|
'Cache-Control': 'private, max-age=1800',
|
||||||
|
'ETag': etag,
|
||||||
|
'X-Protection-Level': 'basic'
|
||||||
|
});
|
||||||
|
return res.sendFile(watermarkFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: apply watermark on-the-fly. applyWatermark needs a
|
||||||
|
// local file path (sharp + fs.readFile) — for managed photos in
|
||||||
|
// S3 mode, withLocalCopy materializes to a tmp file and cleans up.
|
||||||
|
const watermarkedBuffer = useStorageBackend
|
||||||
|
? await withLocalCopy(storageKey, (localPath) =>
|
||||||
|
watermarkService.applyWatermark(localPath, watermarkSettings))
|
||||||
|
: await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||||
|
|
||||||
|
// Queue watermark generation in background for next request
|
||||||
|
watermarkGeneratorService.generateForPhoto(photo.id)
|
||||||
|
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': resolvePhotoContentType(photo),
|
||||||
|
'Cache-Control': 'private, max-age=1800',
|
||||||
|
'ETag': etag,
|
||||||
|
'X-Protection-Level': 'basic'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send(watermarkedBuffer);
|
||||||
|
} else {
|
||||||
|
res.set({
|
||||||
|
'Cache-Control': 'private, max-age=1800',
|
||||||
|
'ETag': etag,
|
||||||
|
'X-Protection-Level': 'basic'
|
||||||
|
});
|
||||||
|
if (useStorageBackend) {
|
||||||
|
res.set('Content-Length', stat.size);
|
||||||
|
res.set('Content-Type', resolvePhotoContentType(photo));
|
||||||
|
const stream = await storage.get(storageKey);
|
||||||
|
pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` });
|
||||||
|
} else {
|
||||||
|
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
||||||
|
res.sendFile(absolutePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to serve photo');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Serve thumbnail
|
||||||
|
router.get('/:slug/thumbnail/:photoId',
|
||||||
|
verifyGalleryAccess,
|
||||||
|
blockHiddenGallery,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { photoId } = req.params;
|
||||||
|
|
||||||
|
const photo = await db('photos')
|
||||||
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block guest access to hidden photos
|
||||||
|
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Photo not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||||
|
// Responsive tier (#1095), whitelisted the same way the preview route's
|
||||||
|
// is. Unrecognised or absent falls through to the canonical 300px
|
||||||
|
// thumbnail, so existing clients are untouched.
|
||||||
|
const { THUMBNAIL_WIDTHS, normalizeTierWidth, ensureThumbnailAtWidth } =
|
||||||
|
require('../../services/imageProcessor');
|
||||||
|
const thumbTier = normalizeTierWidth(req.query.w, THUMBNAIL_WIDTHS);
|
||||||
|
|
||||||
|
const thumbnailPath = thumbTier
|
||||||
|
? (await ensureThumbnailAtWidth(photo, thumbTier)) || (await ensureThumbnail(photo))
|
||||||
|
: await ensureThumbnail(photo);
|
||||||
|
|
||||||
|
// What was actually resolved, not what was asked for. A tier request can
|
||||||
|
// land on the canonical thumbnail — generation failed, or the row is a
|
||||||
|
// video — and stamping the requested tier into the ETag below would then
|
||||||
|
// have the client cache a 300px image under its 900px key for the full
|
||||||
|
// max-age, with no way to notice.
|
||||||
|
const servedTier = thumbTier && thumbnailPath
|
||||||
|
&& path.basename(thumbnailPath).startsWith(`thumb_w${thumbTier}_`)
|
||||||
|
? thumbTier
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!thumbnailPath) {
|
||||||
|
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||||
|
return res.status(404).json({ error: 'Thumbnail generation failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read thumbnail metadata via the storage abstraction so we work in
|
||||||
|
// both LocalFs and S3 modes (#432). The previous fs.statSync on the
|
||||||
|
// resolved local path 500'd in S3 deployments because the thumbnail
|
||||||
|
// only exists in the bucket, not on the container's local fs.
|
||||||
|
const storage = getStorage();
|
||||||
|
const stat = await storage.stat(thumbnailPath);
|
||||||
|
if (!stat) {
|
||||||
|
logger.error(`Thumbnail not found in storage backend for photo ${photoId}`, { thumbnailPath });
|
||||||
|
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log thumbnail access
|
||||||
|
await secureImageService.logImageAccess(
|
||||||
|
photoId,
|
||||||
|
req.event.id,
|
||||||
|
req.clientInfo,
|
||||||
|
'thumbnail'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if watermarks are enabled and apply to thumbnail
|
||||||
|
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||||
|
|
||||||
|
// ETag uses storage stat mtime + photo id + watermark hash.
|
||||||
|
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||||
|
const watermarkHash = watermarkSettings?.enabled
|
||||||
|
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||||
|
: '-nowm';
|
||||||
|
// Tier in the ETag, same reason as the preview route: without it a
|
||||||
|
// client holding the 300px thumbnail gets a 304 for its 600px request
|
||||||
|
// and renders the small one, which is this feature inverted.
|
||||||
|
const etag = `"thumb-${photoId}-${servedTier || 'def'}-${mtimeMs}${watermarkHash}"`;
|
||||||
|
|
||||||
|
// Check if client has valid cached version
|
||||||
|
if (req.headers['if-none-match'] === etag) {
|
||||||
|
return res.status(304).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set appropriate headers with enhanced security
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'image/jpeg',
|
||||||
|
'Cache-Control': 'private, max-age=1800', // Reduced cache time
|
||||||
|
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'X-Protected-Thumbnail': 'true',
|
||||||
|
'ETag': etag
|
||||||
|
});
|
||||||
|
|
||||||
|
if (watermarkSettings && watermarkSettings.enabled) {
|
||||||
|
// Watermarking needs a local file path (sharp + fs.readFile).
|
||||||
|
// Materialize via withLocalCopy — no-op in local mode, downloads
|
||||||
|
// to a tmp file then cleans up in S3 mode.
|
||||||
|
const watermarkedBuffer = await withLocalCopy(thumbnailPath, (localPath) =>
|
||||||
|
watermarkService.applyWatermark(localPath, watermarkSettings)
|
||||||
|
);
|
||||||
|
res.send(watermarkedBuffer);
|
||||||
|
} else {
|
||||||
|
res.setHeader('Content-Length', stat.size);
|
||||||
|
const stream = await storage.get(thumbnailPath);
|
||||||
|
pipeStreamToResponse(stream, res, { context: `thumbnail for photo ${photoId}` });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to serve thumbnail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Serve hero-optimized image (1920x1080 for full-width hero sections)
|
||||||
|
router.get('/:slug/hero/:photoId',
|
||||||
|
verifyGalleryAccess,
|
||||||
|
// Reveal-gated too: this route serves a 1920px derivative of ANY photo id,
|
||||||
|
// not just the chosen hero — an open bypass while hidden (review round 1).
|
||||||
|
blockHiddenGallery,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { photoId } = req.params;
|
||||||
|
|
||||||
|
const photo = await db('photos')
|
||||||
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block guest access to hidden photos
|
||||||
|
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Photo not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a video - videos don't get hero images
|
||||||
|
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||||
|
if (isVideo) {
|
||||||
|
// For videos, redirect to the regular photo endpoint
|
||||||
|
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure hero image exists and is valid, regenerate if needed
|
||||||
|
const heroPath = await ensureHeroImage(photo);
|
||||||
|
|
||||||
|
if (!heroPath) {
|
||||||
|
// If hero generation fails, fall back to original photo
|
||||||
|
logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`);
|
||||||
|
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hero images are always written via the storage abstraction (see
|
||||||
|
// imageProcessor.generateHeroImage), so they're a managed-storage
|
||||||
|
// key in both LocalFs and S3 modes (#432). Read via storage.
|
||||||
|
const storage = getStorage();
|
||||||
|
const stat = await storage.stat(heroPath);
|
||||||
|
if (!stat) {
|
||||||
|
logger.error('Hero image file does not exist in storage backend', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
heroPath
|
||||||
|
});
|
||||||
|
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||||
|
const etag = `"hero-${photoId}-${mtimeMs}"`;
|
||||||
|
if (req.headers['if-none-match'] === etag) {
|
||||||
|
return res.status(304).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'image/jpeg',
|
||||||
|
'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
|
||||||
|
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'X-Hero-Image': 'true',
|
||||||
|
'ETag': etag
|
||||||
|
});
|
||||||
|
|
||||||
|
if (watermarkSettings && watermarkSettings.enabled) {
|
||||||
|
// applyWatermark needs a local file path; materialize via
|
||||||
|
// withLocalCopy so this works in S3 mode too.
|
||||||
|
const watermarkedBuffer = await withLocalCopy(heroPath, (localPath) =>
|
||||||
|
watermarkService.applyWatermark(localPath, watermarkSettings)
|
||||||
|
);
|
||||||
|
res.send(watermarkedBuffer);
|
||||||
|
} else {
|
||||||
|
res.setHeader('Content-Length', stat.size);
|
||||||
|
const stream = await storage.get(heroPath);
|
||||||
|
pipeStreamToResponse(stream, res, { context: `hero for photo ${photoId}` });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error serving hero image:', {
|
||||||
|
error: error.message,
|
||||||
|
photoId: req.params.photoId,
|
||||||
|
eventId: req.event?.id
|
||||||
|
});
|
||||||
|
// Fall back to original photo on any error
|
||||||
|
res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lightbox preview tier (#492). Aspect-preserved JPEG capped at 1920px
|
||||||
|
// long edge — admin-controlled opt-in via app_settings.lightbox_preview_enabled.
|
||||||
|
// Mirrors the hero route shape: same auth, ETag from preview mtime,
|
||||||
|
// fall back to original on any failure so the lightbox never shows a
|
||||||
|
// broken image. The watermark application path is preserved so a
|
||||||
|
// preview surfaced in the lightbox carries the same protection a
|
||||||
|
// guest would see on the full original.
|
||||||
|
router.get('/:slug/preview/:photoId',
|
||||||
|
verifyGalleryAccess,
|
||||||
|
blockHiddenGallery,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { photoId } = req.params;
|
||||||
|
|
||||||
|
const photo = await db('photos')
|
||||||
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Photo not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Videos don't get a preview tier — fall through to the regular
|
||||||
|
// photo endpoint (which serves the source). The frontend should
|
||||||
|
// already be checking media_type before requesting /preview but
|
||||||
|
// belt-and-braces in case a stale tab does.
|
||||||
|
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||||
|
if (isVideo) {
|
||||||
|
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive tier (#1095). Whitelisted only — an open ?w= would let
|
||||||
|
// anyone fill the disk with renditions nobody asked for. An unrecognised
|
||||||
|
// or absent value falls through to the canonical 1920 preview, so old
|
||||||
|
// clients and hand-typed URLs behave exactly as before.
|
||||||
|
const { PREVIEW_WIDTHS, normalizeTierWidth, ensurePreviewImageAtWidth } =
|
||||||
|
require('../../services/imageProcessor');
|
||||||
|
const tierWidth = normalizeTierWidth(req.query.w, PREVIEW_WIDTHS);
|
||||||
|
|
||||||
|
// Lazy generation: ensurePreviewImage returns null on any
|
||||||
|
// failure (corrupt source, sharp OOM, storage unavailable, …).
|
||||||
|
// Fall back to the original so the lightbox always renders.
|
||||||
|
const previewPath = tierWidth
|
||||||
|
? (await ensurePreviewImageAtWidth(photo, tierWidth)) || (await ensurePreviewImage(photo))
|
||||||
|
: await ensurePreviewImage(photo);
|
||||||
|
if (!previewPath) {
|
||||||
|
logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`);
|
||||||
|
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
const stat = await storage.stat(previewPath);
|
||||||
|
if (!stat) {
|
||||||
|
logger.error('Preview file does not exist in storage backend', {
|
||||||
|
slug: req.params.slug, photoId, eventId: req.event.id, previewPath,
|
||||||
|
});
|
||||||
|
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||||
|
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||||
|
const watermarkHash = watermarkSettings?.enabled
|
||||||
|
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||||
|
: '-nowm';
|
||||||
|
// Tier is part of the etag: without it a client that already holds the
|
||||||
|
// 1920 rendition would get a 304 for its 640 request and render the
|
||||||
|
// wrong size, which is the whole point of the feature inverted.
|
||||||
|
const etag = `"preview-${photoId}-${tierWidth || 'def'}-${mtimeMs}${watermarkHash}"`;
|
||||||
|
if (req.headers['if-none-match'] === etag) {
|
||||||
|
return res.status(304).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
// From the key, not hard-coded: a preview of a transparent or animated
|
||||||
|
// source is WebP, because JPEG carries neither. `nosniff` below means
|
||||||
|
// getting this wrong shows a broken image rather than being silently
|
||||||
|
// corrected by the browser. Pre-existing keys have no .webp suffix and
|
||||||
|
// are JPEG, so they keep their old header.
|
||||||
|
'Content-Type': previewPath.endsWith('.webp') ? 'image/webp' : 'image/jpeg',
|
||||||
|
// Cache aggressively — preview only changes on photo
|
||||||
|
// re-upload (which generates a new preview key) or settings
|
||||||
|
// regenerate (which writes a new mtime + ETag).
|
||||||
|
'Cache-Control': 'private, max-age=3600',
|
||||||
|
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'X-Preview-Image': 'true',
|
||||||
|
'ETag': etag,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (watermarkSettings && watermarkSettings.enabled) {
|
||||||
|
// No Content-Type override here. applyWatermark PRESERVES the source
|
||||||
|
// format (watermarkService.js: png -> png, webp -> webp, else jpeg),
|
||||||
|
// and its input is this preview — so the output format matches the key
|
||||||
|
// the header was already derived from. Forcing image/jpeg would
|
||||||
|
// mislabel a watermarked WebP preview, and `nosniff` means the browser
|
||||||
|
// will not correct it.
|
||||||
|
//
|
||||||
|
// What is still lost is the animation: the compositor flattens a
|
||||||
|
// multi-frame source to one frame while keeping the WebP container.
|
||||||
|
// That is a separate problem and a much larger one.
|
||||||
|
const watermarkedBuffer = await withLocalCopy(previewPath, (localPath) =>
|
||||||
|
watermarkService.applyWatermark(localPath, watermarkSettings)
|
||||||
|
);
|
||||||
|
res.send(watermarkedBuffer);
|
||||||
|
} else {
|
||||||
|
res.setHeader('Content-Length', stat.size);
|
||||||
|
const stream = await storage.get(previewPath);
|
||||||
|
pipeStreamToResponse(stream, res, { context: `preview for photo ${photoId}` });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error serving preview image:', {
|
||||||
|
error: error.message,
|
||||||
|
photoId: req.params.photoId,
|
||||||
|
eventId: req.event?.id,
|
||||||
|
});
|
||||||
|
res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /:slug/feedback-settings lives in galleryFeedback.js. A duplicate of it
|
||||||
|
// used to sit here, and since server.js mounts galleryRoutes before
|
||||||
|
// galleryFeedback it shadowed the real handler — dropping the per-guest caps
|
||||||
|
// (#655) from the guest payload, so the gallery could never render the
|
||||||
|
// favorite/like limits or their counters (#1030).
|
||||||
|
|
||||||
|
// Get photo stats. no-store: view/download/visitor counters are private
|
||||||
|
// gallery analytics and change on every request.
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
const { isGalleryExpired } = require('../../utils/galleryLifecycle');
|
||||||
|
const express = require('express');
|
||||||
|
const { db } = require('../../database/db');
|
||||||
|
const { formatBoolean } = require('../../utils/dbCompat');
|
||||||
|
const { getAppSetting } = require('../../utils/appSettings');
|
||||||
|
const { timingSafeEqualStr } = require('../../utils/timingSafe');
|
||||||
|
const router = express.Router();
|
||||||
|
const { resolveHeroLogoVisible } = require('../../services/galleryModel');
|
||||||
|
const { verifyAdminPreview } = require('../../middleware/gallery');
|
||||||
|
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||||
|
const logger = require('../../utils/logger');
|
||||||
|
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||||
|
const { handleAsync, errorResponse } = require('../../utils/routeHelpers');
|
||||||
|
const { isGalleryHidden } = require('../../utils/revealMode');
|
||||||
|
const { NotFoundError } = require('../../utils/errors');
|
||||||
|
async function checkSlugRedirect(slug) {
|
||||||
|
try {
|
||||||
|
const hasTable = await db.schema.hasTable('slug_redirects');
|
||||||
|
if (!hasTable) return null;
|
||||||
|
|
||||||
|
const redirect = await db('slug_redirects')
|
||||||
|
.where({ old_slug: slug })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return redirect ? redirect.new_slug : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error checking slug redirect:', { slug, error: error.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/resolve/:identifier', handleAsync(async (req, res) => {
|
||||||
|
const { identifier } = req.params;
|
||||||
|
let result = await resolveShareIdentifier(identifier);
|
||||||
|
|
||||||
|
// If not found, check for redirect
|
||||||
|
if (!result) {
|
||||||
|
const newSlug = await checkSlugRedirect(identifier);
|
||||||
|
if (newSlug) {
|
||||||
|
return res.status(301).json({
|
||||||
|
redirect: true,
|
||||||
|
newSlug,
|
||||||
|
message: 'Gallery has been renamed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new NotFoundError('Gallery');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { event, matchType, shareToken } = result;
|
||||||
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
|
||||||
|
// The share_token is a bearer secret. Only return it (and the share
|
||||||
|
// links/URLs that embed it) when the caller already proved they hold it —
|
||||||
|
// i.e. they resolved via the token or the full share link. A bare *slug*
|
||||||
|
// lookup (slugs appear in gallery URLs and are guessable) must NOT hand
|
||||||
|
// back the secret, or an anonymous caller could turn a known slug into
|
||||||
|
// share-link access to a no-password gallery (GHSA-rh8r).
|
||||||
|
const callerHasToken = matchType !== 'slug';
|
||||||
|
if (!callerHasToken) {
|
||||||
|
return res.json({ slug: event.slug, matchType, requires_password: requiresPassword });
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
|
||||||
|
res.json({
|
||||||
|
slug: event.slug,
|
||||||
|
token: shareToken,
|
||||||
|
matchType,
|
||||||
|
share_link: event.share_link,
|
||||||
|
share_path: linkVariants.sharePath,
|
||||||
|
share_url: linkVariants.shareUrl,
|
||||||
|
short_enabled: linkVariants.shortEnabled,
|
||||||
|
requires_password: requiresPassword
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Verify share token. no-store: this is an authorization decision — a cached
|
||||||
|
// `{ valid: true }` would keep answering for a token the admin has rotated.
|
||||||
|
router.get('/:slug/verify-token/:token', noStoreCache, handleAsync(async (req, res) => {
|
||||||
|
const { slug, token } = req.params;
|
||||||
|
|
||||||
|
const event = await db('events')
|
||||||
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) })
|
||||||
|
.select('id', 'share_link', 'share_token')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw new NotFoundError('Gallery');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedToken = getEventShareToken(event);
|
||||||
|
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
|
||||||
|
throw new NotFoundError('Gallery', 'Invalid gallery link');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ valid: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get gallery info (with optional token verification)
|
||||||
|
router.get('/:slug/info', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { slug } = req.params;
|
||||||
|
const { token } = req.query;
|
||||||
|
|
||||||
|
let event = await db('events')
|
||||||
|
.where({ slug })
|
||||||
|
.select(
|
||||||
|
'id',
|
||||||
|
'created_by',
|
||||||
|
'event_name',
|
||||||
|
'event_type',
|
||||||
|
'event_date',
|
||||||
|
'expires_at',
|
||||||
|
'is_active',
|
||||||
|
'is_archived',
|
||||||
|
'share_link',
|
||||||
|
'share_token',
|
||||||
|
'allow_downloads',
|
||||||
|
'allow_user_uploads',
|
||||||
|
'reveal_mode',
|
||||||
|
'reveal_at',
|
||||||
|
'revealed_at',
|
||||||
|
'disable_right_click',
|
||||||
|
'watermark_downloads',
|
||||||
|
'watermark_text',
|
||||||
|
'require_password',
|
||||||
|
'color_theme',
|
||||||
|
'enable_devtools_protection',
|
||||||
|
'use_canvas_rendering',
|
||||||
|
'hero_logo_visible',
|
||||||
|
'hero_logo_size',
|
||||||
|
'hero_logo_position',
|
||||||
|
'hero_logo_url',
|
||||||
|
'login_logo_visible',
|
||||||
|
'header_style',
|
||||||
|
'hero_divider_style',
|
||||||
|
'hero_image_anchor',
|
||||||
|
'is_draft',
|
||||||
|
'default_photo_sort',
|
||||||
|
// Per-event promotional override (#440). Resolution into a
|
||||||
|
// ready-to-render markdown string happens below so the
|
||||||
|
// frontend doesn't have to know about modes.
|
||||||
|
'promo_mode',
|
||||||
|
'promo_markdown',
|
||||||
|
'info_mode',
|
||||||
|
'info_markdown'
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
// Check for redirect
|
||||||
|
const newSlug = await checkSlugRedirect(slug);
|
||||||
|
if (newSlug) {
|
||||||
|
return res.status(301).json({
|
||||||
|
redirect: true,
|
||||||
|
newSlug,
|
||||||
|
message: 'Gallery has been renamed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(404).json({ error: 'Gallery not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if event is archived
|
||||||
|
if (event.is_archived) {
|
||||||
|
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin preview (#868) bypasses both the draft gate and — below — the
|
||||||
|
// password gate. Computed once and reused.
|
||||||
|
const adminPreview = await verifyAdminPreview(req, event);
|
||||||
|
// Check if event is a draft (allow admin preview)
|
||||||
|
if (event.is_draft && !adminPreview) {
|
||||||
|
return res.status(404).json({ error: 'Gallery is not yet published' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If token provided, verify it matches the share link
|
||||||
|
if (token) {
|
||||||
|
const expectedToken = getEventShareToken(event);
|
||||||
|
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
|
||||||
|
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin preview skips the guest password on published, protected galleries
|
||||||
|
// (#868) — the admin already sees every photo through the admin routes.
|
||||||
|
const requiresPassword = adminPreview
|
||||||
|
? false
|
||||||
|
: !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||||
|
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
event_name: event.event_name,
|
||||||
|
event_type: event.event_type,
|
||||||
|
event_date: event.event_date,
|
||||||
|
expires_at: event.expires_at,
|
||||||
|
is_active: event.is_active,
|
||||||
|
is_expired: !event.is_active || isGalleryExpired(event),
|
||||||
|
requires_password: requiresPassword,
|
||||||
|
color_theme: event.color_theme,
|
||||||
|
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
|
||||||
|
allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1',
|
||||||
|
// Reveal mode (#838): effective hidden state (computed, time-exact) so
|
||||||
|
// the landing page can hint at the reveal before login too.
|
||||||
|
hidden_until_reveal: isGalleryHidden(event),
|
||||||
|
reveal_at: isGalleryHidden(event) ? (event.reveal_at || null) : null,
|
||||||
|
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
|
||||||
|
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||||
|
watermark_text: event.watermark_text,
|
||||||
|
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
|
||||||
|
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
|
||||||
|
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
|
||||||
|
// #894: only an explicit false hides the logo on the password page;
|
||||||
|
// NULL keeps the default (show).
|
||||||
|
login_logo_visible: !(event.login_logo_visible === false || event.login_logo_visible === 0 || event.login_logo_visible === '0'),
|
||||||
|
// #756: NULL per-event size inherits the global branding_logo_size.
|
||||||
|
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
|
||||||
|
hero_logo_position: event.hero_logo_position || 'top',
|
||||||
|
hero_logo_url: event.hero_logo_url || null,
|
||||||
|
header_style: event.header_style || 'standard',
|
||||||
|
hero_divider_style: event.hero_divider_style || 'wave',
|
||||||
|
hero_image_anchor: event.hero_image_anchor || 'center',
|
||||||
|
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
|
||||||
|
// Per-event promotional override (#440). Frontend resolves
|
||||||
|
// 'inherit' against branding_promo_markdown from public settings.
|
||||||
|
promo_mode: event.promo_mode || 'inherit',
|
||||||
|
promo_markdown: event.promo_markdown || null,
|
||||||
|
// Info banner (#932). Same inherit/custom/off semantics as promo,
|
||||||
|
// resolved against branding_info_markdown from public settings.
|
||||||
|
info_mode: event.info_mode || 'inherit',
|
||||||
|
info_markdown: event.info_markdown || null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to fetch gallery info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Live Slideshow ("Diashow") — token-only fullscreen kiosk surface
|
||||||
|
// (migration 138). The token in the URL IS the secret (no gallery password),
|
||||||
|
// so these routes are unauthenticated except for the token match itself. The
|
||||||
|
// slideshow shows ALL public/visible, finished photos — exactly the guest
|
||||||
|
// set — so once /session mints a short-lived `accessLevel:'slideshow'` JWT,
|
||||||
|
// the page reuses the normal /photos + image endpoints unchanged.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Photos a slideshow may display: published, finished, non-hidden. Mirrors the
|
||||||
|
// guest filter in GET /:slug/photos so the live count matches the rendered set.
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { db, logActivity } = require('../../database/db');
|
||||||
|
const router = express.Router();
|
||||||
|
const { verifyGalleryAccess } = require('../../middleware/gallery');
|
||||||
|
const { resolveGuest } = require('../../middleware/guestAuth');
|
||||||
|
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||||
|
const { generateGuestIdentifier } = require('../../middleware/feedbackRateLimit');
|
||||||
|
const { errorResponse } = require('../../utils/routeHelpers');
|
||||||
|
const { guestBlockedByReveal } = require('../../utils/revealMode');
|
||||||
|
const downloadZipService = require('../../services/downloadZipService');
|
||||||
|
const GALLERY_OPENED_DEBOUNCE_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const galleryOpenedNotifiedAt = new Map();
|
||||||
|
function galleryActor(req) {
|
||||||
|
// Portal tokens run as accessLevel 'guest' but carry via:'customer'
|
||||||
|
// (req.viaCustomer); PIN-client logins carry accessLevel 'client'.
|
||||||
|
// Both are customers, not guests (codex review of #849, final round).
|
||||||
|
const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client'));
|
||||||
|
return { type: isCustomer ? 'customer' : 'guest' };
|
||||||
|
}
|
||||||
|
function notifyGalleryOpened(event, req) {
|
||||||
|
// Customer-PORTAL opens already log `customer_event_access` on the
|
||||||
|
// access-token mint — a second `gallery_opened` per portal click would
|
||||||
|
// double-notify. Keyed on the portal provenance (req.viaCustomer), NOT
|
||||||
|
// on accessLevel: PIN-client logins are 'client' without any other
|
||||||
|
// open signal and must keep notifying (codex review of #849, final
|
||||||
|
// round — the previous check had this inverted).
|
||||||
|
if (req && req.viaCustomer) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const last = galleryOpenedNotifiedAt.get(event.id) || 0;
|
||||||
|
if (now - last < GALLERY_OPENED_DEBOUNCE_MS) return;
|
||||||
|
galleryOpenedNotifiedAt.set(event.id, now);
|
||||||
|
// Fire-and-forget — logActivity swallows its own errors.
|
||||||
|
logActivity('gallery_opened', {}, event.id, galleryActor(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const payload = await require('../../services/galleryQueryService').getGalleryPhotos({
|
||||||
|
event: req.event, slug: req.params.slug, query: req.query,
|
||||||
|
identity: { guestId: req.guest?.id, guestIdentifier: generateGuestIdentifier(req) },
|
||||||
|
accessLevel: req.accessLevel, adminPreview: req.isAdminPreview,
|
||||||
|
hiddenForGuest: guestBlockedByReveal(req),
|
||||||
|
});
|
||||||
|
// Log view — but NOT for the Live Slideshow kiosk. A running projector
|
||||||
|
// refetches this list on every new-upload poll, which would massively
|
||||||
|
// inflate total_views / unique_visitors. The slideshow is explicitly
|
||||||
|
// excluded from real visitor analytics (migration 138 design).
|
||||||
|
// Admin preview (#868) is excluded from guest analytics + the "gallery
|
||||||
|
// opened" bell — it's the photographer looking at their own gallery.
|
||||||
|
if (req.accessLevel !== 'slideshow' && !req.isAdminPreview && !(Number(req.query.page) > 1)) {
|
||||||
|
await db('access_logs').insert({
|
||||||
|
event_id: req.event.id,
|
||||||
|
ip_address: req.ip,
|
||||||
|
user_agent: req.headers['user-agent'],
|
||||||
|
action: 'view'
|
||||||
|
});
|
||||||
|
notifyGalleryOpened(req.event, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(payload);
|
||||||
|
} catch (error) { errorResponse(res, error, 500, 'Failed to fetch photos'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* People in this gallery (#1074).
|
||||||
|
*
|
||||||
|
* Returns [] rather than 403 whenever the feature is unavailable — a guest
|
||||||
|
* must not be able to tell "this gallery has no people" from "this gallery
|
||||||
|
* has the feature switched off". Same reasoning as reveal mode returning an
|
||||||
|
* empty photo set rather than an error.
|
||||||
|
*
|
||||||
|
* Counts and cover faces are computed against the caller's own visibility
|
||||||
|
* scope inside facePeopleService; nothing here reads face_count_total.
|
||||||
|
*/
|
||||||
|
// no-store for the same reason as /photos: the people list and its scan
|
||||||
|
// progress are scoped to what THIS viewer may see.
|
||||||
|
router.get('/:slug/people', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const isClient = req.accessLevel === 'client';
|
||||||
|
const { isEnabledForEvent, areFacesVisibleToGuests, getThresholds } =
|
||||||
|
require('../../services/faceSettings');
|
||||||
|
|
||||||
|
if (!(await isEnabledForEvent(req.event))) {
|
||||||
|
return res.json({ people: [] });
|
||||||
|
}
|
||||||
|
if (!isClient && !areFacesVisibleToGuests(req.event)) {
|
||||||
|
return res.json({ people: [] });
|
||||||
|
}
|
||||||
|
// While a gallery is hidden behind reveal mode (#838), a plain guest sees
|
||||||
|
// no photos — so they see no people either.
|
||||||
|
if (guestBlockedByReveal(req)) {
|
||||||
|
return res.json({ people: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { listPeople, getScanStatus } = require('../../services/facePeopleService');
|
||||||
|
const thresholds = await getThresholds();
|
||||||
|
|
||||||
|
const people = await listPeople(req.event.id, {
|
||||||
|
isClient,
|
||||||
|
forAdmin: false,
|
||||||
|
minClusterSize: thresholds.face_min_cluster_size,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drives the "Finding people… 240/1200" progress line during a backfill.
|
||||||
|
// Scoped to what this viewer may see — an unscoped total would leak the
|
||||||
|
// number of hidden photos through the progress bar.
|
||||||
|
const status = await getScanStatus(req.event.id, { isClient });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
people,
|
||||||
|
scan: {
|
||||||
|
in_progress: status.in_progress,
|
||||||
|
scanned: status.scanned,
|
||||||
|
total: status.total,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to fetch people');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle photo visibility (client-only)
|
||||||
|
router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Client access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { photoId } = req.params;
|
||||||
|
const { visibility } = req.body;
|
||||||
|
|
||||||
|
if (!['visible', 'hidden'].includes(visibility)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const photo = await db('photos')
|
||||||
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('photos')
|
||||||
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
|
.update({ visibility });
|
||||||
|
|
||||||
|
// A client hiding/showing a photo changes the guest download bundle —
|
||||||
|
// drop the cached ZIP so it rebuilds fresh (codex review).
|
||||||
|
downloadZipService.invalidate(req.event.id);
|
||||||
|
|
||||||
|
res.json({ message: 'Photo visibility updated', visibility });
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to update photo visibility');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk toggle photo visibility (client-only)
|
||||||
|
router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.accessLevel !== 'client') {
|
||||||
|
return res.status(403).json({ error: 'Client access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { photoIds, visibility } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(photoIds) || photoIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Invalid photo IDs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['visible', 'hidden'].includes(visibility)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await db('photos')
|
||||||
|
.whereIn('id', photoIds)
|
||||||
|
.where('event_id', req.event.id)
|
||||||
|
.update({ visibility });
|
||||||
|
|
||||||
|
// Client bulk hide/show alters the guest download bundle — invalidate
|
||||||
|
// the cached ZIP (codex review).
|
||||||
|
downloadZipService.invalidate(req.event.id);
|
||||||
|
|
||||||
|
res.json({ message: `${count} photos updated`, visibility });
|
||||||
|
} catch (error) {
|
||||||
|
errorResponse(res, error, 500, 'Failed to update photo visibility');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download single photo
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
const { isGalleryAvailable } = require('../../utils/galleryLifecycle');
|
||||||
|
const express = require('express');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { db } = require('../../database/db');
|
||||||
|
const { formatBoolean } = require('../../utils/dbCompat');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||||
|
const logger = require('../../utils/logger');
|
||||||
|
const { getEventShareToken, buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||||
|
const { handleAsync } = require('../../utils/routeHelpers');
|
||||||
|
const { NotFoundError } = require('../../utils/errors');
|
||||||
|
const { setGalleryAuthCookies } = require('../../utils/tokenUtils');
|
||||||
|
const { getSlideshowGlobals } = require('../../utils/slideshowGlobals');
|
||||||
|
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
|
||||||
|
|
||||||
|
function slideshowPhotosQuery(eventId, categoryId = null) {
|
||||||
|
const q = db('photos')
|
||||||
|
.where('photos.event_id', eventId)
|
||||||
|
.where(function() {
|
||||||
|
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
|
||||||
|
})
|
||||||
|
.where(function() {
|
||||||
|
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
|
||||||
|
});
|
||||||
|
// Category filter (#202) — keep the /session + /state count in sync with the
|
||||||
|
// photos the kiosk actually renders.
|
||||||
|
if (categoryId) q.where('photos.category_id', categoryId);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve an active slideshow by slug + token. Returns the event row, or null
|
||||||
|
// when the link is missing/rotated/disabled or the gallery isn't live (archived
|
||||||
|
// / draft / inactive / expired) — every one of those collapses to a 404 so a
|
||||||
|
// dead link reveals nothing and stops any projector on its next poll.
|
||||||
|
async function resolveSlideshow(slug, token) {
|
||||||
|
if (!token) return null;
|
||||||
|
// The `slideshow` feature flag is a master kill-switch: when an admin turns
|
||||||
|
// Live Slideshow off, every existing /show/ link dies on its next request
|
||||||
|
// (the running projector stops within one /state poll), not just the admin UI.
|
||||||
|
if (!(await isFeatureEnabled('slideshow'))) return null;
|
||||||
|
const event = await db('events')
|
||||||
|
.where({
|
||||||
|
slug,
|
||||||
|
show_share_token: token,
|
||||||
|
is_active: formatBoolean(true),
|
||||||
|
is_archived: formatBoolean(false),
|
||||||
|
is_draft: formatBoolean(false)
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
if (!isGalleryAvailable(event)) return null;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the slideshow's live styling, including the ZDF/ARD-ident-style
|
||||||
|
// watermark (a white, semi-transparent corner logo). The logo URL is resolved
|
||||||
|
// from the chosen source so the kiosk renders it without knowing about
|
||||||
|
// branding/event internals; null url = nothing to overlay.
|
||||||
|
async function slideshowSettings(event, req) {
|
||||||
|
// The global look/fit (Settings → Slideshow) + branding logo URLs come from a
|
||||||
|
// short-TTL cached bundle so a 3s projector poll doesn't re-fire ~10 settings
|
||||||
|
// reads each time (PR #646 review, concern 2).
|
||||||
|
const g = await getSlideshowGlobals();
|
||||||
|
|
||||||
|
// Watermark: the LOOK (logo/position/opacity/style/size) is configured ONCE
|
||||||
|
// globally; it is NOT duplicated per event. The only per-event control is
|
||||||
|
// whether the watermark shows: `show_watermark` NULL inherits the global
|
||||||
|
// enabled flag, true/false force it on/off.
|
||||||
|
const wm = event.show_watermark;
|
||||||
|
const inherit = (wm === null || wm === undefined);
|
||||||
|
const enabled = inherit ? g.watermark_enabled : (wm === true || wm === 1 || wm === '1');
|
||||||
|
let watermark = null;
|
||||||
|
if (enabled) {
|
||||||
|
// Resolve the chosen logo to a URL. Branding assets come from settings;
|
||||||
|
// the event source uses the event's own hero logo.
|
||||||
|
let url;
|
||||||
|
if (g.watermark_source === 'event') {
|
||||||
|
url = event.hero_logo_url || null;
|
||||||
|
} else if (g.watermark_source === 'logo_dark') {
|
||||||
|
url = g.branding_logo_url_dark;
|
||||||
|
} else if (g.watermark_source === 'favicon') {
|
||||||
|
url = g.branding_favicon_url;
|
||||||
|
} else {
|
||||||
|
url = g.branding_logo_url;
|
||||||
|
}
|
||||||
|
if (url) {
|
||||||
|
watermark = {
|
||||||
|
url,
|
||||||
|
position: g.watermark_position,
|
||||||
|
opacity: g.watermark_opacity,
|
||||||
|
style: g.watermark_style,
|
||||||
|
size: g.watermark_size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// QR overlay (#837): like the watermark, the LOOK is global-only and the
|
||||||
|
// per-event `show_qr` tri-state (NULL = inherit) decides visibility. The QR
|
||||||
|
// encodes the gallery share URL and ships as a data URI so the public
|
||||||
|
// slideshow client needs no QR library and no extra authenticated endpoint.
|
||||||
|
const qrOverride = event.show_qr;
|
||||||
|
const qrInherit = (qrOverride === null || qrOverride === undefined);
|
||||||
|
const qrEnabled = qrInherit ? g.qr_enabled : (qrOverride === true || qrOverride === 1 || qrOverride === '1');
|
||||||
|
let qr = null;
|
||||||
|
if (qrEnabled) {
|
||||||
|
const dataUrl = await slideshowQrDataUrl(event, req);
|
||||||
|
if (dataUrl) {
|
||||||
|
qr = {
|
||||||
|
data_url: dataUrl,
|
||||||
|
position: g.qr_position,
|
||||||
|
opacity: g.qr_opacity,
|
||||||
|
size: g.qr_size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
interval_ms: event.show_interval_ms || 5000,
|
||||||
|
transition: event.show_transition || 'crossfade',
|
||||||
|
transition_ms: event.show_transition_ms || 800,
|
||||||
|
colorfilter: event.show_colorfilter || 'none',
|
||||||
|
// Play order (#202): 'chronological' | 'random'. The client shuffles when
|
||||||
|
// 'random' so live-appended uploads keep working.
|
||||||
|
order: event.show_order || 'chronological',
|
||||||
|
fit: g.fit,
|
||||||
|
watermark,
|
||||||
|
qr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The state endpoint is polled every ~3s per projector — cache the generated
|
||||||
|
// QR data URI per share URL instead of re-encoding on every poll. Bounded:
|
||||||
|
// entries live for past events / rotated tokens too, so without eviction the
|
||||||
|
// map would grow with every share URL ever displayed (codex review of #848).
|
||||||
|
// Insertion-order eviction is enough — concurrently-shown events stay hot.
|
||||||
|
const SLIDESHOW_QR_CACHE_MAX = 50;
|
||||||
|
// Keyed by event id (NOT by URL): the origin is caller-influenced when the
|
||||||
|
// configured base is loopback, so URL-keyed caching would let a slideshow
|
||||||
|
// -link holder force a fresh QRCode.toDataURL per request with unique
|
||||||
|
// origins — a cheap CPU-exhaustion path (codex review of #848,
|
||||||
|
// confirmation round). Per-event entries + a regeneration throttle bound
|
||||||
|
// the encode rate regardless of what the caller sends.
|
||||||
|
const SLIDESHOW_QR_REGEN_MS = 60_000;
|
||||||
|
const slideshowQrCache = new Map(); // eventId -> { url, dataUrl, at }
|
||||||
|
// Localhost/relative guard (codex review of #848): with the compose-default
|
||||||
|
// FRONTEND_URL=http://localhost:3000 (or none configured) the QR would send
|
||||||
|
// scanning phones to THEIR localhost. The state poll comes from the kiosk
|
||||||
|
// browser itself, so its Host header + protocol are exactly the public
|
||||||
|
// origin guests can reach — prefer that whenever the configured base is
|
||||||
|
// missing or loopback. trust proxy is configured, so req.protocol respects
|
||||||
|
// X-Forwarded-Proto behind the standard reverse-proxy setups.
|
||||||
|
// Centralised in utils/frontendUrl (#705) so the QR path and the public-origin
|
||||||
|
// resolver agree on what counts as a non-shareable base.
|
||||||
|
const QR_LOCAL_BASE_RE = { test: (v) => require('../../utils/frontendUrl').isLoopbackBase(v) };
|
||||||
|
const QR_ORIGIN_RE = /^https?:\/\/[^\s/]+$/i;
|
||||||
|
async function slideshowQrDataUrl(event, req) {
|
||||||
|
try {
|
||||||
|
const shareToken = getEventShareToken(event);
|
||||||
|
if (!shareToken) return null;
|
||||||
|
let { shareUrl, sharePath } = await buildShareLinkVariants({ slug: event.slug, shareToken });
|
||||||
|
if (!/^https?:\/\//i.test(shareUrl) || QR_LOCAL_BASE_RE.test(shareUrl)) {
|
||||||
|
// Prefer the kiosk's own window.location.origin (?origin=, validated):
|
||||||
|
// req.get('host') is NOT the browser origin behind the standard
|
||||||
|
// proxies — frontend/nginx.conf forwards $host (port stripped), so a
|
||||||
|
// compose LAN deployment on :3000 would encode port 80. A LOOPBACK
|
||||||
|
// kiosk origin is rejected too: it is no more guest-reachable than
|
||||||
|
// the loopback base it would replace (codex review of #848).
|
||||||
|
const rawOrigin = req?.query?.origin;
|
||||||
|
const queryOrigin = typeof rawOrigin === 'string' && QR_ORIGIN_RE.test(rawOrigin) && !QR_LOCAL_BASE_RE.test(rawOrigin)
|
||||||
|
? rawOrigin.replace(/\/$/, '')
|
||||||
|
: null;
|
||||||
|
const host = req && req.get ? req.get('host') : null;
|
||||||
|
const hostOrigin = host ? `${req.protocol}://${host}` : null;
|
||||||
|
if (queryOrigin) shareUrl = `${queryOrigin}${sharePath}`;
|
||||||
|
else if (hostOrigin && !QR_LOCAL_BASE_RE.test(hostOrigin)) shareUrl = `${hostOrigin}${sharePath}`;
|
||||||
|
// Still loopback/relative → no reachable URL exists; suppress the
|
||||||
|
// overlay rather than encode a QR that sends phones to localhost.
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = slideshowQrCache.get(event.id);
|
||||||
|
if (cached && cached.url === shareUrl) return cached.dataUrl;
|
||||||
|
// URL differs from the cached one: NEVER serve the mismatched artifact —
|
||||||
|
// a slideshow-token holder could otherwise poison the projector's QR
|
||||||
|
// with an attacker origin for a whole throttle window (codex review of
|
||||||
|
// #848, final round). Inside the window the overlay is briefly
|
||||||
|
// suppressed instead; regeneration stays bounded per event.
|
||||||
|
if (cached && Date.now() - cached.at < SLIDESHOW_QR_REGEN_MS) {
|
||||||
|
return cached.pending ? cached.dataUrl : null;
|
||||||
|
}
|
||||||
|
// Single-flight: concurrent polls on a cold cache must not each
|
||||||
|
// schedule their own 512px encode — reserve the entry with a shared
|
||||||
|
// promise before awaiting.
|
||||||
|
if (cached && cached.pending && cached.url === shareUrl) return cached.pending;
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const entry = { url: shareUrl, dataUrl: null, at: Date.now(), pending: null };
|
||||||
|
entry.pending = QRCode.toDataURL(shareUrl, { width: 512, margin: 4 }).then((dataUrl) => {
|
||||||
|
entry.dataUrl = dataUrl;
|
||||||
|
entry.pending = null;
|
||||||
|
return dataUrl;
|
||||||
|
}).catch((e) => {
|
||||||
|
slideshowQrCache.delete(event.id);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
if (!slideshowQrCache.has(event.id) && slideshowQrCache.size >= SLIDESHOW_QR_CACHE_MAX) {
|
||||||
|
slideshowQrCache.delete(slideshowQrCache.keys().next().value);
|
||||||
|
}
|
||||||
|
slideshowQrCache.set(event.id, entry);
|
||||||
|
return await entry.pending;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Slideshow QR generation failed:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a slideshow session: validate the token and mint a short-lived gallery
|
||||||
|
// JWT scoped to `accessLevel:'slideshow'` (treated as a guest by the photo /
|
||||||
|
// image endpoints → visible photos only, no client-only/hidden). The page
|
||||||
|
// stores this token and the existing axios interceptor injects it.
|
||||||
|
// no-store: this response *is* a credential (it mints a gallery JWT and sets
|
||||||
|
// the per-slug auth cookie), so it must never be retained anywhere.
|
||||||
|
router.get('/:slug/show/:token/session', noStoreCache, handleAsync(async (req, res) => {
|
||||||
|
const { slug, token } = req.params;
|
||||||
|
const event = await resolveSlideshow(slug, token);
|
||||||
|
if (!event) {
|
||||||
|
throw new NotFoundError('Slideshow');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = jwt.sign({
|
||||||
|
eventId: event.id,
|
||||||
|
eventSlug: event.slug,
|
||||||
|
type: 'gallery',
|
||||||
|
// Unique per token: the revocation key falls back to eventId+iat otherwise,
|
||||||
|
// so one guest's logout would revoke every same-second login (#1357).
|
||||||
|
jti: crypto.randomUUID(),
|
||||||
|
accessLevel: 'slideshow',
|
||||||
|
loginTime: Date.now()
|
||||||
|
}, process.env.JWT_SECRET, {
|
||||||
|
expiresIn: '12h',
|
||||||
|
issuer: 'picpeak-auth'
|
||||||
|
});
|
||||||
|
|
||||||
|
// <img> tags can't carry an Authorization header, so the photo/thumbnail/
|
||||||
|
// preview endpoints authenticate via the per-slug gallery cookie. Set it
|
||||||
|
// here so the kiosk's image requests are authorized with zero extra wiring.
|
||||||
|
setGalleryAuthCookies(res, sessionToken, event.slug);
|
||||||
|
|
||||||
|
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token: sessionToken,
|
||||||
|
event: {
|
||||||
|
event_name: event.event_name,
|
||||||
|
event_type: event.event_type,
|
||||||
|
color_theme: event.color_theme
|
||||||
|
},
|
||||||
|
settings: await slideshowSettings(event, req),
|
||||||
|
photo_count: parseInt(count, 10) || 0,
|
||||||
|
expires_at: event.expires_at || null
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cheap live-poll endpoint (tiny payload, hit every ~3s by the running show):
|
||||||
|
// current settings + the visible photo count. The page diffs photo_count to
|
||||||
|
// decide when to refetch the full list, and re-reads settings so admin changes
|
||||||
|
// take effect live. A dead/disabled link 404s here → the projector stops.
|
||||||
|
router.get('/:slug/show/:token/state', noStoreCache, handleAsync(async (req, res) => {
|
||||||
|
const { slug, token } = req.params;
|
||||||
|
const event = await resolveSlideshow(slug, token);
|
||||||
|
if (!event) {
|
||||||
|
throw new NotFoundError('Slideshow');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...(await slideshowSettings(event, req)),
|
||||||
|
photo_count: parseInt(count, 10) || 0,
|
||||||
|
expires_at: event.expires_at || null
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get all photos.
|
||||||
|
//
|
||||||
|
// no-store (B6): the payload is private and per-guest — it carries the
|
||||||
|
// viewer's own likes/favorites/ratings and, for a client token, photos hidden
|
||||||
|
// from plain guests. With no Cache-Control at all a browser applies heuristic
|
||||||
|
// freshness and may reuse a body it stored on disk, on a shared device, for a
|
||||||
|
// gallery whose password has since been rotated. Express still computes its
|
||||||
|
// weak ETag, so a caller that does revalidate (React Query's own in-memory
|
||||||
|
// cache is unaffected either way) still gets a correct 304.
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { db } = require('../../database/db');
|
||||||
|
const router = express.Router();
|
||||||
|
const { verifyGalleryAccess } = require('../../middleware/gallery');
|
||||||
|
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||||
|
const { blockHiddenGallery } = require('../../utils/revealMode');
|
||||||
|
|
||||||
|
router.get('/:slug/stats', verifyGalleryAccess, blockHiddenGallery, noStoreCache, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const totalPhotos = await db('photos')
|
||||||
|
.where('event_id', req.event.id)
|
||||||
|
.count('id as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const totalViews = await db('access_logs')
|
||||||
|
.where('event_id', req.event.id)
|
||||||
|
.where('action', 'view')
|
||||||
|
.count('id as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const totalDownloads = await db('photos')
|
||||||
|
.where('event_id', req.event.id)
|
||||||
|
.sum('download_count as total')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const uniqueVisitors = await db('access_logs')
|
||||||
|
.where('event_id', req.event.id)
|
||||||
|
.countDistinct('ip_address as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total_photos: totalPhotos.count,
|
||||||
|
total_views: totalViews.count,
|
||||||
|
total_downloads: totalDownloads.total || 0,
|
||||||
|
unique_visitors: uniqueVisitors.count
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch stats' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// User photo upload endpoint
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { db } = require('../../database/db');
|
||||||
|
const router = express.Router();
|
||||||
|
const logger = require('../../utils/logger');
|
||||||
|
|
||||||
|
router.get('/:slug/css-template', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { slug } = req.params;
|
||||||
|
|
||||||
|
// Find the event by slug
|
||||||
|
const event = await db('events')
|
||||||
|
.where({ slug })
|
||||||
|
.select('css_template_id')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!event || !event.css_template_id) {
|
||||||
|
// No custom CSS - return 204 No Content
|
||||||
|
return res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the template if it's enabled
|
||||||
|
const template = await db('css_templates')
|
||||||
|
.where({ id: event.css_template_id, is_enabled: true })
|
||||||
|
.select('css_content')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!template || !template.css_content) {
|
||||||
|
return res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return CSS with caching headers
|
||||||
|
res.setHeader('Content-Type', 'text/css');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
|
||||||
|
res.send(template.css_content);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Get CSS template error:', error);
|
||||||
|
res.status(500).send('/* Error loading template */');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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;
|
||||||
@@ -6,6 +6,7 @@ const { verifyGalleryAccess } = require('../middleware/gallery');
|
|||||||
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
|
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
|
||||||
const watermarkService = require('../services/watermarkService');
|
const watermarkService = require('../services/watermarkService');
|
||||||
const secureImageService = require('../services/secureImageService');
|
const secureImageService = require('../services/secureImageService');
|
||||||
|
const galleryAccessService = require('../services/galleryAccessService');
|
||||||
const { getStorage } = require('../services/storage');
|
const { getStorage } = require('../services/storage');
|
||||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver');
|
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver');
|
||||||
const { withLocalCopy } = require('../services/imageProcessor');
|
const { withLocalCopy } = require('../services/imageProcessor');
|
||||||
@@ -19,16 +20,13 @@ const router = express.Router();
|
|||||||
/**
|
/**
|
||||||
* Generate a signed URL token for image access
|
* Generate a signed URL token for image access
|
||||||
*/
|
*/
|
||||||
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false) {
|
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false, galleryAccess) {
|
||||||
const secret = process.env.JWT_SECRET;
|
const secret = process.env.JWT_SECRET;
|
||||||
const expires = Date.now() + (expiresIn * 1000);
|
const expires = Date.now() + (expiresIn * 1000);
|
||||||
// Third segment (#838): whether the minting context bypasses reveal mode
|
// Bind the URL to its issuing access grant. Old tokens without a grant
|
||||||
// (slideshow/client/admin). Fourth segment: whether the minter was a
|
// must be refreshed: they cannot prove session revocation or ownership.
|
||||||
// PIN-client, allowing the serve route to still deliver a photo that was
|
const grant = Buffer.from(JSON.stringify(galleryAccess)).toString('base64url');
|
||||||
// hidden AFTER minting (TOCTOU) — a guest's token carries 0, so it stops
|
const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}:${clientBypass ? 1 : 0}:${grant}`;
|
||||||
// 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');
|
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
|
||||||
return `${Buffer.from(data).toString('base64')}.${signature}`;
|
return `${Buffer.from(data).toString('base64')}.${signature}`;
|
||||||
}
|
}
|
||||||
@@ -41,7 +39,7 @@ function verifyImageToken(token) {
|
|||||||
const secret = process.env.JWT_SECRET;
|
const secret = process.env.JWT_SECRET;
|
||||||
const [data, signature] = token.split('.');
|
const [data, signature] = token.split('.');
|
||||||
const decoded = Buffer.from(data, 'base64').toString();
|
const decoded = Buffer.from(data, 'base64').toString();
|
||||||
const [photoId, expires, bypassFlag, clientFlag] = decoded.split(':');
|
const [photoId, expires, bypassFlag, clientFlag, grant] = decoded.split(':');
|
||||||
|
|
||||||
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
|
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
|
||||||
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
|
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
|
||||||
@@ -50,7 +48,7 @@ function verifyImageToken(token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check expiration
|
// Check expiration
|
||||||
if (Date.now() > parseInt(expires)) {
|
if (!Number.isFinite(Number(expires)) || Date.now() >= Number(expires) || !grant) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +57,7 @@ function verifyImageToken(token) {
|
|||||||
expires: parseInt(expires),
|
expires: parseInt(expires),
|
||||||
revealBypass: bypassFlag === '1',
|
revealBypass: bypassFlag === '1',
|
||||||
clientBypass: clientFlag === '1',
|
clientBypass: clientFlag === '1',
|
||||||
|
galleryAccess: JSON.parse(Buffer.from(grant, 'base64url').toString()),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null;
|
return null;
|
||||||
@@ -170,6 +169,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery
|
|||||||
res.send(finalImage);
|
res.send(finalImage);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||||
logger.error('Error serving protected image:', error);
|
logger.error('Error serving protected image:', error);
|
||||||
res.status(500).json({ error: 'Failed to serve image' });
|
res.status(500).json({ error: 'Failed to serve image' });
|
||||||
}
|
}
|
||||||
@@ -207,6 +207,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
|
|||||||
// Generate secure token. clientBypass lets a client's token keep serving
|
// Generate secure token. clientBypass lets a client's token keep serving
|
||||||
// a photo hidden after minting; a guest's stops at the serve route.
|
// a photo hidden after minting; a guest's stops at the serve route.
|
||||||
const token = secureImageService.generateSecureToken(photoId, req.sessionID || 'anonymous', {
|
const token = secureImageService.generateSecureToken(photoId, req.sessionID || 'anonymous', {
|
||||||
|
galleryAccess: req.galleryAccess,
|
||||||
expiresIn,
|
expiresIn,
|
||||||
maxUses: protectionLevel === 'maximum' ? 1 : 3,
|
maxUses: protectionLevel === 'maximum' ? 1 : 3,
|
||||||
clientFingerprint,
|
clientFingerprint,
|
||||||
@@ -222,6 +223,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||||
logger.error('Error generating secure token:', error);
|
logger.error('Error generating secure token:', error);
|
||||||
res.status(500).json({ error: 'Failed to generate token' });
|
res.status(500).json({ error: 'Failed to generate token' });
|
||||||
}
|
}
|
||||||
@@ -262,7 +264,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
|
|||||||
// Generate signed token. The client-bypass flag lets a PIN-client's
|
// Generate signed token. The client-bypass flag lets a PIN-client's
|
||||||
// token keep serving a photo hidden after minting; a guest's token
|
// token keep serving a photo hidden after minting; a guest's token
|
||||||
// (clientBypass=0) stops the moment the photo is hidden.
|
// (clientBypass=0) stops the moment the photo is hidden.
|
||||||
const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel));
|
const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel), req.galleryAccess);
|
||||||
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
|
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -271,6 +273,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||||
logger.error('Error generating signed URL:', error);
|
logger.error('Error generating signed URL:', error);
|
||||||
res.status(500).json({ error: 'Failed to generate URL' });
|
res.status(500).json({ error: 'Failed to generate URL' });
|
||||||
}
|
}
|
||||||
@@ -299,6 +302,8 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Event not found' });
|
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
|
// 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.
|
// serving hidden photos; tokens minted by bypass contexts carry the flag.
|
||||||
if (isGalleryHidden(event) && !tokenData.revealBypass) {
|
if (isGalleryHidden(event) && !tokenData.revealBypass) {
|
||||||
@@ -338,7 +343,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
|
|||||||
res.set({
|
res.set({
|
||||||
'Content-Type': resolvePhotoContentType(photo),
|
'Content-Type': resolvePhotoContentType(photo),
|
||||||
'Content-Length': imageBuffer.length,
|
'Content-Length': imageBuffer.length,
|
||||||
'Cache-Control': 'private, max-age=3600',
|
'Cache-Control': 'private, no-store',
|
||||||
'X-Content-Type-Options': 'nosniff'
|
'X-Content-Type-Options': 'nosniff'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -346,6 +351,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
|
|||||||
res.send(imageBuffer);
|
res.send(imageBuffer);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||||
logger.error('Error serving signed image:', error);
|
logger.error('Error serving signed image:', error);
|
||||||
res.status(500).json({ error: 'Failed to serve image' });
|
res.status(500).json({ error: 'Failed to serve image' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { db } = require('../database/db');
|
|||||||
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
||||||
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
|
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
|
||||||
const secureImageService = require('../services/secureImageService');
|
const secureImageService = require('../services/secureImageService');
|
||||||
|
const galleryAccessService = require('../services/galleryAccessService');
|
||||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
@@ -58,6 +59,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
|
|||||||
|
|
||||||
// Generate secure token with appropriate settings
|
// Generate secure token with appropriate settings
|
||||||
const tokenOptions = {
|
const tokenOptions = {
|
||||||
|
galleryAccess: req.galleryAccess,
|
||||||
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
|
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
|
||||||
maxUses: accessType === 'download' ? 1 : 3,
|
maxUses: accessType === 'download' ? 1 : 3,
|
||||||
clientFingerprint,
|
clientFingerprint,
|
||||||
@@ -96,6 +98,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||||
logger.error('Error generating secure token', {
|
logger.error('Error generating secure token', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
photoId: req.body.photoId,
|
photoId: req.body.photoId,
|
||||||
@@ -151,6 +154,10 @@ router.get('/:slug/secure/:photoId/:token',
|
|||||||
return res.status(404).json({ error: 'Gallery not found' });
|
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
|
// Bind the token to the gallery + photo it was minted for
|
||||||
// (GHSA-g94x-8vv8-3c9f). This route serves via <img src> with the
|
// (GHSA-g94x-8vv8-3c9f). This route serves via <img src> with the
|
||||||
// token in the URL, so it can't require verifyGalleryAccess like the
|
// token in the URL, so it can't require verifyGalleryAccess like the
|
||||||
@@ -248,6 +255,7 @@ router.get('/:slug/secure/:photoId/:token',
|
|||||||
res.send(processedImage);
|
res.send(processedImage);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||||
logger.error('Error serving secure image', {
|
logger.error('Error serving secure image', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
photoId,
|
photoId,
|
||||||
@@ -292,6 +300,8 @@ router.get('/:slug/secure-download/:photoId/:token',
|
|||||||
return res.status(403).json({ error: 'Invalid or expired 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
|
// 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
|
// /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).
|
// token minted for photo A could download photo B (incl. a hidden one).
|
||||||
@@ -386,6 +396,7 @@ router.get('/:slug/secure-download/:photoId/:token',
|
|||||||
res.send(fileBuffer);
|
res.send(fileBuffer);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
|
||||||
logger.error('Error serving secure download', {
|
logger.error('Error serving secure download', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
photoId: req.params.photoId
|
photoId: req.params.photoId
|
||||||
@@ -414,6 +425,7 @@ router.get('/security/stats', adminAuth, requirePermission('settings.view'), asy
|
|||||||
res.json(stats);
|
res.json(stats);
|
||||||
|
|
||||||
} catch (error) {
|
} 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 });
|
logger.error('Error getting security stats', { error: error.message });
|
||||||
res.status(500).json({ error: 'Failed to get security stats' });
|
res.status(500).json({ error: 'Failed to get security stats' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,283 +1,74 @@
|
|||||||
/**
|
/** Persisted contracts, not a mock tied to the number/order of Knex calls. */
|
||||||
* Regression tests for issue #550.
|
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../../../../__tests__/integration/helpers/crmDb');
|
||||||
*
|
|
||||||
* 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 request = require('supertest');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
let db, cleanup, app, adminId, adminToken, apiToken;
|
||||||
const buildChain = ({ firstResult, insertResult, returningResult, selectResult } = {}) => {
|
const base = { event_type: 'wedding', event_name: 'Creation parity', event_date: '2030-06-15',
|
||||||
const chain = {
|
customer_name: 'Ada', customer_email: '[email protected]', admin_email: '[email protected]',
|
||||||
where: jest.fn().mockReturnThis(),
|
require_password: false, is_draft: false, expires_at: '2030-07-15T00:00:00.000Z' };
|
||||||
whereIn: jest.fn().mockReturnThis(),
|
beforeAll(async () => {
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
({ db, cleanup } = await bootCrmDb()); ({ adminId } = await seedMinimal(db)); await assignAdminRole(db, adminId);
|
||||||
orWhere: jest.fn().mockReturnThis(),
|
adminToken = mintAdminToken(adminId);
|
||||||
// `select` resolves to an array so `await db(...).whereIn(...).select(...)`
|
const generated = require('../../../middleware/apiTokenAuth').generateApiToken(); apiToken = generated.plaintext;
|
||||||
// gives an iterable result (used by the branding-defaults probe added in
|
await db('api_tokens').insert({ name: 'parity', hashed_token: generated.hashed, scopes: 'admin', created_by: adminId });
|
||||||
// #592 follow-up). Tests that don't need it leave selectResult undefined
|
app = express(); app.use(express.json());
|
||||||
// and get `[]`, which is a safe no-op for any caller that iterates.
|
app.use('/admin', require('../../adminEvents'));
|
||||||
select: jest.fn().mockResolvedValue(selectResult ?? []),
|
app.use('/v1', require('../events'));
|
||||||
first: jest.fn().mockResolvedValue(firstResult),
|
}, 120000);
|
||||||
insert: jest.fn().mockReturnThis(),
|
afterAll(async () => { await require('../../../services/serviceShutdown').stopServices(); await cleanup(); });
|
||||||
returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]),
|
async function create(source, extra) {
|
||||||
};
|
const input = { ...base, ...extra };
|
||||||
return chain;
|
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);
|
||||||
jest.mock('../../../database/db', () => {
|
expect(response.status).toBe(source === 'admin' ? 200 : 201);
|
||||||
const dbMock = jest.fn();
|
return response.body;
|
||||||
dbMock.raw = jest.fn();
|
}
|
||||||
dbMock.__setImplementations = (...chains) => {
|
it.each(['admin', 'v1', 'legacy'])('%s stores theme, owner, dates and feedback defaults through one use case', async source => {
|
||||||
dbMock.mockReset();
|
const theme = JSON.stringify({ primaryColor: '#ff0066' });
|
||||||
chains.forEach((chain) => {
|
const created = await create(source, { color_theme: theme, feedback_enabled: true });
|
||||||
dbMock.mockImplementationOnce(() => chain);
|
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');
|
||||||
});
|
});
|
||||||
};
|
it('inherits global feedback and preserves explicit overrides for every entry point', async () => {
|
||||||
return {
|
await db('app_settings').insert({ setting_key: 'event_default_feedback_enabled', setting_value: 'true', setting_type: 'boolean' })
|
||||||
db: dbMock,
|
.onConflict('setting_key').merge({ setting_value: 'true' });
|
||||||
logActivity: jest.fn().mockResolvedValue(undefined),
|
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 () => {
|
||||||
// RBAC is enforced on these routes since GHSA-9697 (requirePermission), but
|
const draft = await create('admin', { is_draft: true });
|
||||||
// this suite mocks the database, so a real permission lookup would 500. These
|
expect(await db('email_queue').where({ event_id: draft.id })).toHaveLength(0);
|
||||||
// tests cover route logic, not authorization — the intersection of token
|
const published = await create('v1', {});
|
||||||
// scopes and role permissions is pinned in __tests__/routes/v1EventOwnership.
|
expect(await db('email_queue').where({ event_id: published.id, email_type: 'gallery_created' })).toHaveLength(1);
|
||||||
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('rejects a required password that is missing with 400 on both routes', async () => {
|
||||||
it('persists color_theme to the events row when provided', async () => {
|
for (const source of ['admin', 'v1']) {
|
||||||
const slugChain = buildChain({ firstResult: null });
|
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
|
||||||
const insertChain = buildChain({ returningResult: [{ id: 42 }] });
|
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send({ ...base, require_password: true });
|
||||||
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
|
expect(response.status).toBe(400);
|
||||||
|
}
|
||||||
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('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 => {
|
||||||
it('accepts a JSON-encoded theme string and persists it verbatim', async () => {
|
for (const source of ['admin', 'v1']) {
|
||||||
const slugChain = buildChain({ firstResult: null });
|
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
|
||||||
const insertChain = buildChain({ returningResult: [{ id: 43 }] });
|
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send({ ...base, ...extra });
|
||||||
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
|
expect(response.status).toBe(400);
|
||||||
|
}
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,15 +30,13 @@ const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ow
|
|||||||
// requirePermission gates supply the missing half; they key on req.admin.id,
|
// requirePermission gates supply the missing half; they key on req.admin.id,
|
||||||
// which apiTokenAuth populates.
|
// which apiTokenAuth populates.
|
||||||
const { requirePermission } = require('../../middleware/permissions');
|
const { requirePermission } = require('../../middleware/permissions');
|
||||||
const { resolveEventFeedbackDefaults } = require('../../services/feedbackDefaults');
|
|
||||||
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
|
|
||||||
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||||
const { generateThumbnail } = require('../../services/imageProcessor');
|
const { generateThumbnail } = require('../../services/imageProcessor');
|
||||||
const logger = require('../../utils/logger');
|
const logger = require('../../utils/logger');
|
||||||
const { slugify } = require('../../utils/slug');
|
|
||||||
const { formatBoolean } = require('../../utils/dbCompat');
|
const { formatBoolean } = require('../../utils/dbCompat');
|
||||||
const { parseBooleanInput } = require('../../utils/parsers');
|
|
||||||
const { getImageSecurityDefaults, resolveImageSecurityColumns, decodeSettingValue } = require('../adminEvents/helpers');
|
|
||||||
const { isValidEventType } = require('../../services/eventTypeService');
|
const { isValidEventType } = require('../../services/eventTypeService');
|
||||||
const { replacePhoto } = require('../../services/photoReplacementService');
|
const { replacePhoto } = require('../../services/photoReplacementService');
|
||||||
const { getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings');
|
const { getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings');
|
||||||
@@ -195,262 +193,12 @@ router.post(
|
|||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||||
const {
|
const created = await require('../../services/eventCreationService').createEvent(req.body, {
|
||||||
event_name, event_type, event_date,
|
actor: req.admin, source: 'v1',
|
||||||
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()
|
|
||||||
});
|
});
|
||||||
}
|
res.status(201).json({ id: created.id, slug: created.slug, share_url: created.share_link, share_token: created.share_token });
|
||||||
|
|
||||||
await logActivity('event_created', { via: 'api_v1', event_type }, id, {
|
|
||||||
type: 'admin', id: req.admin.id, name: req.admin.username
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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) {
|
} 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 });
|
logger.error('v1 POST /events failed', { error: error.message, stack: error.stack });
|
||||||
res.status(500).json({ error: 'Failed to create event', detail: error.message });
|
res.status(500).json({ error: 'Failed to create event', detail: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,14 +338,13 @@ async function cleanupExpiredUploads() {
|
|||||||
return expiredIds.length;
|
return expiredIds.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup every hour. unref so this module-level housekeeping timer
|
const cleanupTask = require('./scheduledTask').scheduledTask(cleanupExpiredUploads, {
|
||||||
// never holds the process open on its own — in production the HTTP
|
interval: 60 * 60 * 1000
|
||||||
// listener keeps the loop alive, and in Jest this exact handle kept the
|
});
|
||||||
// runner from exiting for every suite that requires adminPhotos (#908;
|
cleanupTask.start();
|
||||||
// it is why adminPhotos.reference sits on the CI ignore list).
|
|
||||||
setInterval(cleanupExpiredUploads, 60 * 60 * 1000).unref();
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
stop: () => cleanupTask.stop(),
|
||||||
initializeUpload,
|
initializeUpload,
|
||||||
uploadChunk,
|
uploadChunk,
|
||||||
completeUpload,
|
completeUpload,
|
||||||
|
|||||||
@@ -12,21 +12,13 @@
|
|||||||
* schedulers don't all wake at once.
|
* schedulers don't all wake at once.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const cron = require('node-cron');
|
const { scheduledTask } = require('./scheduledTask');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const downloadJobService = require('./downloadJobService');
|
const downloadJobService = require('./downloadJobService');
|
||||||
|
|
||||||
function startDownloadJobCleanup() {
|
const task = scheduledTask(runDownloadJobCleanup, { schedule: '7,27,47 * * * *' });
|
||||||
// A restart leaves any in-flight build with no worker. Fail those rows once
|
function startDownloadJobCleanup() { task.start(); }
|
||||||
// at startup so their owners get a clear error instead of polling forever.
|
const stopDownloadJobCleanup = () => task.stop();
|
||||||
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() {
|
async function runDownloadJobCleanup() {
|
||||||
try {
|
try {
|
||||||
@@ -37,6 +29,7 @@ async function runDownloadJobCleanup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
stopDownloadJobCleanup,
|
||||||
startDownloadJobCleanup,
|
startDownloadJobCleanup,
|
||||||
// exported for tests / manual invocation
|
// exported for tests / manual invocation
|
||||||
runDownloadJobCleanup,
|
runDownloadJobCleanup,
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ class DownloadZipService {
|
|||||||
this.versions = new Map(); // eventId -> generation counter
|
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.
|
* Relative storage key for the cached zip.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -532,11 +532,8 @@ async function pollOnce() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Start the 1-minute poll loop (mirrors the outgoing queue cadence). */
|
/** Start the 1-minute poll loop (mirrors the outgoing queue cadence). */
|
||||||
function startIncomingMailPoller() {
|
const mailPoller = require('./scheduledTask').scheduledTask(pollOnce, { interval: 60000, initialDelay: 15000 });
|
||||||
const run = () => pollOnce().catch((e) => logger.error?.(`emailIntake: ${e.message}`));
|
function startIncomingMailPoller() { mailPoller.start(); }
|
||||||
setTimeout(run, 15000); // first run shortly after boot
|
const stopIncomingMailPoller = () => mailPoller.stop();
|
||||||
setInterval(run, 60 * 1000);
|
|
||||||
logger.info?.('Incoming-mail poller started (every 60s when enabled)');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { pollOnce, startIncomingMailPoller, listFolders, testConnection, roundTripTest, _internal: { getImapConfig, isEnabled, saveAttachment } };
|
module.exports = { stopIncomingMailPoller, pollOnce, startIncomingMailPoller, listFolders, testConnection, roundTripTest, _internal: { getImapConfig, isEnabled, saveAttachment } };
|
||||||
|
|||||||
@@ -1514,39 +1514,13 @@ async function testEmailConnection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start email queue processor
|
const emailTask = require('./scheduledTask').scheduledTask(processEmailQueue, { interval: 60000, initialDelay: 0 });
|
||||||
let emailQueueInterval = null;
|
|
||||||
|
|
||||||
function startEmailQueueProcessor() {
|
function startEmailQueueProcessor() {
|
||||||
logger.info('Email queue processor: Attempting to start...');
|
emailTask.start(); processorStatus.started = true;
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopEmailQueueProcessor() {
|
|
||||||
if (emailQueueInterval) {
|
|
||||||
clearInterval(emailQueueInterval);
|
|
||||||
emailQueueInterval = null;
|
|
||||||
processorStatus.started = false;
|
|
||||||
logger.info('Email queue processor stopped');
|
|
||||||
}
|
}
|
||||||
|
async function stopEmailQueueProcessor() {
|
||||||
|
await emailTask.stop(); processorStatus.started = false;
|
||||||
|
transporter?.close?.(); transporter = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize on module load - DISABLED for production startup
|
// Initialize on module load - DISABLED for production startup
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const fs = require('fs').promises;
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { signPayload } = require('./webhookService');
|
const { signPayload } = require('./webhookService');
|
||||||
|
const { pinnedRequestOptions } = require('../utils/pinnedRequest');
|
||||||
const { validateExternalUrlAsync } = require('../utils/networkValidation');
|
const { validateExternalUrlAsync } = require('../utils/networkValidation');
|
||||||
|
|
||||||
const SIGNATURE_HEADER = 'X-PicPeak-Signature';
|
const SIGNATURE_HEADER = 'X-PicPeak-Signature';
|
||||||
@@ -167,6 +168,7 @@ async function send(mail) {
|
|||||||
// Vetted before every send, not once at startup: DNS answers change, and the
|
// 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
|
// check is what stops an operator-supplied URL becoming a request to link
|
||||||
// local metadata or a service on the host network.
|
// local metadata or a service on the host network.
|
||||||
|
let connectionOptions = {};
|
||||||
if (!allowPrivateUrls) {
|
if (!allowPrivateUrls) {
|
||||||
// https for anything leaving the machine. The HMAC proves who sent the
|
// 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
|
// body, not who can read it — and these bodies carry password-reset links
|
||||||
@@ -190,6 +192,7 @@ async function send(mail) {
|
|||||||
+ 'private network (a container or LAN address).'
|
+ 'private network (a container or LAN address).'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
connectionOptions = pinnedRequestOptions(check);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -218,6 +221,7 @@ async function send(mail) {
|
|||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await axios.post(url, rawBody, {
|
response = await axios.post(url, rawBody, {
|
||||||
|
...connectionOptions,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
[SIGNATURE_HEADER]: signature,
|
[SIGNATURE_HEADER]: signature,
|
||||||
|
|||||||
@@ -0,0 +1,615 @@
|
|||||||
|
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 };
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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 };
|
||||||
@@ -10,11 +10,11 @@ const crypto = require('crypto');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const logger = require('../utils/logger');
|
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { hasColumnCached } = require('../utils/schemaCache');
|
const { hasColumnCached } = require('../utils/schemaCache');
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const { buildShareLinkVariants } = require('./shareLinkService');
|
|
||||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||||
const eventTypeService = require('./eventTypeService');
|
const eventTypeService = require('./eventTypeService');
|
||||||
const { AppError } = require('../utils/errors');
|
const { AppError } = require('../utils/errors');
|
||||||
@@ -154,167 +154,11 @@ const createEventFolders = async (slug) => {
|
|||||||
* @param {Object} eventData - Event data
|
* @param {Object} eventData - Event data
|
||||||
* @returns {Promise<Object>} - Created event
|
* @returns {Promise<Object>} - Created event
|
||||||
*/
|
*/
|
||||||
const createEvent = async (eventData) => {
|
const createEvent = async (eventData, options = {}) => {
|
||||||
const {
|
return require('./eventCreationService').createEvent(eventData, {
|
||||||
event_type,
|
...options,
|
||||||
event_name,
|
actor: options.actor || (eventData.created_by ? { id: eventData.created_by } : undefined),
|
||||||
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
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
const { db } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
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 } = require('../config/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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
SLIDESHOW_ORDERS,
|
||||||
|
SLIDESHOW_TRANSITIONS,
|
||||||
|
SLIDESHOW_COLORFILTERS,
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const cron = require('node-cron');
|
const { scheduledTask } = require('./scheduledTask');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { archiveEvent } = require('./archiveService');
|
const { archiveEvent } = require('./archiveService');
|
||||||
const { queueEmail, getSupportEmail } = require('./emailProcessor');
|
const { queueEmail, getSupportEmail } = require('./emailProcessor');
|
||||||
@@ -6,14 +6,9 @@ const { buildShareLinkVariants } = require('./shareLinkService');
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
function startExpirationChecker() {
|
const task = scheduledTask(checkExpirations, { schedule: '0 * * * *' });
|
||||||
// Check every hour for expired events and warnings
|
function startExpirationChecker() { task.start(); }
|
||||||
cron.schedule('0 * * * *', async () => {
|
const stopExpirationChecker = () => task.stop();
|
||||||
await checkExpirations();
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('Expiration checker started');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkExpirations() {
|
async function checkExpirations() {
|
||||||
try {
|
try {
|
||||||
@@ -248,6 +243,7 @@ async function handleExpiredEvent(event, { sendLegacyEmails = true } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
stopExpirationChecker,
|
||||||
startExpirationChecker,
|
startExpirationChecker,
|
||||||
// Reused by the workflow notify_gallery_* actions so the engine path sends the
|
// Reused by the workflow notify_gallery_* actions so the engine path sends the
|
||||||
// exact same emails as the legacy hourly checker.
|
// exact same emails as the legacy hourly checker.
|
||||||
|
|||||||
@@ -28,7 +28,21 @@ const watcherConcurrency = Number.isFinite(configuredConcurrency)
|
|||||||
: 2;
|
: 2;
|
||||||
const processLimit = pLimit(watcherConcurrency);
|
const processLimit = pLimit(watcherConcurrency);
|
||||||
|
|
||||||
|
let watcher = null;
|
||||||
|
const pending = new Set();
|
||||||
|
const enqueue = (run) => {
|
||||||
|
const task = processLimit(run); pending.add(task);
|
||||||
|
task.finally(() => pending.delete(task)).catch(() => {});
|
||||||
|
return task;
|
||||||
|
};
|
||||||
|
async function stopFileWatcher() {
|
||||||
|
const closing = watcher; watcher = null;
|
||||||
|
if (closing) await closing.close();
|
||||||
|
await Promise.allSettled([...pending]);
|
||||||
|
}
|
||||||
|
|
||||||
function startFileWatcher() {
|
function startFileWatcher() {
|
||||||
|
if (watcher) return watcher;
|
||||||
// Auto-import via filesystem watching only works with the local storage
|
// Auto-import via filesystem watching only works with the local storage
|
||||||
// backend. In S3 mode there is no local directory to watch — every photo
|
// backend. In S3 mode there is no local directory to watch — every photo
|
||||||
// must enter through the admin upload API. Skip cleanly with a clear log
|
// must enter through the admin upload API. Skip cleanly with a clear log
|
||||||
@@ -39,7 +53,7 @@ function startFileWatcher() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const watcher = chokidar.watch(WATCH_PATH(), {
|
watcher = chokidar.watch(WATCH_PATH(), {
|
||||||
ignored: /(^|[/\\])\../, // ignore dotfiles
|
ignored: /(^|[/\\])\../, // ignore dotfiles
|
||||||
persistent: true,
|
persistent: true,
|
||||||
awaitWriteFinish: {
|
awaitWriteFinish: {
|
||||||
@@ -50,17 +64,18 @@ function startFileWatcher() {
|
|||||||
|
|
||||||
watcher
|
watcher
|
||||||
.on('add', (filePath) => {
|
.on('add', (filePath) => {
|
||||||
processLimit(() => processNewPhoto(filePath)).catch((error) => {
|
enqueue(() => processNewPhoto(filePath)).catch((error) => {
|
||||||
logger.error('Error processing new photo:', error);
|
logger.error('Error processing new photo:', error);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.on('unlink', (filePath) => {
|
.on('unlink', (filePath) => {
|
||||||
processLimit(() => removePhoto(filePath)).catch((error) => {
|
enqueue(() => removePhoto(filePath)).catch((error) => {
|
||||||
logger.error('Error removing photo:', error);
|
logger.error('Error removing photo:', error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('File watcher started');
|
logger.info('File watcher started');
|
||||||
|
return watcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,4 +237,4 @@ async function removePhoto(filePath) {
|
|||||||
logger.info(`Removed photo: ${relativePath}`);
|
logger.info(`Removed photo: ${relativePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startFileWatcher, findExistingPhoto };
|
module.exports = { stopFileWatcher, startFileWatcher, findExistingPhoto };
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const { db } = require('../database/db');
|
||||||
|
const { userHasAllPermissions } = require('../middleware/permissions');
|
||||||
|
const { canAccessEvent } = require('../middleware/ownership');
|
||||||
|
const { assertGalleryAvailable, requiresGalleryPassword } = require('../utils/galleryLifecycle');
|
||||||
|
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
|
||||||
|
const { AppError } = require('../utils/errors');
|
||||||
|
const sessions = require('./sessionAccessService');
|
||||||
|
|
||||||
|
// These claims identify a session for revocation; no raw JWT, IP or password
|
||||||
|
// enters a media URL. Only use grants from this service or a verified signature.
|
||||||
|
const CLAIMS = ['type', 'id', 'customerId', 'eventId', 'eventSlug', 'iat', 'exp', 'jti', 'via', 'accessLevel'];
|
||||||
|
|
||||||
|
class GalleryAccessService {
|
||||||
|
grant(event, kind, decoded) {
|
||||||
|
const session = decoded && Object.fromEntries(CLAIMS
|
||||||
|
.filter((key) => decoded[key] !== undefined).map((key) => [key, decoded[key]]));
|
||||||
|
return { kind, eventId: event.id, issuedAt: Math.floor(Date.now() / 1000), ...(session && { session }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async authorize(event, grant) {
|
||||||
|
if (!grant || !['public', 'gallery', 'admin'].includes(grant.kind)
|
||||||
|
|| !event || Number(grant.eventId) !== Number(event.id)) {
|
||||||
|
throw new AppError('Invalid gallery grant', 403, 'INVALID_GALLERY_GRANT');
|
||||||
|
}
|
||||||
|
assertGalleryAvailable(event, { adminPreview: grant.kind === 'admin' });
|
||||||
|
if (!Number.isFinite(grant.issuedAt) || await isTokenBeforeCutoff({ iat: grant.issuedAt })) {
|
||||||
|
throw new AppError('Session invalidated', 401, 'SESSION_INVALIDATED');
|
||||||
|
}
|
||||||
|
const session = grant.session;
|
||||||
|
if (grant.kind === 'admin') {
|
||||||
|
const account = await sessions.admin(session);
|
||||||
|
const principal = { id: account.id, roleName: account.role_name };
|
||||||
|
if (!canAccessEvent(principal, event)
|
||||||
|
|| !await userHasAllPermissions(account.id, ['events.view', 'photos.view'])) {
|
||||||
|
throw new AppError('Access denied', 403, 'FORBIDDEN');
|
||||||
|
}
|
||||||
|
} else if (grant.kind === 'gallery') {
|
||||||
|
await sessions.assertActive(session, 'gallery');
|
||||||
|
if (Number(session.eventId) !== Number(event.id)) {
|
||||||
|
throw new AppError('Token does not match requested gallery', 403, 'INVALID_GALLERY_GRANT');
|
||||||
|
}
|
||||||
|
if (session.via === 'customer') {
|
||||||
|
await sessions.customer(session, { derived: true });
|
||||||
|
const assignment = await db('event_customer_assignments')
|
||||||
|
.where({ event_id: event.id, customer_account_id: session.customerId }).first();
|
||||||
|
if (!assignment) {
|
||||||
|
throw new AppError('Access to this gallery has been revoked', 403, 'CUSTOMER_ASSIGNMENT_REVOKED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (requiresGalleryPassword(event)) {
|
||||||
|
throw new AppError('No token provided', 401, 'NO_TOKEN');
|
||||||
|
}
|
||||||
|
return grant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new GalleryAccessService();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// #756: a NULL per-event hero_logo_visible means "inherit the global
|
||||||
|
// branding_logo_display_hero toggle". Only an explicit true/false is a
|
||||||
|
// per-gallery override. `globalDefault` is branding_logo_display_hero
|
||||||
|
// (defaults true when unset).
|
||||||
|
function resolveHeroLogoVisible(perEvent, globalDefault) {
|
||||||
|
if (perEvent === null || perEvent === undefined) {
|
||||||
|
return globalDefault !== false;
|
||||||
|
}
|
||||||
|
return perEvent !== false && perEvent !== 0 && perEvent !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats whose ORIGINAL bytes a browser can't render in an <img> (HEIC/HEIF,
|
||||||
|
// camera RAW/DNG). For these the lightbox must be served the generated JPEG
|
||||||
|
// preview instead of `url` (the original) — otherwise it shows a broken image.
|
||||||
|
// So we force `preview_url` for them regardless of the lightbox_preview_enabled
|
||||||
|
// toggle. Detection is by MIME first, extension as a fallback (browsers report
|
||||||
|
// these MIMEs inconsistently). EXPERIMENTAL: whether a preview actually renders
|
||||||
|
// still depends on the backend being able to decode the source (HEVC-in-HEIC on
|
||||||
|
// the prod image; exiftool for DNG) — see #821.
|
||||||
|
const NON_DISPLAYABLE_ORIGINAL_EXT = new Set(['heic', 'heif', 'dng']);
|
||||||
|
const NON_DISPLAYABLE_ORIGINAL_MIME = new Set(['image/heic', 'image/heif', 'image/x-adobe-dng']);
|
||||||
|
function originalNeedsPreview(photo) {
|
||||||
|
const mime = (photo.mime_type || '').toLowerCase();
|
||||||
|
if (NON_DISPLAYABLE_ORIGINAL_MIME.has(mime)) return true;
|
||||||
|
const name = photo.original_filename || photo.filename || '';
|
||||||
|
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
||||||
|
return NON_DISPLAYABLE_ORIGINAL_EXT.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resolveHeroLogoVisible, originalNeedsPreview };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { COLOR_LABELS, SHARED_COLOR_LABEL_IDENTITY } = require('../constants/colorLabels');
|
||||||
|
/** Apply the same own/shared feedback visibility before pagination and counts. */
|
||||||
|
function applyFeedbackFilter(query, { filter, event, identity, sharedColorMode, showFeedbackToGuests }) {
|
||||||
|
if (!filter) return query;
|
||||||
|
const tokens = new Set(String(filter).toLowerCase().split(',').map(x => x.trim()).filter(Boolean));
|
||||||
|
if (tokens.size === 0 || tokens.has('all')) return query;
|
||||||
|
if (tokens.has('saved') || tokens.has('favorite')) tokens.add('favorited');
|
||||||
|
const feedback = type => db('photo_feedback').where({ event_id: event.id, feedback_type: type,
|
||||||
|
is_hidden: formatBoolean(false) }).select('photo_id');
|
||||||
|
const own = q => identity.guestId ? q.where('guest_id', identity.guestId) : q.where('guest_identifier', identity.guestIdentifier);
|
||||||
|
return query.where(function () {
|
||||||
|
this.whereRaw('1 = 0');
|
||||||
|
for (const [token, type, column] of [['liked', 'like', 'like_count'], ['favorited', 'favorite', 'favorite_count'], ['rated', 'rating', 'average_rating'], ['commented', 'comment', null]]) {
|
||||||
|
if (!tokens.has(token)) continue;
|
||||||
|
this.orWhereIn('photos.id', own(feedback(type)));
|
||||||
|
if (showFeedbackToGuests) {
|
||||||
|
if (column) this.orWhere(`photos.${column}`, '>', 0);
|
||||||
|
else this.orWhereIn('photos.id', feedback(type).where('is_approved', formatBoolean(true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const colors = COLOR_LABELS.filter(color => tokens.has(`color:${color}`));
|
||||||
|
if (colors.length) {
|
||||||
|
const colorQuery = feedback('color_label').whereIn('color_label', colors);
|
||||||
|
if (sharedColorMode) {
|
||||||
|
this.orWhereIn('photos.id', colorQuery.where('guest_identifier', SHARED_COLOR_LABEL_IDENTITY));
|
||||||
|
} else {
|
||||||
|
this.orWhereIn('photos.id', own(colorQuery.clone()));
|
||||||
|
if (showFeedbackToGuests) this.orWhereIn('photos.id', colorQuery.where(function () {
|
||||||
|
this.whereNot('guest_identifier', SHARED_COLOR_LABEL_IDENTITY).orWhereNull('guest_identifier');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = { applyFeedbackFilter };
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
const { toIso } = require('../utils/dateNormalize');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const { parseBooleanInput } = require('../utils/parsers');
|
||||||
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { SHARED_COLOR_LABEL_IDENTITY } = require('../constants/colorLabels');
|
||||||
|
const watermarkService = require('./watermarkService');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
|
||||||
|
const { getUseOriginalFilenames } = require('./downloadFilenameService');
|
||||||
|
const { resolveEventDownloadPolicy } = require('../utils/downloadResolutions');
|
||||||
|
const { resolveHeroLogoVisible, originalNeedsPreview } = require('./galleryModel');
|
||||||
|
const { applyFeedbackFilter } = require('./galleryPhotoQuery');
|
||||||
|
async function getGalleryPhotos({ event, query = {}, identity, accessLevel, adminPreview, hiddenForGuest, slug }) {
|
||||||
|
// Get filter and sort parameters from query
|
||||||
|
// `guest_id` is deliberately NOT read from the query string: the viewer's
|
||||||
|
// own feedback is resolved from the request identity instead (see the
|
||||||
|
// filter block). The frontend still sends it; it is ignored.
|
||||||
|
const { filter, sort = 'upload_date', order = 'desc' } = query;
|
||||||
|
|
||||||
|
// Get watermark settings to generate cache-busting version for URLs
|
||||||
|
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||||
|
const wmVersion = watermarkSettings?.enabled
|
||||||
|
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Build the query with sorting
|
||||||
|
const sortOrder = order === 'asc' ? 'asc' : 'desc';
|
||||||
|
const isClient = accessLevel === 'client';
|
||||||
|
let photosQuery = db('photos')
|
||||||
|
.where('photos.event_id', event.id)
|
||||||
|
// Guests/clients never see photos still being processed by the
|
||||||
|
// background worker — the original is on disk but the thumbnail
|
||||||
|
// / dimensions / EXIF haven't landed yet. Photos with a NULL
|
||||||
|
// processing_status are pre-async-migration rows and are treated
|
||||||
|
// as complete (the migration's column default is 'complete' so
|
||||||
|
// this is just defensive against partial migration states).
|
||||||
|
.where(function() {
|
||||||
|
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
|
||||||
|
})
|
||||||
|
.select('photos.*');
|
||||||
|
|
||||||
|
// Guests only see visible photos; clients see all
|
||||||
|
if (!isClient) {
|
||||||
|
photosQuery = photosQuery.where(function() {
|
||||||
|
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live Slideshow category filter (#202). Enforced server-side so the kiosk
|
||||||
|
// viewer can't widen the set: when the event pins show_category_id, the
|
||||||
|
// slideshow only sees that category. NULL = all photos (unchanged).
|
||||||
|
if (accessLevel === 'slideshow' && event.show_category_id) {
|
||||||
|
photosQuery = photosQuery.where('photos.category_id', event.show_category_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sort option.
|
||||||
|
//
|
||||||
|
// Every branch carries photos.id as a tiebreaker (#1172). Without one the
|
||||||
|
// order within a tie is whatever the engine happens to return, and ties are
|
||||||
|
// the normal case rather than the exception: a bulk import writes hundreds
|
||||||
|
// of rows inside the same second, so uploaded_at collapses — and with
|
||||||
|
// captured_at NULL the COALESCE below collapses onto it too. The visible
|
||||||
|
// symptom is a grid that reshuffles between page loads. id is insertion
|
||||||
|
// order, so it also makes the fallback ordering meaningful rather than
|
||||||
|
// arbitrary.
|
||||||
|
if (sort === 'capture_date') {
|
||||||
|
// Sort by capture date, falling back to uploaded_at if capture date is null.
|
||||||
|
//
|
||||||
|
// On SQLite that fallback cannot be a plain COALESCE, because the two
|
||||||
|
// columns do not hold one type. photos.captured_at ends up carrying three
|
||||||
|
// different storage classes:
|
||||||
|
//
|
||||||
|
// integer managed uploads — photoProcessor.js:488 writes a Date, which
|
||||||
|
// the sqlite3 binding stores as epoch milliseconds
|
||||||
|
// text external imports and the backfill, which write ISO-8601
|
||||||
|
// ('2026-06-03T01:15:00.000Z') per the CLAUDE.md rule that
|
||||||
|
// Dates must not be handed to the binding in tests
|
||||||
|
// null no capture date, so the sort falls through to uploaded_at —
|
||||||
|
// usually text in knex's 'YYYY-MM-DD HH:MM:SS' default shape,
|
||||||
|
// but epoch milliseconds on rows written by a legacy archive
|
||||||
|
// restore (see __tests__/integration/sqliteEpochTimestamps.js),
|
||||||
|
// so that column needs the same two branches
|
||||||
|
//
|
||||||
|
// SQLite orders INTEGER before TEXT unconditionally, so every managed
|
||||||
|
// photo carrying EXIF sorted ahead of every photo that did not, whatever
|
||||||
|
// the actual dates — a 2027 capture landing before a 2020 one. Among the
|
||||||
|
// text values the 'T' separator (0x54) also outranks the space (0x20), so
|
||||||
|
// a same-day ISO 01:15 sorted after a fallback 23:00.
|
||||||
|
//
|
||||||
|
// Normalising in the ORDER BY rather than rewriting the column: the data
|
||||||
|
// fix would have to touch every existing row and every writer, which is a
|
||||||
|
// much heavier change than the sort it is meant to correct. The cost here
|
||||||
|
// is that this sort stops using idx_photos_captured_at on SQLite — an
|
||||||
|
// acceptable trade on the fallback engine, where the alternative is an
|
||||||
|
// index-assisted wrong answer.
|
||||||
|
//
|
||||||
|
// Postgres is untouched: captured_at is a real timestamp there, so
|
||||||
|
// COALESCE already compares correctly.
|
||||||
|
if (db.client.config.client === 'pg') {
|
||||||
|
photosQuery = photosQuery
|
||||||
|
.orderByRaw('COALESCE(photos.captured_at, photos.uploaded_at) ' + sortOrder);
|
||||||
|
} else {
|
||||||
|
photosQuery = photosQuery.orderByRaw(`CASE
|
||||||
|
WHEN typeof(photos.captured_at) IN ('integer', 'real') THEN datetime(photos.captured_at / 1000, 'unixepoch')
|
||||||
|
WHEN photos.captured_at IS NOT NULL THEN replace(replace(substr(photos.captured_at, 1, 19), 'T', ' '), 'Z', '')
|
||||||
|
WHEN typeof(photos.uploaded_at) IN ('integer', 'real') THEN datetime(photos.uploaded_at / 1000, 'unixepoch')
|
||||||
|
ELSE substr(photos.uploaded_at, 1, 19)
|
||||||
|
END ${sortOrder}`);
|
||||||
|
}
|
||||||
|
photosQuery = photosQuery.orderBy('photos.id', sortOrder);
|
||||||
|
} else if (sort === 'filename') {
|
||||||
|
photosQuery = photosQuery.orderBy('photos.filename', sortOrder).orderBy('photos.id', sortOrder);
|
||||||
|
} else {
|
||||||
|
// Default: sort by upload date
|
||||||
|
photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder).orderBy('photos.id', sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal mode (#838): while the gallery is hidden, plain guests get
|
||||||
|
// the event shell with an empty photo/category set plus the
|
||||||
|
// hidden_until_reveal flag — the frontend renders the upload-only view
|
||||||
|
// from it. Slideshow, client access and the admin preview bypass
|
||||||
|
// (guestBlockedByReveal). Enforced here, not just in the UI.
|
||||||
|
|
||||||
|
|
||||||
|
// Check if feedback should be visible to guests. Read BEFORE the filter
|
||||||
|
// block, not after: the filters below consult it, because a filter that
|
||||||
|
// selects on other people's feedback is a way of reading that feedback.
|
||||||
|
const feedbackService = require('./feedbackService');
|
||||||
|
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||||
|
const showFeedbackToGuests = isClient || parseBooleanInput(feedbackSettings.show_feedback_to_guests, true);
|
||||||
|
// One identity-less colour tag per photo, any guest may overwrite it
|
||||||
|
// (#1197). Read in three places below: the colour filters, the per-viewer
|
||||||
|
// badge, and the "other viewers" dots that must not double-render it.
|
||||||
|
const sharedColorMode = feedbackSettings?.identity_mode === 'shared';
|
||||||
|
|
||||||
|
applyFeedbackFilter(photosQuery, { filter, event, identity, sharedColorMode, showFeedbackToGuests });
|
||||||
|
const limit = query.limit === undefined ? null : Math.min(250, Math.max(1, parseInt(query.limit, 10) || 100));
|
||||||
|
const page = Math.max(1, parseInt(query.page, 10) || 1);
|
||||||
|
const countRow = hiddenForGuest ? { total: 0 } : await photosQuery.clone().clearSelect().clearOrder().count('photos.id as total').first();
|
||||||
|
const total = Number(countRow.total);
|
||||||
|
if (limit) photosQuery.limit(limit).offset((page - 1) * limit);
|
||||||
|
const photos = hiddenForGuest ? [] : await photosQuery;
|
||||||
|
|
||||||
|
// Then get comment counts separately
|
||||||
|
const commentCounts = await db('photo_feedback')
|
||||||
|
.whereIn('photo_id', photos.map(p => p.id))
|
||||||
|
.where('feedback_type', 'comment')
|
||||||
|
.where('is_approved', formatBoolean(true))
|
||||||
|
.where('is_hidden', formatBoolean(false))
|
||||||
|
.groupBy('photo_id')
|
||||||
|
.select('photo_id', db.raw('COUNT(*) as comment_count'));
|
||||||
|
|
||||||
|
// Create a map for quick lookup
|
||||||
|
const commentMap = {};
|
||||||
|
commentCounts.forEach(c => {
|
||||||
|
commentMap[c.photo_id] = parseInt(c.comment_count);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-viewer "is_liked" set (#590 follow-up). Hard refresh on the
|
||||||
|
// gallery grid used to reset every heart to empty because the lifted
|
||||||
|
// likedPhotoIds state started as a fresh Set on mount — even photos
|
||||||
|
// the viewer had actually liked. Surface a per-viewer flag so the
|
||||||
|
// frontend can seed correctly. Prefers identity.guestId when a verified
|
||||||
|
// guest token is present (per-person identity), falls back to the
|
||||||
|
// IP+UA hash that the original like was recorded under — same model
|
||||||
|
// the /my-feedback endpoint uses.
|
||||||
|
//
|
||||||
|
// NOT gated on showFeedbackToGuests (#1286). This query is filtered to
|
||||||
|
// the VIEWER — by guest_id or by their own identifier — so what it
|
||||||
|
// returns is their own selection, not shared aggregate data. Gating it
|
||||||
|
// emptied every heart the guest had set themselves on a gallery with
|
||||||
|
// sharing off, which reads as the gallery silently discarding their
|
||||||
|
// choices. Same reasoning the colour-label block below already applies;
|
||||||
|
// this was the one per-viewer field that disagreed with it.
|
||||||
|
const likedPhotoIds = new Set();
|
||||||
|
if (photos.length > 0) {
|
||||||
|
const likeQuery = db('photo_feedback')
|
||||||
|
// Hidden rows are not there, for the viewer's OWN feedback as much as
|
||||||
|
// anyone's (#1150). getPhotoFeedback drops them, the filter drops them
|
||||||
|
// and updatePhotoFeedbackStats does not count them — leaving the heart
|
||||||
|
// filled was the one place that disagreed, so a like the photographer
|
||||||
|
// had hidden still showed as liked on a photo whose like_count was 0.
|
||||||
|
.where({ event_id: event.id, feedback_type: 'like', is_hidden: formatBoolean(false) })
|
||||||
|
.whereIn('photo_id', photos.map(p => p.id));
|
||||||
|
if (identity.guestId) {
|
||||||
|
likeQuery.where('guest_id', identity.guestId);
|
||||||
|
} else {
|
||||||
|
likeQuery.where('guest_identifier', identity.guestIdentifier);
|
||||||
|
}
|
||||||
|
const likedRows = await likeQuery.select('photo_id');
|
||||||
|
likedRows.forEach(row => likedPhotoIds.add(row.photo_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-viewer colour label (#1044), same identity resolution as the likes
|
||||||
|
// above. NOT gated on showFeedbackToGuests: a guest's own label is their
|
||||||
|
// own selection, not shared aggregate data, and hiding it would blank the
|
||||||
|
// grid badges on every refresh in a gallery with sharing switched off.
|
||||||
|
//
|
||||||
|
// In shared identity mode (#1197) there is no per-viewer label to read:
|
||||||
|
// the photo carries one tag and it belongs to everyone, so it arrives on
|
||||||
|
// this same field. The badge, the lightbox swatch and the keyboard
|
||||||
|
// shortcuts then work unchanged — they were already reading "the colour on
|
||||||
|
// this photo, from my point of view", which is precisely what the shared
|
||||||
|
// tag is.
|
||||||
|
const myColorLabelByPhoto = {};
|
||||||
|
if (photos.length > 0 && sharedColorMode) {
|
||||||
|
Object.assign(
|
||||||
|
myColorLabelByPhoto,
|
||||||
|
await feedbackService.getSharedColorLabels(event.id, photos.map(p => p.id)),
|
||||||
|
);
|
||||||
|
} else if (photos.length > 0) {
|
||||||
|
const colorQuery = db('photo_feedback')
|
||||||
|
// Same rule as the heart above (#1150).
|
||||||
|
.where({ event_id: event.id, feedback_type: 'color_label', is_hidden: formatBoolean(false) })
|
||||||
|
.whereIn('photo_id', photos.map(p => p.id));
|
||||||
|
if (identity.guestId) {
|
||||||
|
colorQuery.where('guest_id', identity.guestId);
|
||||||
|
} else {
|
||||||
|
colorQuery.where('guest_identifier', identity.guestIdentifier);
|
||||||
|
}
|
||||||
|
const colorRows = await colorQuery.select('photo_id', 'color_label');
|
||||||
|
colorRows.forEach(row => {
|
||||||
|
if (row.color_label) myColorLabelByPhoto[row.photo_id] = row.color_label;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// OTHER viewers' colour labels, per photo (#1178).
|
||||||
|
//
|
||||||
|
// The lightbox has always shown these — /photos/:id/feedback returns
|
||||||
|
// per-colour tallies across everyone — but the grid had no field carrying
|
||||||
|
// them, so a label set by one guest was visible in fullscreen and invisible
|
||||||
|
// on the tile. With sharing on, that is just a hole.
|
||||||
|
//
|
||||||
|
// DISTINCT colours, not counts: a tile has room for a couple of dots, and
|
||||||
|
// "who else marked this, and how" is a lightbox question. The viewer's own
|
||||||
|
// colour is excluded here so the badge and the dots never say the same
|
||||||
|
// thing twice — the frontend renders `my_color_label` as the badge and
|
||||||
|
// these beside it.
|
||||||
|
//
|
||||||
|
// Gated on showFeedbackToGuests, like every other aggregate: this is other
|
||||||
|
// people's feedback, unlike my_color_label above.
|
||||||
|
//
|
||||||
|
// Skipped entirely in shared mode (#1197). There are no other viewers'
|
||||||
|
// labels there — there is one tag, already delivered as my_color_label
|
||||||
|
// above. Without this the shared row would come back here too (its
|
||||||
|
// reserved identity is not the viewer's), and every tile would render the
|
||||||
|
// same colour twice: once as the badge, once as a dot beside it.
|
||||||
|
const otherColorLabelsByPhoto = {};
|
||||||
|
if (photos.length > 0 && showFeedbackToGuests && !sharedColorMode) {
|
||||||
|
const othersQuery = db('photo_feedback')
|
||||||
|
.where({ event_id: event.id, feedback_type: 'color_label', is_hidden: formatBoolean(false) })
|
||||||
|
.whereIn('photo_id', photos.map(p => p.id))
|
||||||
|
.whereNotNull('color_label')
|
||||||
|
// The other direction of the same rule (#1197): an event switched back
|
||||||
|
// out of shared mode keeps its shared tag, and it is nobody's — so
|
||||||
|
// without this it would show up as an anonymous other viewer's dot on
|
||||||
|
// every tile that still carries one.
|
||||||
|
.where(function () {
|
||||||
|
this.whereNot('guest_identifier', SHARED_COLOR_LABEL_IDENTITY).orWhereNull('guest_identifier');
|
||||||
|
});
|
||||||
|
if (identity.guestId) {
|
||||||
|
othersQuery.where(function () {
|
||||||
|
this.whereNot('guest_id', identity.guestId).orWhereNull('guest_id');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const mine = identity.guestIdentifier;
|
||||||
|
othersQuery.where(function () {
|
||||||
|
this.whereNot('guest_identifier', mine).orWhereNull('guest_identifier');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const otherRows = await othersQuery.distinct('photo_id', 'color_label');
|
||||||
|
otherRows.forEach(row => {
|
||||||
|
if (!otherColorLabelsByPhoto[row.photo_id]) otherColorLabelsByPhoto[row.photo_id] = [];
|
||||||
|
if (!otherColorLabelsByPhoto[row.photo_id].includes(row.color_label)) {
|
||||||
|
otherColorLabelsByPhoto[row.photo_id].push(row.color_label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// People in each photo (#1074). Two independent gates: the feature must
|
||||||
|
// be on for this event AND, for a plain guest, the photographer must have
|
||||||
|
// left the strip visible. A client (PIN access) is the photographer's own
|
||||||
|
// view, so faces_visible_to_guests doesn't restrict them.
|
||||||
|
//
|
||||||
|
// `photos` is already visibility-filtered above, and this only ever asks
|
||||||
|
// about ids in that set, so it cannot widen what the caller sees.
|
||||||
|
let peopleEnabled = false;
|
||||||
|
let personIdsByPhoto = new Map();
|
||||||
|
try {
|
||||||
|
const { isEnabledForEvent, areFacesVisibleToGuests } = require('./faceSettings');
|
||||||
|
if (photos.length > 0 && await isEnabledForEvent(event)) {
|
||||||
|
peopleEnabled = isClient || areFacesVisibleToGuests(event);
|
||||||
|
if (peopleEnabled) {
|
||||||
|
const { getPersonIdsByPhoto } = require('./facePeopleService');
|
||||||
|
personIdsByPhoto = await getPersonIdsByPhoto(
|
||||||
|
event.id,
|
||||||
|
photos.map(p => p.id),
|
||||||
|
{ forAdmin: isClient }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// A face-feature failure must never take down the gallery payload.
|
||||||
|
logger.warn(`gallery: person_ids lookup failed for event ${event.id}`, { error: err.message });
|
||||||
|
peopleEnabled = false;
|
||||||
|
personIdsByPhoto = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get actual categories used by photos in this event
|
||||||
|
// This includes both global categories and event-specific ones
|
||||||
|
const usedCategoryIds = hiddenForGuest ? [] : await db('photos')
|
||||||
|
.where('event_id', event.id)
|
||||||
|
.whereNotNull('category_id')
|
||||||
|
.distinct('category_id')
|
||||||
|
.pluck('category_id');
|
||||||
|
|
||||||
|
// Fetch category details from photo_categories table
|
||||||
|
let categories = [];
|
||||||
|
if (usedCategoryIds.length > 0) {
|
||||||
|
// Resolved category order (#782): per-event override, else global
|
||||||
|
// default, else name — restricted to categories that have photos.
|
||||||
|
const categoryDetails = await getEventCategoriesOrdered(event.id, {
|
||||||
|
onlyIds: usedCategoryIds,
|
||||||
|
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads', 'c.is_folder'],
|
||||||
|
});
|
||||||
|
|
||||||
|
categories = categoryDetails.map(cat => ({
|
||||||
|
id: cat.id,
|
||||||
|
name: cat.name,
|
||||||
|
slug: cat.slug,
|
||||||
|
is_global: cat.is_global,
|
||||||
|
hero_photo_id: cat.hero_photo_id || null,
|
||||||
|
// Per-category download flag (#640). false explicitly disables; the
|
||||||
|
// gallery hides the download button. Defaults true so categories
|
||||||
|
// created before migration 135 keep working.
|
||||||
|
allow_downloads: parseBooleanInput(cat.allow_downloads, true),
|
||||||
|
// Folder vs filter (#1160). true = the category CONTAINS its photos:
|
||||||
|
// they leave the root grid and only render inside the folder. Defaults
|
||||||
|
// false so categories predating migration 185 keep filtering.
|
||||||
|
is_folder: parseBooleanInput(cat.is_folder, false)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map for quick category lookup
|
||||||
|
const categoryMap = {};
|
||||||
|
categories.forEach(cat => {
|
||||||
|
categoryMap[cat.id] = cat;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Include protection settings in response
|
||||||
|
const protectionSettings = {
|
||||||
|
protection_level: event.protection_level || 'standard',
|
||||||
|
image_quality: event.image_quality || 85,
|
||||||
|
use_canvas_rendering: parseBooleanInput(event.use_canvas_rendering, false),
|
||||||
|
overlay_protection: parseBooleanInput(event.overlay_protection, true)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lightbox preview tier (#492). When the admin opts in, the
|
||||||
|
// photos response carries a preview_url alongside url/thumbnail_url
|
||||||
|
// — the lightbox uses preview_url when present and falls back to
|
||||||
|
// url when not, so existing galleries continue working before
|
||||||
|
// any preview has actually been generated.
|
||||||
|
let lightboxPreviewEnabled = false;
|
||||||
|
try {
|
||||||
|
const setting = await db('app_settings')
|
||||||
|
.where('setting_key', 'lightbox_preview_enabled')
|
||||||
|
.first();
|
||||||
|
if (setting) {
|
||||||
|
const raw = setting.setting_value;
|
||||||
|
// setting_value is JSON-stringified per migration 104; tolerate
|
||||||
|
// raw boolean/string for forward-compat.
|
||||||
|
const parsed = typeof raw === 'string' ? (() => {
|
||||||
|
try { return JSON.parse(raw); } catch { return raw; }
|
||||||
|
})() : raw;
|
||||||
|
lightboxPreviewEnabled = parsed === true || parsed === 'true' || parsed === 1;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Setting missing / DB blip → fall back to off so the lightbox
|
||||||
|
// keeps working with the original. logger.debug to avoid noise.
|
||||||
|
logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// #508: when the admin has flipped the "use original camera filenames"
|
||||||
|
// toggle (#493), the lightbox surfaces each photo's original_filename
|
||||||
|
// alongside the position counter so the photographer can map a guest's
|
||||||
|
// selection back to source files. Tied to the same toggle as downloads —
|
||||||
|
// one switch controls both surfaces.
|
||||||
|
const useOriginalFilenames = await getUseOriginalFilenames();
|
||||||
|
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||||
|
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||||
|
const downloadPolicy = await resolveEventDownloadPolicy(event);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pagination: { page, limit: limit || total, total, has_more: !!limit && page * limit < total },
|
||||||
|
event: {
|
||||||
|
id: event.id,
|
||||||
|
event_name: event.event_name,
|
||||||
|
event_type: event.event_type,
|
||||||
|
event_date: event.event_date,
|
||||||
|
welcome_message: event.welcome_message,
|
||||||
|
color_theme: event.color_theme,
|
||||||
|
expires_at: event.expires_at,
|
||||||
|
hero_photo_id: event.hero_photo_id,
|
||||||
|
// Defaults match /info: downloads on unless explicitly disabled,
|
||||||
|
// uploads off unless explicitly enabled (#1028).
|
||||||
|
allow_downloads: parseBooleanInput(event.allow_downloads, true),
|
||||||
|
allow_user_uploads: parseBooleanInput(event.allow_user_uploads, false),
|
||||||
|
// Download resolutions (#858). `choices` drives the picker modal and is
|
||||||
|
// empty when the picker is off, so the UI can never offer a size the
|
||||||
|
// server would reject.
|
||||||
|
download_resolution: {
|
||||||
|
standard: downloadPolicy.standard,
|
||||||
|
picker_enabled: downloadPolicy.pickerEnabled,
|
||||||
|
choices: downloadPolicy.pickerEnabled ? downloadPolicy.choices : [],
|
||||||
|
},
|
||||||
|
// Reveal mode (#838): armed flag lets an open VISIBLE gallery keep
|
||||||
|
// polling so a re-hide propagates without a manual reload.
|
||||||
|
reveal_armed: parseBooleanInput(event.reveal_mode, false),
|
||||||
|
disable_right_click: parseBooleanInput(event.disable_right_click, false),
|
||||||
|
watermark_downloads: parseBooleanInput(event.watermark_downloads, false),
|
||||||
|
watermark_text: event.watermark_text,
|
||||||
|
enable_devtools_protection: parseBooleanInput(event.enable_devtools_protection, false),
|
||||||
|
use_canvas_rendering: parseBooleanInput(event.use_canvas_rendering, false),
|
||||||
|
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
|
||||||
|
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',
|
||||||
|
// Promo banner override (#440). GalleryView has always read
|
||||||
|
// promo_mode from THIS payload, but it was never sent — so every
|
||||||
|
// per-event promo override silently resolved to 'inherit' and a
|
||||||
|
// gallery set to 'off' still showed the global banner.
|
||||||
|
promo_mode: event.promo_mode || 'inherit',
|
||||||
|
promo_markdown: event.promo_markdown || null,
|
||||||
|
// Info banner override (#932). GalleryAuthContext refreshes its cached
|
||||||
|
// event from THIS payload, so the fields have to travel here — /info
|
||||||
|
// alone isn't enough, the context stops reading it once the guest is
|
||||||
|
// authenticated.
|
||||||
|
info_mode: event.info_mode || 'inherit',
|
||||||
|
info_markdown: event.info_markdown || null,
|
||||||
|
download_zip_ready: !!(event.download_zip_path && event.download_zip_generated_at),
|
||||||
|
// Mirror of the admin-side toggle so the lightbox can decide
|
||||||
|
// whether to surface original camera filenames (#508).
|
||||||
|
use_original_filenames: useOriginalFilenames,
|
||||||
|
// "People in this gallery" (#1074). False whenever the global flag
|
||||||
|
// is off, detection is off for this event, or the photographer chose
|
||||||
|
// to keep the strip to themselves — the frontend renders no face UI
|
||||||
|
// at all in that case.
|
||||||
|
people_enabled: peopleEnabled,
|
||||||
|
...protectionSettings
|
||||||
|
},
|
||||||
|
// Reveal mode (#838): the guest UI switches to the upload-only view
|
||||||
|
// on this flag; reveal_at lets it show the scheduled time.
|
||||||
|
hidden_until_reveal: hiddenForGuest,
|
||||||
|
reveal_at: hiddenForGuest ? (event.reveal_at || null) : undefined,
|
||||||
|
categories: categories,
|
||||||
|
photos: photos.map(photo => {
|
||||||
|
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||||
|
// Watermark version (cache-busting) + admin-preview flag (#868). In
|
||||||
|
// preview mode no gallery cookie is minted, so each <img> request must
|
||||||
|
// re-assert the admin session — thread the flag onto every /api/gallery
|
||||||
|
// image URL so the browser sends it (the admin_token cookie rides along
|
||||||
|
// same-origin).
|
||||||
|
const imgQuery = [wmVersion, adminPreview ? 'admin_preview=1' : ''].filter(Boolean).join('&');
|
||||||
|
const wmQuery = imgQuery ? `?${imgQuery}` : '';
|
||||||
|
const photoUrl = useJwtUrl ?
|
||||||
|
`/api/gallery/${slug}/photo/${photo.id}${wmQuery}` :
|
||||||
|
`/api/secure-images/${slug}/secure/${photo.id}/{{token}}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: photo.id,
|
||||||
|
filename: photo.filename,
|
||||||
|
// Raw camera filename (or null for pre-migration-062 uploads).
|
||||||
|
// The lightbox renders it when `use_original_filenames` is on.
|
||||||
|
original_filename: photo.original_filename || null,
|
||||||
|
url: photoUrl,
|
||||||
|
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${slug}/thumbnail/${photo.id}${wmQuery}` : null,
|
||||||
|
// Hero-optimized image URL (1920x1080) for full-width hero sections
|
||||||
|
hero_url: `/api/gallery/${slug}/hero/${photo.id}${wmQuery}`,
|
||||||
|
// Lightbox preview URL (#492). Only emitted when the admin
|
||||||
|
// has flipped lightbox_preview_enabled — the frontend
|
||||||
|
// lightbox reads preview_url with a fallback to url so
|
||||||
|
// installs that haven't opted in keep loading the original
|
||||||
|
// (current behaviour). Skipped for videos since they don't
|
||||||
|
// get a preview tier; lightbox will use the original .url.
|
||||||
|
preview_url: (lightboxPreviewEnabled || originalNeedsPreview(photo))
|
||||||
|
&& photo.media_type !== 'video'
|
||||||
|
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
|
||||||
|
? `/api/gallery/${slug}/preview/${photo.id}${wmQuery}`
|
||||||
|
: null,
|
||||||
|
// Slideshow source (#1015). Same preview tier, but emitted
|
||||||
|
// unconditionally: the slideshow has no `url` fallback worth
|
||||||
|
// taking (originals are projector-sized) and must never land on
|
||||||
|
// `hero_url`, which is cover-cropped to 16:9 — that made the
|
||||||
|
// "no crop" fit letterbox an already-cropped frame. The preview
|
||||||
|
// route generates lazily and redirects to the original on any
|
||||||
|
// failure, so this is safe even where no preview exists yet.
|
||||||
|
slideshow_url: photo.media_type !== 'video'
|
||||||
|
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
|
||||||
|
? `/api/gallery/${slug}/preview/${photo.id}${wmQuery}`
|
||||||
|
: null,
|
||||||
|
secure_url_template: `/api/secure-images/${slug}/secure/${photo.id}/{{token}}`,
|
||||||
|
download_url_template: `/api/secure-images/${slug}/secure-download/${photo.id}/{{token}}`,
|
||||||
|
type: photo.type,
|
||||||
|
category_id: photo.category_id || null,
|
||||||
|
category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null,
|
||||||
|
// Per-category download permission (#640). Defaults true for photos
|
||||||
|
// without a category or for categories that pre-date migration 135.
|
||||||
|
category_allow_downloads: photo.category_id && categoryMap[photo.category_id]
|
||||||
|
? parseBooleanInput(categoryMap[photo.category_id].allow_downloads, true)
|
||||||
|
: true,
|
||||||
|
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
|
||||||
|
size: photo.size_bytes,
|
||||||
|
// toIso: on SQLite installs rows written with a raw Date (e.g.
|
||||||
|
// the pre-fix archive-restore path) hold epoch numbers — the
|
||||||
|
// Timeline layout's parseISO() crashes on those (#485 class).
|
||||||
|
uploaded_at: toIso(photo.uploaded_at),
|
||||||
|
// Image dimensions for layout calculations
|
||||||
|
width: photo.width || null,
|
||||||
|
height: photo.height || null,
|
||||||
|
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||||
|
requires_token: !useJwtUrl,
|
||||||
|
// EXIF capture date
|
||||||
|
captured_at: toIso(photo.captured_at) || null,
|
||||||
|
// Media type
|
||||||
|
media_type: photo.media_type || null,
|
||||||
|
mime_type: photo.mime_type || null,
|
||||||
|
duration: photo.duration || null,
|
||||||
|
// Feedback data (hidden when show_feedback_to_guests is disabled)
|
||||||
|
has_feedback: showFeedbackToGuests ? (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0) : false,
|
||||||
|
average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0,
|
||||||
|
comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0,
|
||||||
|
like_count: showFeedbackToGuests ? (photo.like_count || 0) : 0,
|
||||||
|
// Per-viewer flag (#590 follow-up) — true when this viewer has
|
||||||
|
// an active like row for this photo, false otherwise. Lets the
|
||||||
|
// grid seed its lifted likedPhotoIds correctly on hard refresh.
|
||||||
|
// Survives show_feedback_to_guests being off (#1286): the viewer's
|
||||||
|
// own heart is theirs, and the like_count beside it stays hidden.
|
||||||
|
is_liked: likedPhotoIds.has(photo.id),
|
||||||
|
favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0,
|
||||||
|
// Colour labels (#1044). The COUNT is aggregate data and follows
|
||||||
|
// show_feedback_to_guests like its siblings; the viewer's OWN label
|
||||||
|
// is not aggregate and must survive with sharing off, otherwise the
|
||||||
|
// grid badge disappears on refresh for the very guest who set it.
|
||||||
|
color_label_count: showFeedbackToGuests ? (photo.color_label_count || 0) : 0,
|
||||||
|
my_color_label: myColorLabelByPhoto[photo.id] || null,
|
||||||
|
// Distinct colours other viewers put on this photo (#1178), so the
|
||||||
|
// grid can show them beside the viewer's own badge. Empty with
|
||||||
|
// sharing off — it is other people's feedback.
|
||||||
|
other_color_labels: otherColorLabelsByPhoto[photo.id] || [],
|
||||||
|
// People in this photo (#1074). Empty array when the feature is
|
||||||
|
// off for this event or hidden from guests, so the frontend has
|
||||||
|
// one shape to handle. Riding along on this payload is what keeps
|
||||||
|
// face filtering client-side and instant, like the category and
|
||||||
|
// liked/rated filters.
|
||||||
|
person_ids: personIdsByPhoto.get(photo.id) || [],
|
||||||
|
// Visibility (only included for clients)
|
||||||
|
...(isClient ? { visibility: photo.visibility || 'visible' } : {})
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
module.exports = { getGalleryPhotos };
|
||||||
@@ -24,13 +24,13 @@
|
|||||||
* `crmSchedulerService` is a future cleanup.
|
* `crmSchedulerService` is a future cleanup.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const cron = require('node-cron');
|
const { scheduledTask } = require('./scheduledTask');
|
||||||
const invoiceService = require('./invoiceService');
|
const invoiceService = require('./invoiceService');
|
||||||
const eventReminderService = require('./eventReminderService');
|
const eventReminderService = require('./eventReminderService');
|
||||||
const quoteService = require('./quoteService');
|
const quoteService = require('./quoteService');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
let task = null;
|
|
||||||
|
|
||||||
async function runTick() {
|
async function runTick() {
|
||||||
try {
|
try {
|
||||||
@@ -71,31 +71,8 @@ async function runTick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startInvoiceScheduler() {
|
const task = scheduledTask(runTick, { schedule: '11 * * * *', initialDelay: 0 });
|
||||||
if (task) {
|
function startInvoiceScheduler() { task.start(); return task; }
|
||||||
logger.info('Invoice scheduler already running');
|
const stopInvoiceScheduler = () => task.stop();
|
||||||
return task;
|
|
||||||
}
|
|
||||||
// Hourly at minute 11 to spread load away from other hourly jobs.
|
|
||||||
task = cron.schedule('11 * * * *', async () => {
|
|
||||||
logger.info('Invoice scheduler: tick');
|
|
||||||
await runTick();
|
|
||||||
});
|
|
||||||
logger.info('Invoice scheduler started (hourly @ :11) — invoice + event-reminder jobs');
|
|
||||||
// Run once on boot so a missed window (server restart) gets caught
|
|
||||||
// up immediately.
|
|
||||||
runTick().catch((err) => {
|
|
||||||
logger.warn('Invoice scheduler initial tick failed', { err: err.message });
|
|
||||||
});
|
|
||||||
return task;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopInvoiceScheduler() {
|
|
||||||
if (task) {
|
|
||||||
task.stop();
|
|
||||||
task = null;
|
|
||||||
logger.info('Invoice scheduler stopped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { startInvoiceScheduler, stopInvoiceScheduler };
|
module.exports = { startInvoiceScheduler, stopInvoiceScheduler };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { requestLogPath } = require('../utils/requestLogPath');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { MemoryStore } = require('express-rate-limit');
|
const { MemoryStore } = require('express-rate-limit');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
@@ -250,19 +251,18 @@ async function createRateLimiter(store = new MemoryStore()) {
|
|||||||
// Enhanced logging for production analysis
|
// Enhanced logging for production analysis
|
||||||
logger.warn('Rate limit exceeded', {
|
logger.warn('Rate limit exceeded', {
|
||||||
ip: clientIp,
|
ip: clientIp,
|
||||||
path: req.path,
|
path: requestLogPath(req.originalUrl || req.path),
|
||||||
method: req.method,
|
method: req.method,
|
||||||
authenticated: isAuthenticated(req),
|
authenticated: isAuthenticated(req),
|
||||||
tokenType: req.tokenType,
|
tokenType: req.tokenType,
|
||||||
userAgent: req.headers['user-agent'],
|
userAgent: req.headers['user-agent'],
|
||||||
referer: req.headers['referer'],
|
|
||||||
origin: req.headers['origin'],
|
origin: req.headers['origin'],
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
headers: {
|
headers: {
|
||||||
'x-forwarded-for': req.headers['x-forwarded-for'],
|
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||||
'x-real-ip': req.headers['x-real-ip']
|
'x-real-ip': req.headers['x-real-ip']
|
||||||
},
|
},
|
||||||
requestUrl: req.originalUrl,
|
requestUrl: requestLogPath(req.originalUrl || req.path),
|
||||||
rateLimitInfo: {
|
rateLimitInfo: {
|
||||||
limit: req.rateLimit?.limit,
|
limit: req.rateLimit?.limit,
|
||||||
current: req.rateLimit?.current,
|
current: req.rateLimit?.current,
|
||||||
@@ -318,7 +318,7 @@ async function createAuthRateLimiter(store = new MemoryStore()) {
|
|||||||
// Enhanced logging for auth failures
|
// Enhanced logging for auth failures
|
||||||
logger.warn('Auth rate limit exceeded', {
|
logger.warn('Auth rate limit exceeded', {
|
||||||
ip: clientIp,
|
ip: clientIp,
|
||||||
path: req.path,
|
path: requestLogPath(req.originalUrl || req.path),
|
||||||
method: req.method,
|
method: req.method,
|
||||||
userAgent: req.headers['user-agent'],
|
userAgent: req.headers['user-agent'],
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -326,7 +326,7 @@ async function createAuthRateLimiter(store = new MemoryStore()) {
|
|||||||
'x-forwarded-for': req.headers['x-forwarded-for'],
|
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||||
'x-real-ip': req.headers['x-real-ip']
|
'x-real-ip': req.headers['x-real-ip']
|
||||||
},
|
},
|
||||||
requestUrl: req.originalUrl,
|
requestUrl: requestLogPath(req.originalUrl || req.path),
|
||||||
authType: req.path.includes('admin') ? 'admin' : 'gallery',
|
authType: req.path.includes('admin') ? 'admin' : 'gallery',
|
||||||
rateLimitInfo: {
|
rateLimitInfo: {
|
||||||
limit: req.rateLimit?.limit,
|
limit: req.rateLimit?.limit,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* workflow trigger so hosts can hook a notification email onto it.
|
* workflow trigger so hosts can hook a notification email onto it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const cron = require('node-cron');
|
const { scheduledTask } = require('./scheduledTask');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -68,9 +68,9 @@ async function checkScheduledReveals() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRevealScheduler() {
|
const task = scheduledTask(checkScheduledReveals, { schedule: '* * * * *' });
|
||||||
cron.schedule('* * * * *', checkScheduledReveals);
|
function startRevealScheduler() { task.start(); }
|
||||||
logger.info('Reveal scheduler started');
|
const stopRevealScheduler = () => task.stop();
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { startRevealScheduler, checkScheduledReveals };
|
module.exports = {
|
||||||
|
stopRevealScheduler, startRevealScheduler, checkScheduledReveals };
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ const ENABLED = process.env.STORAGE_AUTO_IMPORT === 'true';
|
|||||||
// On the next poll, any key in BOTH the previous and current snapshots is
|
// On the next poll, any key in BOTH the previous and current snapshots is
|
||||||
// eligible for import. This is the eventual-consistency gate.
|
// eligible for import. This is the eventual-consistency gate.
|
||||||
const previousSnapshot = new Map();
|
const previousSnapshot = new Map();
|
||||||
let intervalHandle = null;
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|
||||||
async function tick() {
|
async function runTick() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
if (storage.kind() !== 's3') return; // no-op for local fs
|
if (storage.kind() !== 's3') return; // no-op for local fs
|
||||||
@@ -157,24 +157,19 @@ async function processEvent(event, storage) {
|
|||||||
previousSnapshot.set(event.id, currentKeys);
|
previousSnapshot.set(event.id, currentKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pollingTask = require('./scheduledTask').scheduledTask(runTick, {
|
||||||
|
// Run once on start so admins see import activity without waiting a full poll.
|
||||||
|
interval: POLL_INTERVAL_MS, initialDelay: 0,
|
||||||
|
});
|
||||||
|
const tick = () => runTick(); // Explicit test/manual tick does not start a timer.
|
||||||
function startS3AutoImporter() {
|
function startS3AutoImporter() {
|
||||||
if (!ENABLED) return null;
|
if (!ENABLED) return;
|
||||||
if (intervalHandle) return intervalHandle;
|
|
||||||
stopped = false;
|
stopped = false;
|
||||||
// Run once on startup so admins see import activity in logs without
|
pollingTask.start();
|
||||||
// waiting for the first poll interval.
|
|
||||||
tick().catch((err) => logger.error(`[s3AutoImporter] initial tick error: ${err.message}`));
|
|
||||||
intervalHandle = setInterval(tick, POLL_INTERVAL_MS);
|
|
||||||
logger.info(`[s3AutoImporter] started — interval=${POLL_INTERVAL_MS}ms`);
|
|
||||||
return intervalHandle;
|
|
||||||
}
|
}
|
||||||
|
async function stopS3AutoImporter() {
|
||||||
function stopS3AutoImporter() {
|
|
||||||
stopped = true;
|
stopped = true;
|
||||||
if (intervalHandle) {
|
await pollingTask.stop();
|
||||||
clearInterval(intervalHandle);
|
|
||||||
intervalHandle = null;
|
|
||||||
}
|
|
||||||
previousSnapshot.clear();
|
previousSnapshot.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const cron = require('node-cron');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
/** One owner for a periodic job, with no overlapping runs and a draining stop. */
|
||||||
|
function scheduledTask(run, { schedule, interval, initialDelay } = {}) {
|
||||||
|
let started = false, timer = null, first = null, running = null;
|
||||||
|
const tick = () => {
|
||||||
|
if (!started || running) return running;
|
||||||
|
running = Promise.resolve().then(run).catch(error => {
|
||||||
|
logger.error('Scheduled task failed', { error: error.message });
|
||||||
|
}).finally(() => { running = null; });
|
||||||
|
return running;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
timer = schedule ? cron.schedule(schedule, tick) : setInterval(tick, interval);
|
||||||
|
if (!schedule) timer.unref?.();
|
||||||
|
if (initialDelay !== undefined) { first = setTimeout(tick, initialDelay); first.unref?.(); }
|
||||||
|
},
|
||||||
|
async stop() {
|
||||||
|
started = false;
|
||||||
|
if (schedule) timer?.stop(); else clearInterval(timer);
|
||||||
|
clearTimeout(first); timer = null; first = null;
|
||||||
|
await running;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
module.exports = { scheduledTask };
|
||||||
@@ -9,6 +9,25 @@ class SecureImageService {
|
|||||||
this.tokenCache = new Map();
|
this.tokenCache = new Map();
|
||||||
this.sessionTokens = new Map();
|
this.sessionTokens = new Map();
|
||||||
this.rateLimitCache = new Map();
|
this.rateLimitCache = new Map();
|
||||||
|
this.cleanupTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.cleanupTimer) return;
|
||||||
|
this.cleanupTimer = setInterval(() => this.cleanup(), 60_000);
|
||||||
|
this.cleanupTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
clearInterval(this.cleanupTimer);
|
||||||
|
this.cleanupTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.stop();
|
||||||
|
this.tokenCache.clear();
|
||||||
|
this.sessionTokens.clear();
|
||||||
|
this.rateLimitCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,9 +46,14 @@ class SecureImageService {
|
|||||||
// Whether the minter was a PIN-client — lets the serve route keep
|
// Whether the minter was a PIN-client — lets the serve route keep
|
||||||
// delivering a photo hidden AFTER minting (TOCTOU). A guest's token
|
// delivering a photo hidden AFTER minting (TOCTOU). A guest's token
|
||||||
// carries false, so it stops the moment the photo is hidden.
|
// carries false, so it stops the moment the photo is hidden.
|
||||||
clientBypass = false
|
clientBypass = false,
|
||||||
|
galleryAccess = null
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
if (!Number.isFinite(Number(expiresIn)) || Number(expiresIn) <= 0 || Number(expiresIn) > 3600) {
|
||||||
|
throw new (require('../utils/errors').ValidationError)('Invalid image token lifetime');
|
||||||
|
}
|
||||||
|
|
||||||
const tokenData = {
|
const tokenData = {
|
||||||
photoId: parseInt(photoId),
|
photoId: parseInt(photoId),
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -40,6 +64,7 @@ class SecureImageService {
|
|||||||
protectionLevel,
|
protectionLevel,
|
||||||
revealBypass,
|
revealBypass,
|
||||||
clientBypass,
|
clientBypass,
|
||||||
|
galleryAccess,
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,10 +81,8 @@ class SecureImageService {
|
|||||||
// Cache token with metadata
|
// Cache token with metadata
|
||||||
this.tokenCache.set(token, tokenData);
|
this.tokenCache.set(token, tokenData);
|
||||||
|
|
||||||
// Set cleanup timer
|
// One owned timer per service, not one live handle per issued token.
|
||||||
setTimeout(() => {
|
this.start();
|
||||||
this.tokenCache.delete(token);
|
|
||||||
}, expiresIn * 1000 + 60000); // Add 1 minute buffer
|
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -217,9 +240,7 @@ class SecureImageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
image = image.withMetadata({
|
image = image.withMetadata({
|
||||||
exif: {
|
exif: { IFD0: { ImageDescription: `Protected:${fingerprint}` } }
|
||||||
[sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return await image.toBuffer();
|
return await image.toBuffer();
|
||||||
@@ -261,9 +282,7 @@ class SecureImageService {
|
|||||||
|
|
||||||
// Embed fingerprint in metadata
|
// Embed fingerprint in metadata
|
||||||
image = image.withMetadata({
|
image = image.withMetadata({
|
||||||
exif: {
|
exif: { IFD0: { ImageDescription: `Protected:${fingerprint}` } }
|
||||||
[sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +449,9 @@ class SecureImageService {
|
|||||||
cleanup() {
|
cleanup() {
|
||||||
// Clear expired rate limit entries
|
// Clear expired rate limit entries
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
for (const [token, data] of this.tokenCache) {
|
||||||
|
if (data.expiresAt <= now) this.tokenCache.delete(token);
|
||||||
|
}
|
||||||
for (const [clientId, requests] of this.rateLimitCache.entries()) {
|
for (const [clientId, requests] of this.rateLimitCache.entries()) {
|
||||||
const recent = requests.filter(timestamp => timestamp > now - 60000);
|
const recent = requests.filter(timestamp => timestamp > now - 60000);
|
||||||
if (recent.length === 0) {
|
if (recent.length === 0) {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const logger = require('../utils/logger');
|
||||||
|
// Resolve only services already loaded by startup. Shutdown must not construct
|
||||||
|
// unrelated singletons or start new work just to stop it.
|
||||||
|
const resources = [
|
||||||
|
['../middleware/sessionTimeout', 'dispose'], ['./chunkedUploadService', 'stop'],
|
||||||
|
['../utils/cleanupTempUploads', 'stopTempUploadCleanup'],
|
||||||
|
['../middleware/secureImageMiddleware', 'dispose'], ['../middleware/feedbackRateLimit', 'dispose'],
|
||||||
|
['./downloadZipService', 'stop'],
|
||||||
|
['./fileWatcher', 'stopFileWatcher'], ['./externalMediaWatcher', 'stopExternalMediaWatcher'],
|
||||||
|
['./expirationChecker', 'stopExpirationChecker'], ['./transferCleanupService', 'stopTransferCleanup'],
|
||||||
|
['./downloadJobCleanupService', 'stopDownloadJobCleanup'], ['./revealScheduler', 'stopRevealScheduler'],
|
||||||
|
['./invoiceSchedulerService', 'stopInvoiceScheduler'], ['./emailProcessor', 'stopEmailQueueProcessor'],
|
||||||
|
['./whatsappProcessor', 'stopWhatsAppQueueProcessor'], ['./emailIntakeService', 'stopIncomingMailPoller'],
|
||||||
|
['./webhookDeliveryWorker', 'stopWebhookDeliveryWorker'], ['./s3AutoImporter', 'stopS3AutoImporter'],
|
||||||
|
['./backupService', 'stopBackupService'], ['./databaseBackup', 'stopScheduledBackups'],
|
||||||
|
['./backgroundProcessor', 'stop'], ['./faceQueue', 'stop'], ['./secureImageService', 'dispose'],
|
||||||
|
['../utils/authSecurity', 'stopCleanupJob'], ['../utils/tokenRevocation', 'stopRevocationCleanup'],
|
||||||
|
];
|
||||||
|
async function stopServices() {
|
||||||
|
const results = await Promise.allSettled(resources.map(async ([path, method]) => {
|
||||||
|
const loaded = require.cache[require.resolve(path)];
|
||||||
|
if (typeof loaded?.exports[method] === 'function') await loaded.exports[method]();
|
||||||
|
}));
|
||||||
|
const failures = results.filter(result => result.status === 'rejected');
|
||||||
|
failures.forEach(result => logger.error('Service shutdown failed', { error: result.reason.message }));
|
||||||
|
if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'Service shutdown failed');
|
||||||
|
}
|
||||||
|
module.exports = { stopServices };
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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 { toTimestamp } = require('../utils/dateNormalize');
|
||||||
|
const { AppError } = require('../utils/errors');
|
||||||
|
|
||||||
|
/** Call only with a verified JWT payload or a signed, server-created grant. */
|
||||||
|
class SessionAccessService {
|
||||||
|
async assertActive(session, type) {
|
||||||
|
if (!session || session.type !== type) {
|
||||||
|
throw new AppError('Invalid token type', 403, 'WRONG_TOKEN_TYPE');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(session.iat)
|
||||||
|
|| (session.exp !== undefined && (!Number.isFinite(session.exp) || session.exp <= Date.now() / 1000))) {
|
||||||
|
throw new AppError('Token expired or invalid', 401, 'TOKEN_EXPIRED');
|
||||||
|
}
|
||||||
|
if (await isTokenRevoked(session)) {
|
||||||
|
throw new AppError('Token has been revoked', 401, 'TOKEN_REVOKED');
|
||||||
|
}
|
||||||
|
if (await isTokenBeforeCutoff(session)) {
|
||||||
|
throw new AppError('Session invalidated', 401, 'SESSION_INVALIDATED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPasswordCurrent(account, session) {
|
||||||
|
if (account.password_changed_at == null) return;
|
||||||
|
const changed = toTimestamp(account.password_changed_at);
|
||||||
|
// Preserve the same-second login convention used by admin/customer auth.
|
||||||
|
if (!Number.isFinite(changed) || session.iat < Math.floor(changed / 1000)) {
|
||||||
|
throw new AppError('Token invalid due to password change', 401, 'PASSWORD_CHANGED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async admin(session, { includeProfile = false } = {}) {
|
||||||
|
await this.assertActive(session, 'admin');
|
||||||
|
let account;
|
||||||
|
try {
|
||||||
|
account = await db('admin_users')
|
||||||
|
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||||
|
.where({ 'admin_users.id': session.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',
|
||||||
|
...(includeProfile ? ['admin_users.must_change_password', 'roles.display_name as role_display_name'] : []))
|
||||||
|
.first();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingRolesSchema(error)) throw error;
|
||||||
|
account = await db('admin_users')
|
||||||
|
.where({ id: session.id, is_active: formatBoolean(true) })
|
||||||
|
.select('id', 'username', 'email', 'password_changed_at').first();
|
||||||
|
if (account) Object.assign(account, { role_id: null, role_name: 'super_admin' });
|
||||||
|
}
|
||||||
|
if (!account) throw new AppError('Invalid token', 401, 'ADMIN_NOT_FOUND');
|
||||||
|
this.assertPasswordCurrent(account, session);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async customer(session, { derived = false } = {}) {
|
||||||
|
if (!derived) await this.assertActive(session, 'customer');
|
||||||
|
if (!Number.isInteger(session.customerId)) {
|
||||||
|
throw new AppError('Invalid customer session', 401, 'CUSTOMER_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const account = await db('customer_accounts')
|
||||||
|
.where({ id: session.customerId, is_active: formatBoolean(true) })
|
||||||
|
.select('id', 'email', 'display_name', 'first_name', 'last_name', 'password_changed_at', 'preferred_language')
|
||||||
|
.first();
|
||||||
|
if (!account) throw new AppError('Invalid token', 401, 'CUSTOMER_NOT_FOUND');
|
||||||
|
this.assertPasswordCurrent(account, session);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new SessionAccessService();
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
* are deleted, which is what the retention cap is about.)
|
* are deleted, which is what the retention cap is about.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const cron = require('node-cron');
|
const { scheduledTask } = require('./scheduledTask');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
@@ -26,13 +26,9 @@ const transferService = require('./transferService');
|
|||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function startTransferCleanup() {
|
const task = scheduledTask(runTransferCleanup, { schedule: '15 * * * *' });
|
||||||
// Hourly at :15 — staggered from the gallery expiration checker (:00).
|
function startTransferCleanup() { task.start(); }
|
||||||
cron.schedule('15 * * * *', async () => {
|
const stopTransferCleanup = () => task.stop();
|
||||||
await runTransferCleanup();
|
|
||||||
});
|
|
||||||
logger.info('Transfer cleanup scheduler started');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTransferCleanup() {
|
async function runTransferCleanup() {
|
||||||
try {
|
try {
|
||||||
@@ -138,6 +134,7 @@ async function deleteRetiredTransfers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
stopTransferCleanup,
|
||||||
startTransferCleanup,
|
startTransferCleanup,
|
||||||
// exported for tests / manual invocation
|
// exported for tests / manual invocation
|
||||||
runTransferCleanup,
|
runTransferCleanup,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const axios = require('axios');
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { signPayload, renderTemplate } = require('./webhookService');
|
const { signPayload, renderTemplate } = require('./webhookService');
|
||||||
|
const { pinnedRequestOptions } = require('../utils/pinnedRequest');
|
||||||
const { validateExternalUrlAsync } = require('../utils/networkValidation');
|
const { validateExternalUrlAsync } = require('../utils/networkValidation');
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = parseInt(process.env.WEBHOOK_DELIVERY_INTERVAL_MS || '5000', 10);
|
const POLL_INTERVAL_MS = parseInt(process.env.WEBHOOK_DELIVERY_INTERVAL_MS || '5000', 10);
|
||||||
@@ -32,7 +33,7 @@ const BACKOFF_MS = [
|
|||||||
12 * 60 * 60_000, // 12 h (only used when MAX_ATTEMPTS extended past 5)
|
12 * 60 * 60_000, // 12 h (only used when MAX_ATTEMPTS extended past 5)
|
||||||
];
|
];
|
||||||
|
|
||||||
let intervalHandle = null;
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
// Tracks deliveries currently being processed in this tick — guards
|
// Tracks deliveries currently being processed in this tick — guards
|
||||||
// against the same row being claimed twice if a tick takes longer than
|
// against the same row being claimed twice if a tick takes longer than
|
||||||
@@ -95,6 +96,7 @@ async function deliverOne(row) {
|
|||||||
// host and vets every A/AAAA record (a public-looking name that now
|
// host and vets every A/AAAA record (a public-looking name that now
|
||||||
// resolves to an internal IP is rejected). Admin can opt out via
|
// resolves to an internal IP is rejected). Admin can opt out via
|
||||||
// WEBHOOK_ALLOW_PRIVATE_URLS=true for local-receiver dev runs.
|
// WEBHOOK_ALLOW_PRIVATE_URLS=true for local-receiver dev runs.
|
||||||
|
let connectionOptions = {};
|
||||||
if (!allowPrivateUrls) {
|
if (!allowPrivateUrls) {
|
||||||
const urlCheck = await validateExternalUrlAsync(webhook.url);
|
const urlCheck = await validateExternalUrlAsync(webhook.url);
|
||||||
if (!urlCheck.valid) {
|
if (!urlCheck.valid) {
|
||||||
@@ -111,6 +113,7 @@ async function deliverOne(row) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
connectionOptions = pinnedRequestOptions(urlCheck);
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelopeBody = typeof row.payload === 'string' ? row.payload : JSON.stringify(row.payload);
|
const envelopeBody = typeof row.payload === 'string' ? row.payload : JSON.stringify(row.payload);
|
||||||
@@ -139,6 +142,7 @@ async function deliverOne(row) {
|
|||||||
let networkError;
|
let networkError;
|
||||||
try {
|
try {
|
||||||
response = await axios.post(webhook.url, rawBody, {
|
response = await axios.post(webhook.url, rawBody, {
|
||||||
|
...connectionOptions,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
[SIGNATURE_HEADER]: signature,
|
[SIGNATURE_HEADER]: signature,
|
||||||
@@ -266,7 +270,7 @@ function stringifyBody(data) {
|
|||||||
try { return JSON.stringify(data); } catch { return String(data); }
|
try { return JSON.stringify(data); } catch { return String(data); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tick() {
|
async function runTick() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
try {
|
try {
|
||||||
const slots = Math.max(0, CONCURRENCY - inFlight.size);
|
const slots = Math.max(0, CONCURRENCY - inFlight.size);
|
||||||
@@ -286,22 +290,15 @@ async function tick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pollingTask = require('./scheduledTask').scheduledTask(runTick, { interval: POLL_INTERVAL_MS });
|
||||||
|
const tick = () => runTick(); // Explicit test/manual tick does not start a timer.
|
||||||
function startWebhookDeliveryWorker() {
|
function startWebhookDeliveryWorker() {
|
||||||
if (intervalHandle) return; // idempotent
|
|
||||||
stopped = false;
|
stopped = false;
|
||||||
intervalHandle = setInterval(tick, POLL_INTERVAL_MS);
|
pollingTask.start();
|
||||||
logger.info(
|
|
||||||
`[webhookWorker] started — interval=${POLL_INTERVAL_MS}ms, concurrency=${CONCURRENCY}, ` +
|
|
||||||
`max_attempts=${MAX_ATTEMPTS}, allow_private=${allowPrivateUrls}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
async function stopWebhookDeliveryWorker() {
|
||||||
function stopWebhookDeliveryWorker() {
|
|
||||||
stopped = true;
|
stopped = true;
|
||||||
if (intervalHandle) {
|
await pollingTask.stop();
|
||||||
clearInterval(intervalHandle);
|
|
||||||
intervalHandle = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const POLL_INTERVAL_MS = parseInt(process.env.WHATSAPP_QUEUE_POLL_MS || '30000',
|
|||||||
const CYCLE_BATCH_SIZE = parseInt(process.env.WHATSAPP_QUEUE_BATCH || '10', 10);
|
const CYCLE_BATCH_SIZE = parseInt(process.env.WHATSAPP_QUEUE_BATCH || '10', 10);
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
let pollHandle = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a Meta template language code from whatever's in the message_data
|
* Resolve a Meta template language code from whatever's in the message_data
|
||||||
@@ -301,29 +301,9 @@ async function processWhatsAppQueue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startWhatsAppQueueProcessor() {
|
const whatsappTask = require('./scheduledTask').scheduledTask(processWhatsAppQueue, { interval: POLL_INTERVAL_MS, initialDelay: 5000 });
|
||||||
if (pollHandle) {
|
function startWhatsAppQueueProcessor() { whatsappTask.start(); }
|
||||||
logger.info('WhatsApp queue processor already running — skipping start');
|
const stopWhatsAppQueueProcessor = () => whatsappTask.stop();
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Fire once shortly after boot so the first message in a fresh install
|
|
||||||
// doesn't wait the full poll interval.
|
|
||||||
setTimeout(() => {
|
|
||||||
processWhatsAppQueue().catch((e) => logger.error('WhatsApp queue initial run failed', e));
|
|
||||||
}, 5000);
|
|
||||||
pollHandle = setInterval(() => {
|
|
||||||
processWhatsAppQueue().catch((e) => logger.error('WhatsApp queue cycle failed', e));
|
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
logger.info(`WhatsApp queue processor started (poll every ${POLL_INTERVAL_MS}ms)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopWhatsAppQueueProcessor() {
|
|
||||||
if (pollHandle) {
|
|
||||||
clearInterval(pollHandle);
|
|
||||||
pollHandle = null;
|
|
||||||
logger.info('WhatsApp queue processor stopped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
queueWhatsapp,
|
queueWhatsapp,
|
||||||
|
|||||||
@@ -34,24 +34,23 @@ async function startWorkers() {
|
|||||||
logger.info('All background workers started successfully');
|
logger.info('All background workers started successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to start background workers:', error);
|
logger.error('Failed to start background workers:', error);
|
||||||
process.exit(1);
|
process.exitCode = 1;
|
||||||
|
await handleShutdown('startup failure');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShutdown(signal) {
|
async function handleShutdown(signal) {
|
||||||
if (isShuttingDown) {
|
if (isShuttingDown) return;
|
||||||
logger.info('Shutdown already in progress...');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isShuttingDown = true;
|
isShuttingDown = true;
|
||||||
logger.info(`Received ${signal}. Shutting down gracefully...`);
|
logger.info(`Received ${signal}. Shutting down gracefully...`);
|
||||||
|
try {
|
||||||
// Give time for cleanup
|
await require('./serviceShutdown').stopServices();
|
||||||
setTimeout(() => {
|
await require('../database/db').db.destroy();
|
||||||
logger.info('Worker manager shutdown complete');
|
logger.info('Worker manager shutdown complete');
|
||||||
process.exit(0);
|
} catch (error) {
|
||||||
}, 1000);
|
logger.error('Worker shutdown failed', { error: error.message });
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle shutdown signals
|
// Handle shutdown signals
|
||||||
|
|||||||
@@ -342,15 +342,12 @@ async function cleanupOldAttempts() {
|
|||||||
/**
|
/**
|
||||||
* Initialize cleanup job
|
* Initialize cleanup job
|
||||||
*/
|
*/
|
||||||
function initializeCleanupJob() {
|
const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupOldAttempts, { interval: 24 * 60 * 60 * 1000, initialDelay: 0 });
|
||||||
// Run cleanup every 24 hours
|
function initializeCleanupJob() { cleanupTask.start(); }
|
||||||
setInterval(cleanupOldAttempts, 24 * 60 * 60 * 1000);
|
const stopCleanupJob = () => cleanupTask.stop();
|
||||||
|
|
||||||
// Run initial cleanup
|
|
||||||
cleanupOldAttempts();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
stopCleanupJob,
|
||||||
trackFailedAttempt,
|
trackFailedAttempt,
|
||||||
trackSuccessfulLogin,
|
trackSuccessfulLogin,
|
||||||
checkAccountLockout,
|
checkAccountLockout,
|
||||||
|
|||||||
@@ -60,19 +60,10 @@ async function cleanupTempUploads() {
|
|||||||
* Start periodic cleanup of temp uploads
|
* Start periodic cleanup of temp uploads
|
||||||
* Runs every hour
|
* Runs every hour
|
||||||
*/
|
*/
|
||||||
function startTempUploadCleanup() {
|
const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupTempUploads, {
|
||||||
// Run immediately on startup
|
interval: 60 * 60 * 1000, initialDelay: 0
|
||||||
cleanupTempUploads();
|
});
|
||||||
|
function startTempUploadCleanup() { cleanupTask.start(); }
|
||||||
|
function stopTempUploadCleanup() { return cleanupTask.stop(); }
|
||||||
|
|
||||||
// Then run every hour
|
module.exports = { cleanupTempUploads, startTempUploadCleanup, stopTempUploadCleanup };
|
||||||
setInterval(() => {
|
|
||||||
cleanupTempUploads();
|
|
||||||
}, 60 * 60 * 1000); // 1 hour
|
|
||||||
|
|
||||||
logger.info('Temp upload cleanup service started');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
cleanupTempUploads,
|
|
||||||
startTempUploadCleanup
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -33,4 +33,15 @@ function toIso(value) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { toIso };
|
// Shared comparison boundary for SQLite epoch values and PostgreSQL Dates.
|
||||||
|
// Invalid input stays NaN so access-control callers can fail closed.
|
||||||
|
function toTimestamp(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return NaN;
|
||||||
|
try {
|
||||||
|
return new Date(toIso(value)).getTime();
|
||||||
|
} catch (_) {
|
||||||
|
return NaN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { toIso, toTimestamp };
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const { toTimestamp } = require('./dateNormalize');
|
||||||
|
const { AppError } = require('./errors');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
const warnedExpiry = new Set();
|
||||||
|
|
||||||
|
const isTrue = (value) => value === true || value === 1 || value === '1';
|
||||||
|
|
||||||
|
function isGalleryExpired(event, now = Date.now()) {
|
||||||
|
if (event.expires_at == null || event.expires_at === '') return false;
|
||||||
|
const expiry = toTimestamp(event.expires_at);
|
||||||
|
if (!Number.isFinite(expiry)) {
|
||||||
|
// Fail closed, but name the row once so an operator can repair it.
|
||||||
|
if (!warnedExpiry.has(event.id)) {
|
||||||
|
warnedExpiry.add(event.id);
|
||||||
|
logger.warn('Unparseable events.expires_at treated as expired', { eventId: event.id, expires_at: String(event.expires_at) });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return expiry <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiresGalleryPassword(event) {
|
||||||
|
return !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGalleryAvailable(event, { adminPreview = false } = {}) {
|
||||||
|
return !!event && isTrue(event.is_active) && !isTrue(event.is_archived)
|
||||||
|
&& (adminPreview || (!isTrue(event.is_draft) && !isGalleryExpired(event)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertGalleryAvailable(event, { adminPreview = false } = {}) {
|
||||||
|
if (!isGalleryAvailable(event, { adminPreview })) {
|
||||||
|
throw new AppError('Gallery not found or expired', 404, 'GALLERY_UNAVAILABLE');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isGalleryAvailable, assertGalleryAvailable, isGalleryExpired, requiresGalleryPassword };
|
||||||
@@ -190,30 +190,30 @@ function validateExternalUrl(urlString) {
|
|||||||
* literal isPrivateIP check alone can't see that. Fails closed on resolution
|
* literal isPrivateIP check alone can't see that. Fails closed on resolution
|
||||||
* failure. IP literals are decided by isPrivateIP without a lookup.
|
* failure. IP literals are decided by isPrivateIP without a lookup.
|
||||||
*
|
*
|
||||||
* Residual: a determined attacker who controls DNS can still rebind between
|
* HTTP clients for admin-configured URLs (webhook delivery, the email webhook
|
||||||
* this check and the client's own resolution (TOCTOU). Fully closing that
|
* transport) must use the returned addresses from validateExternalUrlAsync
|
||||||
* needs pinning the connection to the vetted IP, which the underlying
|
* with pinnedRequestOptions; a separate preflight alone cannot stop rebinding.
|
||||||
* clients (nodemailer/imap/ssh/aws-sdk) don't cleanly support; these actions
|
* The analytics tracker proxy, the tracker adapters and OIDC discovery still
|
||||||
* are admin-only, so resolve-and-vet is the proportionate mitigation.
|
* rely on the preflight only.
|
||||||
*
|
*
|
||||||
* @param {string} hostname
|
* @param {string} hostname
|
||||||
* @returns {Promise<boolean>} true when safe to connect
|
* @returns {Promise<boolean>} true when safe to connect
|
||||||
*/
|
*/
|
||||||
async function classifyHost(hostname) {
|
async function resolveHost(hostname) {
|
||||||
if (!hostname || typeof hostname !== 'string') return 'invalid';
|
if (!hostname || typeof hostname !== 'string') return { reason: 'invalid' };
|
||||||
// Literal check first: IP literals, blocked names, .internal/.local/.localhost.
|
if (isPrivateIP(hostname)) return { reason: 'private' };
|
||||||
if (isPrivateIP(hostname)) return 'private';
|
|
||||||
// An IP literal is fully decided above — no name to resolve.
|
|
||||||
const bare = hostname.replace(/^\[|\]$/g, '');
|
const bare = hostname.replace(/^\[|\]$/g, '');
|
||||||
if (net.isIP(bare)) return 'ok';
|
if (net.isIP(bare)) return { reason: 'ok', addresses: [{ address: bare, family: net.isIP(bare) }] };
|
||||||
let addresses;
|
let addresses;
|
||||||
try {
|
try { addresses = await dns.lookup(hostname, { all: true }); }
|
||||||
addresses = await dns.lookup(hostname, { all: true });
|
catch { return { reason: 'unresolved' }; }
|
||||||
} catch {
|
if (!addresses.length) return { reason: 'unresolved' };
|
||||||
return 'unresolved'; // transient/NXDOMAIN — caller decides retry vs reject
|
if (addresses.some(a => !net.isIP(a.address) || isPrivateIP(a.address))) return { reason: 'private' };
|
||||||
|
return { reason: 'ok', addresses };
|
||||||
}
|
}
|
||||||
if (!addresses.length) return 'unresolved';
|
|
||||||
return addresses.every((a) => !isPrivateIP(a.address)) ? 'ok' : 'private';
|
async function classifyHost(hostname) {
|
||||||
|
return (await resolveHost(hostname)).reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isHostAllowed(hostname) {
|
async function isHostAllowed(hostname) {
|
||||||
@@ -237,11 +237,14 @@ async function validateExternalUrlAsync(urlString) {
|
|||||||
} catch {
|
} catch {
|
||||||
return { valid: false, error: 'Invalid URL format', reason: 'invalid' };
|
return { valid: false, error: 'Invalid URL format', reason: 'invalid' };
|
||||||
}
|
}
|
||||||
const reason = await classifyHost(parsed.hostname);
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
||||||
|
return { valid: false, error: 'HTTP(S) URL without credentials required', reason: 'invalid' };
|
||||||
|
}
|
||||||
|
const { reason, addresses } = await resolveHost(parsed.hostname);
|
||||||
if (reason !== 'ok') {
|
if (reason !== 'ok') {
|
||||||
return { valid: false, error: 'URL points to a private or internal network address', reason };
|
return { valid: false, error: 'URL points to a private or internal network address', reason };
|
||||||
}
|
}
|
||||||
return { valid: true, reason: 'ok' };
|
return { valid: true, reason: 'ok', hostname: parsed.hostname.replace(/^\[|\]$/g, ''), addresses };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { isPrivateIP, validateExternalUrl, isHostAllowed, validateExternalUrlAsync, classifyHost };
|
module.exports = { isPrivateIP, validateExternalUrl, isHostAllowed, validateExternalUrlAsync, classifyHost };
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/** Axios/Node lookup: connect only to the addresses vetted for this delivery.
|
||||||
|
* Keep the original URL for Host, TLS SNI and certificate verification.
|
||||||
|
* Disable environment proxies (which would resolve the destination themselves)
|
||||||
|
* and redirects. No reusable agent/socket can carry an old DNS decision.
|
||||||
|
*/
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
function pinnedRequestOptions(check) {
|
||||||
|
if (!check?.valid || !check.hostname || !check.addresses?.length) {
|
||||||
|
throw new Error('A validated destination is required');
|
||||||
|
}
|
||||||
|
const addresses = check.addresses.map(({ address, family }) => ({ address, family }));
|
||||||
|
const lookup = (hostname, options, callback) => {
|
||||||
|
if (typeof options === 'function') { callback = options; options = {}; }
|
||||||
|
if (hostname !== check.hostname) return callback(new Error('Destination hostname changed'));
|
||||||
|
const family = typeof options === 'number' ? options : options?.family;
|
||||||
|
const matches = family ? addresses.filter(a => a.family === family) : addresses;
|
||||||
|
if (!matches.length) return callback(new Error('No validated address for requested family'));
|
||||||
|
if (options?.all) return callback(null, matches);
|
||||||
|
callback(null, matches[0].address, matches[0].family);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
proxy: false, maxRedirects: 0,
|
||||||
|
httpAgent: new http.Agent({ lookup, keepAlive: false }),
|
||||||
|
httpsAgent: new https.Agent({ lookup, keepAlive: false }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
module.exports = { pinnedRequestOptions };
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { requestLogPath } = require('./requestLogPath');
|
||||||
/**
|
/**
|
||||||
* Rate Limiting Security Utilities
|
* Rate Limiting Security Utilities
|
||||||
* Provides secure rate limiting that prevents bypass attempts
|
* Provides secure rate limiting that prevents bypass attempts
|
||||||
@@ -42,7 +43,7 @@ function hasValidAdminToken(req) {
|
|||||||
// Must be admin type to skip rate limiting
|
// Must be admin type to skip rate limiting
|
||||||
if (decoded.type !== 'admin') {
|
if (decoded.type !== 'admin') {
|
||||||
logger.warn('Non-admin token attempted to bypass rate limit', {
|
logger.warn('Non-admin token attempted to bypass rate limit', {
|
||||||
path: req.path,
|
path: requestLogPath(req.originalUrl || req.path),
|
||||||
tokenType: decoded.type,
|
tokenType: decoded.type,
|
||||||
ip: req.ip
|
ip: req.ip
|
||||||
});
|
});
|
||||||
@@ -55,7 +56,7 @@ function hasValidAdminToken(req) {
|
|||||||
|
|
||||||
if (tokenAge > maxAge) {
|
if (tokenAge > maxAge) {
|
||||||
logger.warn('Old admin token attempted to bypass rate limit', {
|
logger.warn('Old admin token attempted to bypass rate limit', {
|
||||||
path: req.path,
|
path: requestLogPath(req.originalUrl || req.path),
|
||||||
tokenAge: Math.floor(tokenAge / 1000 / 60) + ' minutes',
|
tokenAge: Math.floor(tokenAge / 1000 / 60) + ' minutes',
|
||||||
ip: req.ip
|
ip: req.ip
|
||||||
});
|
});
|
||||||
@@ -70,7 +71,7 @@ function hasValidAdminToken(req) {
|
|||||||
// Log attempts with invalid tokens (potential attacks)
|
// Log attempts with invalid tokens (potential attacks)
|
||||||
if (error.name === 'JsonWebTokenError') {
|
if (error.name === 'JsonWebTokenError') {
|
||||||
logger.warn('Invalid token attempted to bypass rate limit', {
|
logger.warn('Invalid token attempted to bypass rate limit', {
|
||||||
path: req.path,
|
path: requestLogPath(req.originalUrl || req.path),
|
||||||
error: error.message,
|
error: error.message,
|
||||||
ip: req.ip
|
ip: req.ip
|
||||||
});
|
});
|
||||||
@@ -105,7 +106,7 @@ function createSecureSkipFunction() {
|
|||||||
function logRateLimitHit(req, res) {
|
function logRateLimitHit(req, res) {
|
||||||
logger.warn('Rate limit exceeded', {
|
logger.warn('Rate limit exceeded', {
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
path: req.path,
|
path: requestLogPath(req.originalUrl || req.path),
|
||||||
userAgent: req.headers['user-agent'],
|
userAgent: req.headers['user-agent'],
|
||||||
remaining: res.getHeader('X-RateLimit-Remaining'),
|
remaining: res.getHeader('X-RateLimit-Remaining'),
|
||||||
limit: res.getHeader('X-RateLimit-Limit')
|
limit: res.getHeader('X-RateLimit-Limit')
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** Log the path without query values or bearer capabilities embedded in it. */
|
||||||
|
function requestLogPath(value) {
|
||||||
|
const path = String(value || '/').split(/[?#]/, 1)[0];
|
||||||
|
return path
|
||||||
|
.replace(/(\/(?:signed|verify-token|show|download-jobs|invite|accept-invite|password-reset|unsubscribe)\/)[^/]+/gi, '$1[redacted]')
|
||||||
|
.replace(/(\/api\/public\/[^/]+\/)[^/]+/gi, '$1[redacted]')
|
||||||
|
.replace(/(\/(?:secure|secure-download)\/[^/]+\/)[^/]+/gi, '$1[redacted]')
|
||||||
|
.replace(/\b(?:[a-f0-9]{32,}|eyJ[A-Za-z0-9_.-]+)\b/gi, '[redacted]')
|
||||||
|
// eslint-disable-next-line no-control-regex -- strip log injection control bytes
|
||||||
|
.replace(/[\r\n\x00-\x1f]/g, '');
|
||||||
|
}
|
||||||
|
module.exports = { requestLogPath };
|
||||||
@@ -21,22 +21,27 @@ function isAllowedOrigin(origin) {
|
|||||||
return allowedOrigins.indexOf(origin) !== -1;
|
return allowedOrigins.indexOf(origin) !== -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Origin check for multipart bodies (see the Content-Type gate below).
|
// Check every browser mutation, including an empty form POST. Explicitly
|
||||||
// Same-origin installs proxy /api through nginx and may not have FRONTEND_URL
|
// configured frontend origins may be cross-site; a sibling origin alone is
|
||||||
// set, so an Origin matching the request Host is accepted alongside the CORS
|
// not trusted. Non-browser clients without Origin/Fetch Metadata still work.
|
||||||
// allowlist; Sec-Fetch-Site is authoritative when a browser sends it.
|
function mutationOriginAllowed(req) {
|
||||||
function multipartOriginAllowed(req) {
|
// Fetch Metadata is set by the browser and cannot be forged cross-site, so a
|
||||||
|
// same-origin request is trusted before the Origin/Host/scheme comparison,
|
||||||
|
// which depends on trust proxy and X-Forwarded-Proto being configured.
|
||||||
const site = req.headers['sec-fetch-site'];
|
const site = req.headers['sec-fetch-site'];
|
||||||
if (site) return site !== 'cross-site';
|
if (site === 'same-origin') return true;
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (!origin) return true;
|
if (origin) {
|
||||||
if (isAllowedOrigin(origin)) return true;
|
if (isAllowedOrigin(origin)) return true;
|
||||||
try {
|
try {
|
||||||
return new URL(origin).host === req.headers.host;
|
const parsed = new URL(origin);
|
||||||
} catch {
|
return parsed.origin !== 'null' && parsed.host === req.headers.host
|
||||||
return false;
|
&& (!req.protocol || parsed.protocol === `${req.protocol}:`);
|
||||||
|
} catch { return false; }
|
||||||
}
|
}
|
||||||
|
return !site || site === 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compatibility export for existing callers.
|
||||||
module.exports = { isAllowedOrigin, multipartOriginAllowed };
|
const multipartOriginAllowed = mutationOriginAllowed;
|
||||||
|
module.exports = { isAllowedOrigin, mutationOriginAllowed, multipartOriginAllowed };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { requestLogPath } = require('../utils/requestLogPath');
|
||||||
/**
|
/**
|
||||||
* Route helper utilities for standardized request handling.
|
* Route helper utilities for standardized request handling.
|
||||||
* Provides async error wrapping, validation, and response formatting.
|
* Provides async error wrapping, validation, and response formatting.
|
||||||
@@ -93,7 +94,7 @@ const successResponse = (res, data, statusCode = 200, message = null) => {
|
|||||||
*/
|
*/
|
||||||
const errorResponse = (res, error, statusCode = 500, publicMessage) => {
|
const errorResponse = (res, error, statusCode = 500, publicMessage) => {
|
||||||
const message = publicMessage || (error instanceof Error ? error.message : String(error));
|
const message = publicMessage || (error instanceof Error ? error.message : String(error));
|
||||||
const route = res.req ? `${res.req.method} ${res.req.originalUrl}` : null;
|
const route = res.req ? `${res.req.method} ${requestLogPath(res.req.originalUrl)}` : null;
|
||||||
logger.error(route ? `${route} - ${message}` : message, {
|
logger.error(route ? `${route} - ${message}` : message, {
|
||||||
error: error instanceof Error ? error.message : error,
|
error: error instanceof Error ? error.message : error,
|
||||||
stack: error instanceof Error ? error.stack : undefined
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
|||||||
@@ -145,15 +145,12 @@ async function cleanupExpiredRevocations() {
|
|||||||
/**
|
/**
|
||||||
* Initialize cleanup job for expired revocations
|
* Initialize cleanup job for expired revocations
|
||||||
*/
|
*/
|
||||||
function initializeRevocationCleanup() {
|
const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupExpiredRevocations, { interval: 6 * 60 * 60 * 1000, initialDelay: 0 });
|
||||||
// Run cleanup every 6 hours
|
function initializeRevocationCleanup() { cleanupTask.start(); }
|
||||||
setInterval(cleanupExpiredRevocations, 6 * 60 * 60 * 1000);
|
const stopRevocationCleanup = () => cleanupTask.stop();
|
||||||
|
|
||||||
// Run initial cleanup
|
module.exports = { buildTokenId,
|
||||||
cleanupExpiredRevocations();
|
stopRevocationCleanup,
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
revokeToken,
|
revokeToken,
|
||||||
isTokenRevoked,
|
isTokenRevoked,
|
||||||
revokeAllUserTokens,
|
revokeAllUserTokens,
|
||||||
|
|||||||
+81
-26
@@ -1413,32 +1413,7 @@
|
|||||||
"decision": "excluded",
|
"decision": "excluded",
|
||||||
"signals": [],
|
"signals": [],
|
||||||
"reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.",
|
"reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.",
|
||||||
"route_signatures": [
|
"route_signatures": []
|
||||||
"GET /resolve/:identifier",
|
|
||||||
"GET /:slug/verify-token/:token",
|
|
||||||
"GET /:slug/info",
|
|
||||||
"GET /:slug/show/:token/session",
|
|
||||||
"GET /:slug/show/:token/state",
|
|
||||||
"GET /:slug/photos",
|
|
||||||
"GET /:slug/people",
|
|
||||||
"PATCH /:slug/photos/:photoId/visibility",
|
|
||||||
"PATCH /:slug/photos/visibility/bulk",
|
|
||||||
"GET /:slug/download/:photoId",
|
|
||||||
"GET /:slug/download-all",
|
|
||||||
"POST /:slug/download-selected",
|
|
||||||
"POST /:slug/download-jobs",
|
|
||||||
"GET /:slug/download-jobs/:token",
|
|
||||||
"GET /:slug/download-jobs/:token/file",
|
|
||||||
"POST /:slug/photo/:photoId/view",
|
|
||||||
"GET /:slug/photo/:photoId",
|
|
||||||
"GET /:slug/thumbnail/:photoId",
|
|
||||||
"GET /:slug/hero/:photoId",
|
|
||||||
"GET /:slug/preview/:photoId",
|
|
||||||
"GET /:slug/stats",
|
|
||||||
"POST /:eventId/upload",
|
|
||||||
"GET /:slug/uploads/status",
|
|
||||||
"GET /:slug/css-template"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"galleryFeedback.js": {
|
"galleryFeedback.js": {
|
||||||
"decision": "excluded",
|
"decision": "excluded",
|
||||||
@@ -1603,6 +1578,86 @@
|
|||||||
"GET /events/:id/share-link",
|
"GET /events/:id/share-link",
|
||||||
"GET /events/:id/photos"
|
"GET /events/:id/photos"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"gallery/downloads.js": {
|
||||||
|
"decision": "excluded",
|
||||||
|
"signals": [],
|
||||||
|
"reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.",
|
||||||
|
"route_signatures": [
|
||||||
|
"GET /:slug/download/:photoId",
|
||||||
|
"GET /:slug/download-all",
|
||||||
|
"POST /:slug/download-selected",
|
||||||
|
"POST /:slug/download-jobs",
|
||||||
|
"GET /:slug/download-jobs/:token",
|
||||||
|
"GET /:slug/download-jobs/:token/file"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gallery/media.js": {
|
||||||
|
"decision": "excluded",
|
||||||
|
"signals": [],
|
||||||
|
"reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.",
|
||||||
|
"route_signatures": [
|
||||||
|
"POST /:slug/photo/:photoId/view",
|
||||||
|
"GET /:slug/photo/:photoId",
|
||||||
|
"GET /:slug/thumbnail/:photoId",
|
||||||
|
"GET /:slug/hero/:photoId",
|
||||||
|
"GET /:slug/preview/:photoId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gallery/metadata.js": {
|
||||||
|
"decision": "excluded",
|
||||||
|
"signals": [],
|
||||||
|
"reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.",
|
||||||
|
"route_signatures": [
|
||||||
|
"GET /resolve/:identifier",
|
||||||
|
"GET /:slug/verify-token/:token",
|
||||||
|
"GET /:slug/info"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gallery/photos.js": {
|
||||||
|
"decision": "excluded",
|
||||||
|
"signals": [],
|
||||||
|
"reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.",
|
||||||
|
"route_signatures": [
|
||||||
|
"GET /:slug/photos",
|
||||||
|
"GET /:slug/people",
|
||||||
|
"PATCH /:slug/photos/:photoId/visibility",
|
||||||
|
"PATCH /:slug/photos/visibility/bulk"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gallery/slideshow.js": {
|
||||||
|
"decision": "excluded",
|
||||||
|
"signals": [],
|
||||||
|
"reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.",
|
||||||
|
"route_signatures": [
|
||||||
|
"GET /:slug/show/:token/session",
|
||||||
|
"GET /:slug/show/:token/state"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gallery/stats.js": {
|
||||||
|
"decision": "excluded",
|
||||||
|
"signals": [],
|
||||||
|
"reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.",
|
||||||
|
"route_signatures": [
|
||||||
|
"GET /:slug/stats"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gallery/styles.js": {
|
||||||
|
"decision": "excluded",
|
||||||
|
"signals": [],
|
||||||
|
"reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.",
|
||||||
|
"route_signatures": [
|
||||||
|
"GET /:slug/css-template"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gallery/uploads.js": {
|
||||||
|
"decision": "excluded",
|
||||||
|
"signals": [],
|
||||||
|
"reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.",
|
||||||
|
"route_signatures": [
|
||||||
|
"POST /:eventId/upload",
|
||||||
|
"GET /:slug/uploads/status"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"feature_flags": {
|
"feature_flags": {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default tseslint.config([
|
|||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
'react-hooks/rules-of-hooks': 'off',
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
'no-useless-escape': 'off',
|
'no-useless-escape': 'off',
|
||||||
'no-case-declarations': 'off',
|
'no-case-declarations': 'off',
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
# URLs and Referer can contain gallery, image and customer bearer tokens.
|
||||||
|
map $uri $request_surface {
|
||||||
|
~*^/api/(?<picpeak_surface>[a-z-]+)(?:/|$) /api/$picpeak_surface;
|
||||||
|
default /;
|
||||||
|
}
|
||||||
|
log_format picpeak_safe '$remote_addr "$http_x_forwarded_for" [$time_local] "$request_method $request_surface" '
|
||||||
|
'$status $body_bytes_sent $request_time';
|
||||||
|
|
||||||
# Honour the outer reverse proxy's X-Forwarded-Proto when present (e.g. NPM,
|
# Honour the outer reverse proxy's X-Forwarded-Proto when present (e.g. NPM,
|
||||||
# Traefik, Caddy in front of PicPeak). Falls back to nginx's own $scheme when
|
# Traefik, Caddy in front of PicPeak). Falls back to nginx's own $scheme when
|
||||||
# the header is absent (direct access / no outer proxy). Without this the
|
# the header is absent (direct access / no outer proxy). Without this the
|
||||||
@@ -10,6 +18,10 @@ map $http_x_forwarded_proto $real_proto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
|
access_log /var/log/nginx/access.log picpeak_safe;
|
||||||
|
# Native request errors include bearer URLs even at crit/alert severity.
|
||||||
|
# Safe access logs retain status/timing; process diagnostics stay at main level.
|
||||||
|
error_log /dev/null;
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
# URLs and Referer can contain gallery, image and customer bearer tokens.
|
||||||
|
map $uri $request_surface {
|
||||||
|
~*^/api/(?<picpeak_surface>[a-z-]+)(?:/|$) /api/$picpeak_surface;
|
||||||
|
default /;
|
||||||
|
}
|
||||||
|
log_format picpeak_safe '$remote_addr "$http_x_forwarded_for" [$time_local] "$request_method $request_surface" '
|
||||||
|
'$status $body_bytes_sent $request_time';
|
||||||
|
|
||||||
# Honour outer reverse-proxy's X-Forwarded-Proto when present (see #547 /
|
# Honour outer reverse-proxy's X-Forwarded-Proto when present (see #547 /
|
||||||
# frontend/nginx.conf for full rationale).
|
# frontend/nginx.conf for full rationale).
|
||||||
map $http_x_forwarded_proto $real_proto {
|
map $http_x_forwarded_proto $real_proto {
|
||||||
@@ -6,6 +14,10 @@ map $http_x_forwarded_proto $real_proto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
|
access_log /var/log/nginx/access.log picpeak_safe;
|
||||||
|
# Native request errors include bearer URLs even at crit/alert severity.
|
||||||
|
# Safe access logs retain status/timing; process diagnostics stay at main level.
|
||||||
|
error_log /dev/null;
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|||||||
Generated
+344
-472
File diff suppressed because it is too large
Load Diff
@@ -22,14 +22,14 @@
|
|||||||
"@fullcalendar/react": "^6.1.20",
|
"@fullcalendar/react": "^6.1.20",
|
||||||
"@fullcalendar/timegrid": "^6.1.20",
|
"@fullcalendar/timegrid": "^6.1.20",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-character-count": "^2.26.1",
|
"@tiptap/extension-character-count": "^3.31.3",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.26.1",
|
"@tiptap/extension-code-block-lowlight": "^3.31.3",
|
||||||
"@tiptap/extension-hard-break": "^2.26.1",
|
"@tiptap/extension-hard-break": "^3.31.3",
|
||||||
"@tiptap/extension-link": "^2.25.0",
|
"@tiptap/extension-link": "^3.31.3",
|
||||||
"@tiptap/extension-placeholder": "^2.26.1",
|
"@tiptap/extension-placeholder": "^3.31.3",
|
||||||
"@tiptap/extension-text-align": "^2.26.1",
|
"@tiptap/extension-text-align": "^3.31.3",
|
||||||
"@tiptap/react": "^2.25.0",
|
"@tiptap/react": "^3.31.3",
|
||||||
"@tiptap/starter-kit": "^2.25.0",
|
"@tiptap/starter-kit": "^3.31.3",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "^4.17.20",
|
||||||
"@types/react-google-recaptcha": "^2.1.9",
|
"@types/react-google-recaptcha": "^2.1.9",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"react-image-gallery": "^1.2.11",
|
"react-image-gallery": "^1.2.11",
|
||||||
"react-intersection-observer": "^9.4.3",
|
"react-intersection-observer": "^9.4.3",
|
||||||
"react-photo-album": "^3.4.0",
|
"react-photo-album": "^3.4.0",
|
||||||
"react-router-dom": "^6.8.0",
|
"react-router-dom": "^7.18.3",
|
||||||
"react-toastify": "11.0.5",
|
"react-toastify": "11.0.5",
|
||||||
"signature_pad": "^5.1.3",
|
"signature_pad": "^5.1.3",
|
||||||
"swiper": "^12.1.0",
|
"swiper": "^12.1.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { usePhotoSelection } from '../../hooks/usePhotoSelection';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -29,15 +30,21 @@ interface AdminPhotoViewerProps {
|
|||||||
categories: Array<{ id: number; name: string; slug: string }>;
|
categories: Array<{ id: number; name: string; slug: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = (props) => {
|
||||||
photos,
|
const selection = usePhotoSelection(props.photos, props.initialIndex);
|
||||||
initialIndex,
|
if (!selection.currentPhoto) return null;
|
||||||
eventId,
|
return <AdminPhotoViewerContent {...props} {...selection} currentPhoto={selection.currentPhoto} />;
|
||||||
onClose,
|
};
|
||||||
onPhotoDeleted,
|
|
||||||
categories
|
type ViewerContentProps = AdminPhotoViewerProps & {
|
||||||
|
currentPhoto: AdminPhoto;
|
||||||
|
currentIndex: number;
|
||||||
|
setCurrentIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdminPhotoViewerContent: React.FC<ViewerContentProps> = ({
|
||||||
|
photos, eventId, onClose, onPhotoDeleted, categories, currentPhoto, currentIndex, setCurrentIndex
|
||||||
}) => {
|
}) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// The photographer's own triage mark (#1044 follow-up). Held locally and
|
// The photographer's own triage mark (#1044 follow-up). Held locally and
|
||||||
@@ -49,7 +56,6 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||||
|
|
||||||
const currentPhoto = photos[currentIndex];
|
|
||||||
const isVideo = currentPhoto
|
const isVideo = currentPhoto
|
||||||
? (currentPhoto.media_type === 'video' ||
|
? (currentPhoto.media_type === 'video' ||
|
||||||
(currentPhoto.mime_type && String(currentPhoto.mime_type).startsWith('video/')) ||
|
(currentPhoto.mime_type && String(currentPhoto.mime_type).startsWith('video/')) ||
|
||||||
@@ -59,10 +65,6 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
const likeCount = currentPhoto?.like_count ?? 0;
|
const likeCount = currentPhoto?.like_count ?? 0;
|
||||||
const favoriteCount = currentPhoto?.favorite_count ?? 0;
|
const favoriteCount = currentPhoto?.favorite_count ?? 0;
|
||||||
|
|
||||||
if (!currentPhoto) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch feedback for current photo
|
// Fetch feedback for current photo
|
||||||
const { data: feedbackData } = useQuery<AdminFeedbackResponse>({
|
const { data: feedbackData } = useQuery<AdminFeedbackResponse>({
|
||||||
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
|
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
|
||||||
@@ -231,7 +233,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [currentIndex]);
|
}, [currentIndex, setCurrentIndex, photos.length, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center">
|
||||||
|
|||||||
@@ -52,6 +52,29 @@ interface CMSEditorProps {
|
|||||||
|
|
||||||
type ViewMode = 'edit' | 'preview' | 'split';
|
type ViewMode = 'edit' | 'preview' | 'split';
|
||||||
|
|
||||||
|
const MenuButton: React.FC<{
|
||||||
|
onClick: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ onClick, active, children, title, disabled }) => (
|
||||||
|
<button
|
||||||
|
onMouseDown={event => event.preventDefault()}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`p-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-accent-dark/15 text-accent-dark'
|
||||||
|
: 'text-neutral-700 dark:text-neutral-200'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
title={title}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave, isSaving }) => {
|
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave, isSaving }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [linkUrl, setLinkUrl] = useState('');
|
const [linkUrl, setLinkUrl] = useState('');
|
||||||
@@ -64,8 +87,13 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
|||||||
const toolbarRef = React.useRef<HTMLDivElement | null>(null);
|
const toolbarRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
|
shouldRerenderOnTransaction: true,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
|
link: false,
|
||||||
|
// v2 StarterKit had no TrailingNode; v3 would append an empty <p> to
|
||||||
|
// documents ending in a heading/list/code block and persist it.
|
||||||
|
trailingNode: false,
|
||||||
hardBreak: false, // We'll use the separate HardBreak extension
|
hardBreak: false, // We'll use the separate HardBreak extension
|
||||||
codeBlock: false, // We'll use CodeBlockLowlight instead
|
codeBlock: false, // We'll use CodeBlockLowlight instead
|
||||||
}),
|
}),
|
||||||
@@ -148,7 +176,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (editor && content !== editor.getHTML()) {
|
if (editor && content !== editor.getHTML()) {
|
||||||
editor.commands.setContent(content);
|
editor.commands.setContent(content, { emitUpdate: false });
|
||||||
}
|
}
|
||||||
}, [content, editor]);
|
}, [content, editor]);
|
||||||
|
|
||||||
@@ -164,27 +192,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const MenuButton: React.FC<{
|
|
||||||
onClick: () => void;
|
|
||||||
active?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}> = ({ onClick, active, children, title, disabled }) => (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
className={`p-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors ${
|
|
||||||
active
|
|
||||||
? 'bg-accent-dark/15 text-accent-dark'
|
|
||||||
: 'text-neutral-700 dark:text-neutral-200'
|
|
||||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
||||||
title={title}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
setIsFullscreen(!isFullscreen);
|
setIsFullscreen(!isFullscreen);
|
||||||
|
|||||||
@@ -31,6 +31,29 @@ interface EmailTemplateEditorProps {
|
|||||||
variables?: string[];
|
variables?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MenuButton: React.FC<{
|
||||||
|
onClick: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ onClick, active, children, title, disabled }) => (
|
||||||
|
<button
|
||||||
|
onMouseDown={event => event.preventDefault()}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-accent-dark/15 text-accent-dark'
|
||||||
|
: 'text-neutral-700 dark:text-neutral-300'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
title={title}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
||||||
content,
|
content,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -44,8 +67,13 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
|||||||
const [showVariables, setShowVariables] = useState(false);
|
const [showVariables, setShowVariables] = useState(false);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
|
shouldRerenderOnTransaction: true,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
|
link: false,
|
||||||
|
// v2 StarterKit had no TrailingNode; v3 would append an empty <p> to
|
||||||
|
// templates ending in a heading/list and persist it in the email HTML.
|
||||||
|
trailingNode: false,
|
||||||
hardBreak: false,
|
hardBreak: false,
|
||||||
}),
|
}),
|
||||||
HardBreak.configure({
|
HardBreak.configure({
|
||||||
@@ -75,7 +103,7 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
|||||||
// Sync editor when content prop changes externally
|
// Sync editor when content prop changes externally
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (editor && !isSourceMode && content !== editor.getHTML()) {
|
if (editor && !isSourceMode && content !== editor.getHTML()) {
|
||||||
editor.commands.setContent(content);
|
editor.commands.setContent(content, { emitUpdate: false });
|
||||||
setSourceContent(content);
|
setSourceContent(content);
|
||||||
}
|
}
|
||||||
}, [content, editor, isSourceMode]);
|
}, [content, editor, isSourceMode]);
|
||||||
@@ -134,27 +162,7 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MenuButton: React.FC<{
|
|
||||||
onClick: () => void;
|
|
||||||
active?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}> = ({ onClick, active, children, title, disabled }) => (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${
|
|
||||||
active
|
|
||||||
? 'bg-accent-dark/15 text-accent-dark'
|
|
||||||
: 'text-neutral-700 dark:text-neutral-300'
|
|
||||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
||||||
title={title}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-neutral-300 dark:border-neutral-600 rounded-lg overflow-hidden">
|
<div className="border border-neutral-300 dark:border-neutral-600 rounded-lg overflow-hidden">
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
import { useGalleryFiltering, resolveMediaType } from './hooks/useGalleryFiltering';
|
||||||
|
import { useGalleryUpload } from './hooks/useGalleryUpload';
|
||||||
|
import { useGallerySelection } from './hooks/useGallerySelection';
|
||||||
|
import { UploadProcessingNotice } from './UploadProcessingNotice';
|
||||||
|
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import { differenceInDays, parseISO } from 'date-fns';
|
import { differenceInDays, parseISO } from 'date-fns';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -34,9 +38,7 @@ import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext';
|
|||||||
import type { FilterType, FeedbackFilterType } from './GalleryFilter';
|
import type { FilterType, FeedbackFilterType } from './GalleryFilter';
|
||||||
import { analyticsService } from '../../services/analytics.service';
|
import { analyticsService } from '../../services/analytics.service';
|
||||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||||
import { api } from '../../config/api';
|
import { Upload, Menu, Eye, EyeOff, Shield, X, Download, ChevronLeft } from 'lucide-react';
|
||||||
import { Upload, Menu, Eye, EyeOff, Shield, X, Download, ChevronLeft, Loader2 } from 'lucide-react';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import { galleryService } from '../../services/gallery.service';
|
import { galleryService } from '../../services/gallery.service';
|
||||||
import { feedbackService, type ColorLabel } from '../../services/feedback.service';
|
import { feedbackService, type ColorLabel } from '../../services/feedback.service';
|
||||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||||
@@ -122,8 +124,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
|
|||||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
|
||||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
|
||||||
const [feedbackEnabled, setFeedbackEnabled] = useState(false);
|
const [feedbackEnabled, setFeedbackEnabled] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
const { watermarkEnabled } = useWatermarkSettings();
|
const { watermarkEnabled } = useWatermarkSettings();
|
||||||
@@ -188,19 +188,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
|
|||||||
const [guestId, setGuestId] = useState<string>('');
|
const [guestId, setGuestId] = useState<string>('');
|
||||||
const [staticHeroPhoto, setStaticHeroPhoto] = useState<Photo | null>(null);
|
const [staticHeroPhoto, setStaticHeroPhoto] = useState<Photo | null>(null);
|
||||||
|
|
||||||
const resolveMediaType = (photo: Photo) => {
|
|
||||||
if (photo.media_type === 'video' || photo.media_type === 'photo') {
|
|
||||||
return photo.media_type;
|
|
||||||
}
|
|
||||||
if (photo.mime_type && photo.mime_type.startsWith('video/')) {
|
|
||||||
return 'video';
|
|
||||||
}
|
|
||||||
if ((photo as any).type === 'video') {
|
|
||||||
return 'video';
|
|
||||||
}
|
|
||||||
return 'photo';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate a unique guest ID for this session
|
// Generate a unique guest ID for this session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Use existing guest ID from localStorage or generate new one
|
// Use existing guest ID from localStorage or generate new one
|
||||||
@@ -215,6 +202,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
|
|||||||
// Fetch photos WITHOUT filter (always get all photos, filter on frontend)
|
// Fetch photos WITHOUT filter (always get all photos, filter on frontend)
|
||||||
// This ensures counts are always calculated from the full dataset
|
// This ensures counts are always calculated from the full dataset
|
||||||
const { data, isLoading, error, refetch } = useGalleryPhotos(slug, 'all', guestId);
|
const { data, isLoading, error, refetch } = useGalleryPhotos(slug, 'all', guestId);
|
||||||
|
const { isSelectionMode, setIsSelectionMode, selectedPhotos, setSelectedPhotos } = useGallerySelection(data?.photos);
|
||||||
|
|
||||||
// Set protection level when data is available
|
// Set protection level when data is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -251,109 +239,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
|
|||||||
return () => { timers.forEach(clearTimeout); clearInterval(interval); };
|
return () => { timers.forEach(clearTimeout); clearInterval(interval); };
|
||||||
}, [hiddenUntilReveal, revealArmed, revealAtMs, refetch]);
|
}, [hiddenUntilReveal, revealArmed, revealAtMs, refetch]);
|
||||||
|
|
||||||
// Post-upload refresh (P4-E.01). A guest upload is *queued*: the route
|
const { uploadProcessing, handleUploadComplete } = useGalleryUpload(slug, refetch, () => setShowUploadModal(false));
|
||||||
// answers 202 and the row lands as `processing_status: 'pending'`, while
|
|
||||||
// the photo list only returns completed rows. A single immediate refetch
|
|
||||||
// therefore comes back with a byte-identical payload (which the browser is
|
|
||||||
// answered with a 304), so the guest saw their upload silently vanish until
|
|
||||||
// they hard-reloaded.
|
|
||||||
//
|
|
||||||
// The first fix polled the photo list blind against a count baseline, which
|
|
||||||
// cannot tell a slow worker from a photo that failed processing — it just
|
|
||||||
// stopped after 60s with nothing on screen either way. Poll the upload
|
|
||||||
// group's real processing status instead (B7): it drives the "processing…"
|
|
||||||
// notice, refetches the grid as photos land rather than only at the end, and
|
|
||||||
// reports a failure instead of a silence.
|
|
||||||
const uploadRefreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const [uploadProcessing, setUploadProcessing] = useState<{ complete: number; total: number } | null>(null);
|
|
||||||
const stopUploadRefresh = () => {
|
|
||||||
if (uploadRefreshTimerRef.current) {
|
|
||||||
clearInterval(uploadRefreshTimerRef.current);
|
|
||||||
uploadRefreshTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
useEffect(() => stopUploadRefresh, []);
|
|
||||||
|
|
||||||
const handleUploadComplete = (uploadIds: string[] = []) => {
|
const uploadProcessingNotice = <UploadProcessingNotice processing={uploadProcessing} />;
|
||||||
setShowUploadModal(false);
|
|
||||||
stopUploadRefresh();
|
|
||||||
|
|
||||||
// Nothing to follow (no id came back, e.g. every file failed on the wire).
|
|
||||||
// Refetch once rather than polling something unknowable.
|
|
||||||
if (uploadIds.length === 0) {
|
|
||||||
void refetch();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadProcessing({ complete: 0, total: uploadIds.length });
|
|
||||||
const deadline = Date.now() + 120_000;
|
|
||||||
let lastComplete = 0;
|
|
||||||
let inFlight = false;
|
|
||||||
|
|
||||||
const finish = async (announce?: () => void) => {
|
|
||||||
stopUploadRefresh();
|
|
||||||
setUploadProcessing(null);
|
|
||||||
await refetch();
|
|
||||||
announce?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
// The interval keeps firing while a slow request is open; without this
|
|
||||||
// the requests stack up for the whole deadline.
|
|
||||||
if (inFlight) return;
|
|
||||||
inFlight = true;
|
|
||||||
try {
|
|
||||||
const status = await galleryService.getUploadStatus(slug, uploadIds);
|
|
||||||
setUploadProcessing({
|
|
||||||
complete: status.complete + status.failed,
|
|
||||||
total: status.total || uploadIds.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refetch as each photo lands, not only once the batch settles, so a
|
|
||||||
// large upload fills the grid progressively.
|
|
||||||
if (status.complete > lastComplete) {
|
|
||||||
lastComplete = status.complete;
|
|
||||||
void refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.pending === 0 && status.processing === 0) {
|
|
||||||
await finish(() => {
|
|
||||||
if (status.failed > 0) {
|
|
||||||
toast.error(t('upload.processingFailed', { count: status.failed }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (Date.now() > deadline) {
|
|
||||||
// Bounded. The worker is genuinely still running, so say that rather
|
|
||||||
// than leaving the guest with a grid that quietly never updated.
|
|
||||||
await finish(() => toast.info(t('upload.processingStillRunning')));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// The status signal is a convenience — the photos are stored either
|
|
||||||
// way — so a failing status call degrades to the plain refetch.
|
|
||||||
await finish();
|
|
||||||
} finally {
|
|
||||||
inFlight = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
uploadRefreshTimerRef.current = setInterval(poll, 2000);
|
|
||||||
void poll();
|
|
||||||
};
|
|
||||||
|
|
||||||
// The two layout branches below that render the photo grid have no shared
|
|
||||||
// wrapper, so the notice is shared as a value rather than as markup.
|
|
||||||
const uploadProcessingNotice = uploadProcessing ? (
|
|
||||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 rounded-full bg-neutral-900/90 px-4 py-2 text-sm text-white shadow-lg">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
|
|
||||||
<span>
|
|
||||||
{t('upload.processing')}{' '}
|
|
||||||
{t('upload.processingProgress', {
|
|
||||||
complete: uploadProcessing.complete,
|
|
||||||
total: uploadProcessing.total,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
// Get individual protection settings from event
|
// Get individual protection settings from event
|
||||||
const disableRightClick = data?.event?.disable_right_click === true;
|
const disableRightClick = data?.event?.disable_right_click === true;
|
||||||
@@ -427,8 +315,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
// Use public endpoint to get feedback settings
|
// Use public endpoint to get feedback settings
|
||||||
const response = await api.get(`/gallery/${slug}/feedback-settings`);
|
return await feedbackService.getGalleryFeedbackSettings(slug);
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching feedback settings:', error);
|
console.error('Error fetching feedback settings:', error);
|
||||||
// If endpoint doesn't exist or returns error, default to disabled
|
// If endpoint doesn't exist or returns error, default to disabled
|
||||||
@@ -755,7 +642,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
|
|||||||
setSelectedPersonIds([]);
|
setSelectedPersonIds([]);
|
||||||
setPeopleMatchAny(false);
|
setPeopleMatchAny(false);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}, []);
|
}, [setSelectedPhotos]);
|
||||||
|
|
||||||
// The address bar is the source of truth, so Back/Forward walk in and out of
|
// The address bar is the source of truth, so Back/Forward walk in and out of
|
||||||
// folders instead of leaving the gallery.
|
// folders instead of leaving the gallery.
|
||||||
@@ -769,130 +656,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
|
|||||||
};
|
};
|
||||||
window.addEventListener('popstate', onPop);
|
window.addEventListener('popstate', onPop);
|
||||||
return () => window.removeEventListener('popstate', onPop);
|
return () => window.removeEventListener('popstate', onPop);
|
||||||
}, []);
|
}, [setSelectedPhotos]);
|
||||||
|
|
||||||
// Filter and sort photos
|
const filteredPhotos = useGalleryFiltering({
|
||||||
const filteredPhotos = useMemo(() => {
|
sourcePhotos: data?.photos, categories: data?.categories, folderId: openFolder?.id ?? null,
|
||||||
if (!data?.photos) return [];
|
selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug,
|
||||||
|
activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds,
|
||||||
// Folder containment (#1160) comes FIRST: at root this drops every photo that
|
selectedPersonIds, peopleMatchAny,
|
||||||
// lives in a folder, inside a folder it keeps only that folder's photos.
|
|
||||||
// Everything below narrows within that scope, so a search or a feedback chip
|
|
||||||
// never reaches across a folder boundary.
|
|
||||||
let photos = photosInScope(data.photos, data.categories, openFolder?.id ?? null);
|
|
||||||
|
|
||||||
if (mediaFilter === 'photo') {
|
|
||||||
photos = photos.filter(photo => resolveMediaType(photo) !== 'video');
|
|
||||||
} else if (mediaFilter === 'video') {
|
|
||||||
photos = photos.filter(photo => resolveMediaType(photo) === 'video');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply category filter. Only meaningful at root — inside a folder every
|
|
||||||
// photo already shares the folder's category.
|
|
||||||
if (selectedCategoryId && !openFolder) {
|
|
||||||
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply search filter
|
|
||||||
if (searchTerm) {
|
|
||||||
const term = searchTerm.toLowerCase();
|
|
||||||
photos = photos.filter(photo =>
|
|
||||||
photo.filename.toLowerCase().includes(term)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply feedback filters. Multi-select (#889): a photo matching ANY
|
|
||||||
// active filter passes (OR-combined); an empty set means no feedback
|
|
||||||
// filtering. In guest identity mode each filter has to scope to the
|
|
||||||
// *current guest's* interactions (#538 bug 1) — the aggregate counts
|
|
||||||
// on each photo row are global across all guests, which gave an empty
|
|
||||||
// grid when the guest had liked photos that nobody else had touched.
|
|
||||||
// Falls back to the aggregate-count check in simple/non-guest mode
|
|
||||||
// where there's no per-person identity to scope by.
|
|
||||||
if (activeFilters.length > 0) {
|
|
||||||
const matchers: Record<FeedbackFilterType, (photo: Photo) => boolean> = {
|
|
||||||
liked: (photo) => isGuestIdentityMode
|
|
||||||
? myFeedbackPhotoIds.liked.has(photo.id)
|
|
||||||
: (photo.like_count || 0) > 0,
|
|
||||||
favorited: (photo) => isGuestIdentityMode
|
|
||||||
? myFeedbackPhotoIds.favorited.has(photo.id)
|
|
||||||
: (photo.favorite_count || 0) > 0,
|
|
||||||
rated: (photo) => isGuestIdentityMode
|
|
||||||
? myFeedbackPhotoIds.rated.has(photo.id)
|
|
||||||
: (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0,
|
|
||||||
commented: (photo) => isGuestIdentityMode
|
|
||||||
? myFeedbackPhotoIds.commented.has(photo.id)
|
|
||||||
: (photo.comment_count || 0) > 0,
|
|
||||||
};
|
|
||||||
photos = photos.filter(photo => activeFilters.some(filter => matchers[filter](photo)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply people filter (#1074). Composes with every filter above rather
|
|
||||||
// than replacing them, so "photos of Anna that I liked" works.
|
|
||||||
//
|
|
||||||
// Two people selected means AND by default ("photos with both Anna and
|
|
||||||
// Ben") — that is what someone picking a second face is almost always
|
|
||||||
// asking for. `peopleMatchAny` flips it to OR for the couple-shots case.
|
|
||||||
if (selectedPersonIds.length > 0) {
|
|
||||||
photos = photos.filter(photo => {
|
|
||||||
const ids = photo.person_ids || [];
|
|
||||||
return peopleMatchAny
|
|
||||||
? selectedPersonIds.some(id => ids.includes(id))
|
|
||||||
: selectedPersonIds.every(id => ids.includes(id));
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Apply colour-label filters (#1044). Guest-scoped by construction:
|
|
||||||
// `my_color_label` is the requesting viewer's own label, which is what a
|
|
||||||
// proofing client means by "show me my greens". Composes with (ANDs
|
|
||||||
// against) every filter above, like the people filter.
|
|
||||||
if (activeColorFilters.length > 0) {
|
|
||||||
photos = photos.filter(photo =>
|
|
||||||
!!photo.my_color_label && activeColorFilters.includes(photo.my_color_label as ColorLabel)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
// Each comparator defaults to its natural order (desc for dates/size/rating, asc for name).
|
|
||||||
// The flip multiplier reverses that when sortDesc differs from the natural order.
|
|
||||||
const flip = sortDesc ? 1 : -1;
|
|
||||||
photos.sort((a, b) => {
|
|
||||||
switch (sortBy) {
|
|
||||||
case 'name':
|
|
||||||
// Natural order is ascending (A-Z); flip when sortDesc=true
|
|
||||||
return (sortDesc ? -1 : 1) * a.filename.localeCompare(b.filename);
|
|
||||||
case 'size':
|
|
||||||
return flip * (b.size - a.size);
|
|
||||||
case 'rating': {
|
|
||||||
const ratingA = a.average_rating || 0;
|
|
||||||
const ratingB = b.average_rating || 0;
|
|
||||||
if (ratingA !== ratingB) {
|
|
||||||
return flip * (ratingB - ratingA);
|
|
||||||
}
|
|
||||||
return flip * ((b.comment_count || 0) - (a.comment_count || 0));
|
|
||||||
}
|
|
||||||
case 'capture_date': {
|
|
||||||
const captureDateA = a.captured_at || a.uploaded_at;
|
|
||||||
const captureDateB = b.captured_at || b.uploaded_at;
|
|
||||||
return flip * (new Date(captureDateB).getTime() - new Date(captureDateA).getTime());
|
|
||||||
}
|
|
||||||
case 'date':
|
|
||||||
default:
|
|
||||||
return flip * (new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transform full-size URLs for watermarks if enabled
|
|
||||||
// Note: Thumbnails are watermarked server-side at the thumbnail endpoint
|
|
||||||
if (watermarkEnabled) {
|
|
||||||
photos = photos.map(photo => ({
|
|
||||||
...photo,
|
|
||||||
url: `/api/gallery/${slug}/photo/${photo.id}`
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return photos;
|
|
||||||
}, [data?.photos, data?.categories, openFolder, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, selectedPersonIds, peopleMatchAny]);
|
|
||||||
|
|
||||||
// Counts shown in the filter chips ("Liked (N)", etc.). In guest
|
// Counts shown in the filter chips ("Liked (N)", etc.). In guest
|
||||||
// mode these need to mirror the per-guest filter behaviour above —
|
// mode these need to mirror the per-guest filter behaviour above —
|
||||||
|
|||||||
@@ -336,14 +336,6 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
// Gallery Premium and Gallery Story layouts have their own integrated hero/header
|
// Gallery Premium and Gallery Story layouts have their own integrated hero/header
|
||||||
const isFullPageLayout = galleryLayout === 'gallery-premium' || galleryLayout === 'gallery-story';
|
const isFullPageLayout = galleryLayout === 'gallery-premium' || galleryLayout === 'gallery-story';
|
||||||
|
|
||||||
// Folder-only root (#1160). The full-bleed layouts own the hero/logout chrome,
|
|
||||||
// so they are mounted even with an empty set. Every other layout is skipped
|
|
||||||
// instead: CarouselGalleryLayout returns before four of its useState calls, so
|
|
||||||
// driving one instance between empty and non-empty changes its hook count and
|
|
||||||
// React throws. Skipping only the child keeps this component's own HeroHeader
|
|
||||||
// and welcome message on screen.
|
|
||||||
const skipEmptyLayoutChild = photos.length === 0 && suppressEmptyState && !isFullPageLayout;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hero Header - shown when headerStyle is 'hero' (skip for full-page layouts with integrated hero) */}
|
{/* Hero Header - shown when headerStyle is 'hero' (skip for full-page layouts with integrated hero) */}
|
||||||
@@ -432,7 +424,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Render the selected layout */}
|
{/* Render the selected layout */}
|
||||||
{skipEmptyLayoutChild ? null : <LayoutComponent {...layoutProps} />}
|
<LayoutComponent {...layoutProps} />
|
||||||
|
|
||||||
{/* Lightbox - skip for full-page layouts which have their own lightbox */}
|
{/* Lightbox - skip for full-page layouts which have their own lightbox */}
|
||||||
{selectedPhotoIndex !== null && !isFullPageLayout && (
|
{selectedPhotoIndex !== null && !isFullPageLayout && (
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
export function UploadProcessingNotice({ processing }: { processing: { complete: number; total: number } | null }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return processing ? (
|
||||||
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 rounded-full bg-neutral-900/90 px-4 py-2 text-sm text-white shadow-lg">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
|
||||||
|
<span>
|
||||||
|
{t('upload.processing')}{' '}
|
||||||
|
{t('upload.processingProgress', {
|
||||||
|
complete: processing.complete,
|
||||||
|
total: processing.total,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user