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:
Paul Nothaft
2026-09-08 15:34:09 +02:00
committed by GitHub
parent 895e5ab3cc
commit f0e6d2dfb1
120 changed files with 7147 additions and 8525 deletions
+11
View File
@@ -125,6 +125,11 @@ DB_NAME=picpeak_prod
# address is refused by the SSRF check otherwise. Running n8n beside PicPeak is
# normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that.
#
# Webhook and email-webhook deliveries connect to the DNS answer they just
# validated and ignore HTTP_PROXY / HTTPS_PROXY. Behind a mandatory egress
# proxy set the *_ALLOW_PRIVATE_URLS flag, which sends through the proxy
# without pinning.
#
# A mail account with its own SMTP host (Settings -> Mail accounts) keeps
# sending through it; this replaces the global transport only.
#
@@ -353,3 +358,9 @@ LOGS=./logs
# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET.
# Keep this value stable until participation has been deleted.
# USAGE_ENCRYPTION_KEY=
# Graceful shutdown budget in milliseconds. On SIGTERM the server stops
# accepting requests, drains workers and closes the pool; whatever is still
# running after this long is abandoned so the process exits before Docker's
# 10 s stop grace period (raise stop_grace_period together with this value).
#SHUTDOWN_TIMEOUT_MS=8000
+21 -17
View File
@@ -6,11 +6,6 @@ name: Tests
# calendar) plus the photo / settings / OG / auth surface — wiring them
# into CI makes regressions visible at PR time instead of post-merge.
#
# Six backend suites are excluded via --testPathIgnorePatterns. They
# fail on `upstream/beta` too (pre-existing mock/infra issues, NOT CRM
# regressions). Excluding them here keeps CI green from day 1; revisit
# each individually as its own fix.
#
# Triggers on any change that could affect either suite. The backend
# job intentionally omits frontend paths and vice versa so unrelated
# PRs don't pay both build costs.
@@ -89,18 +84,7 @@ jobs:
# Un-gates the real-Postgres cases in the .picpeak restore suites
# (see the `services:` note above). Absent it they silently skip.
PICPEAK_PG_TEST_URL: 'postgres://picpeak:[email protected]:5432/picpeak_test'
run: |
# Excluded suites — fail on upstream/beta too, tracked
# separately as test-infra debt:
# adminSettings.logo — supertest fixture
# integration/adminPhotos.reference — supertest fixture
# integration/webhookDelivery — supertest fixture
# services/backupService.enhanced — knex mock chain
# routes/__tests__/adminAuth — supertest fixture
# (adminNotifications was excluded; #597 fix re-enables it.)
npx jest \
--testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \
--ci
run: npx jest --ci
frontend:
runs-on: ubuntu-latest
@@ -121,10 +105,30 @@ jobs:
working-directory: ./frontend
run: npm ci
- name: Lint frontend (including Rules of Hooks)
working-directory: ./frontend
run: npm run lint
- name: Run Vitest suite
working-directory: ./frontend
run: npm test -- --run
nginx:
runs-on: ubuntu-latest
timeout-minutes: 5
strategy:
matrix:
# Match the two shipped frontend Dockerfiles.
image: ['nginx:1.28-alpine', 'nginx:1.30-alpine']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify token-safe nginx logging
env:
NGINX_TEST_IMAGE: ${{ matrix.image }}
run: python3 tests/nginx/test_request_logging.py
# Optional face-detection sidecar (#1074). Runs on every PR regardless of
# whether the feature is enabled anywhere — these tests need no model
# weights (they stub the pipeline out) and cover the auth boundary, the
@@ -5,9 +5,10 @@ process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
const http = require('http');
const { db } = require('../../src/database/db');
const webhookService = require('../../src/services/webhookService');
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
let db, cleanup, adminId;
let webhookService;
let __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker;
// Local-only test stub: matches what dev/webhook-receiver/server.js does
// in the docker-compose flow but spun up inside the Jest process so the
@@ -43,7 +44,7 @@ async function insertWebhook(url, events = ['event.published'], extras = {}) {
secret_preview: preview,
events: JSON.stringify(events),
active: extras.active !== false,
created_by: 1,
created_by: adminId,
}).returning('id');
const id = insert[0]?.id || insert[0];
return { id, secret: plaintext };
@@ -56,16 +57,15 @@ async function clearWebhooks() {
describe('webhook delivery worker (#327)', () => {
beforeAll(async () => {
// Schema is expected to already be applied by `npm run migrate`. We
// just verify the webhooks tables exist; if not, the test harness has
// missed running migration 082.
const ok = await db.schema.hasTable('webhooks');
if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first');
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
webhookService = require('../../src/services/webhookService');
({ __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker'));
}, 30000);
afterAll(async () => {
stopWebhookDeliveryWorker();
await db.destroy();
await stopWebhookDeliveryWorker();
await cleanup();
});
beforeEach(async () => {
@@ -20,6 +20,7 @@ const fake = { maintenance: 'true', revoked: false, beforeCutoff: false, admin:
jest.mock('../../src/database/db', () => {
const db = jest.fn((table) => {
const q = {
leftJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
first: jest.fn(async () => {
@@ -27,6 +28,7 @@ jest.mock('../../src/database/db', () => {
return { setting_key: 'general_maintenance_mode', setting_value: fake.maintenance };
}
if (table === 'admin_users') return fake.admin;
if (table === 'events') return { id: 1, slug: 'preview', created_by: 1, is_active: 1 };
return null;
}),
};
@@ -34,6 +36,7 @@ jest.mock('../../src/database/db', () => {
});
return { db, withRetry: (fn) => fn() };
});
jest.mock('../../src/middleware/permissions', () => ({ userHasAllPermissions: jest.fn().mockResolvedValue(true) }));
jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }));
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn(async () => fake.revoked) }));
jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn(async () => fake.beforeCutoff) }));
@@ -75,7 +78,7 @@ describe('general rate limiter skip', () => {
});
describe('admin preview requires a live admin session', () => {
const req = (token) => ({ query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} });
const req = (token) => ({ params: { slug: 'preview' }, query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} });
beforeEach(() => { fake.revoked = false; fake.beforeCutoff = false; fake.admin = { id: 1, password_changed_at: null }; });
it('passes for a live session and sets req.isAdminPreview', async () => {
@@ -108,13 +111,23 @@ describe('multipart origin gate', () => {
const req = (headers) => ({ headers: { host: 'photos.example.com', ...headers } });
it('accepts same-origin, same-site and non-browser requests', () => {
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-origin' }))).toBe(true);
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(true);
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(false);
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'none' }))).toBe(true);
expect(multipartOriginAllowed(req({}))).toBe(true);
expect(multipartOriginAllowed(req({ origin: 'https://photos.example.com' }))).toBe(true);
// Same-origin install without FRONTEND_URL: Origin matches the Host.
expect(multipartOriginAllowed({ headers: { host: 'gallery.local', origin: 'http://gallery.local' } })).toBe(true);
});
it('trusts Fetch Metadata same-origin before the Origin/scheme comparison', () => {
// TLS terminated upstream without X-Forwarded-Proto: req.protocol is http
// while the browser's Origin is https. Login must still work.
// gallery.local is not in the configured allowlist, so only the Host/scheme
// comparison or Fetch Metadata can admit it.
const proxied = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local', 'sec-fetch-site': 'same-origin' } };
expect(multipartOriginAllowed(proxied)).toBe(true);
const legacyBrowser = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local' } };
expect(multipartOriginAllowed(legacyBrowser)).toBe(false);
});
it('rejects cross-site form posts', () => {
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'cross-site' }))).toBe(false);
expect(multipartOriginAllowed(req({ origin: 'https://evil.example' }))).toBe(false);
@@ -0,0 +1,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 @@
/**
* Regression test for the /admin/login → /admin/dashboard → /admin/login
* redirect loop reported on v3.32.4-beta.0.
*
* Cause: GET /auth/session was less strict than the adminAuth middleware.
* The session endpoint accepted tokens that the protected endpoints
* subsequently rejected with 401, which the frontend's interceptor
* translated into a hard redirect to /admin/login. /auth/session then
* said "valid: true" again on the next page load and the cycle closed.
*
* /auth/session must reject the same admin tokens adminAuth would
* reject, specifically: deactivated admin user, deleted admin user,
* password changed since iat. Same for gallery: archived event.
*/
const express = require('express');
/** Session restoration uses the same live policy as protected routes. */
const request = require('supertest');
const jwt = require('jsonwebtoken');
process.env.JWT_SECRET = 'session-symmetry-test-secret';
const fakeDb = {
adminUsers: [],
events: [],
revokedTokens: [],
};
jest.mock('../../src/database/db', () => {
const formatBoolean = (v) => (v ? 1 : 0);
void formatBoolean;
function dbFn(table) {
if (table === 'admin_users') {
let rowFilter = () => true;
return {
// The session route joins roles for the adminUser payload (#798);
// fake rows carry no role fields, so the join is a pass-through.
leftJoin() {
return this;
},
where(criteria) {
rowFilter = (row) => {
return Object.entries(criteria).every(([rawKey, v]) => {
// Joined queries prefix columns ('admin_users.id') — the fake
// rows use bare names.
const k = rawKey.replace(/^admin_users\./, '');
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
return row[k] === v;
});
};
return this;
},
select(...cols) {
this._cols = cols;
return this;
},
async first() {
const row = fakeDb.adminUsers.find(rowFilter);
if (!row) return undefined;
if (!this._cols) return row;
const out = {};
for (const c of this._cols) {
// Support 'table.col' and 'table.col as alias' shapes.
const [source, alias] = c.split(/\s+as\s+/i);
const bare = source.includes('.') ? source.split('.').pop() : source;
out[alias || bare] = row[bare];
}
return out;
},
};
}
if (table === 'events') {
let rowFilter = () => true;
return {
where(criteria) {
rowFilter = (row) =>
Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
return row[k] === v;
});
return this;
},
async first() {
return fakeDb.events.find(rowFilter);
},
};
}
throw new Error(`Unexpected table: ${table}`);
}
return { db: dbFn, formatBoolean: () => 1 };
const crypto = require('crypto');
const { bootCrmDb, seedMinimal, assignAdminRole, buildRouteApp } = require('../integration/helpers/crmDb');
process.env.JWT_SECRET = 'session-symmetry-test-secret-with-at-least-32-characters';
let db, cleanup, app, adminId, customerId, eventId, cutoff;
const slug = 'session-symmetry';
const sign = (claims = {}) => jwt.sign({ type: 'admin', id: adminId, username: 'tester',
iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims },
process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '4h' });
const gallery = (claims = {}) => sign({ type: 'gallery', eventId, eventSlug: slug, ...claims });
const session = bearer => request(app).get(`/api/auth/session?slug=${slug}`).set('Authorization', `Bearer ${bearer}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
await assignAdminRole(db, adminId);
const row = await require('../../src/services/eventCreationService').createEvent({
event_type: 'wedding', event_name: 'Session symmetry', event_date: '2026-10-01',
slug, password: 'Session-Strong-Password-924!', expiration_days: 30,
customer_email: '[email protected]', admin_email: '[email protected]',
}, { actor: { id: adminId }, source: 'v1' });
eventId = row.id;
await db('events').where({ id: eventId }).update({ slug });
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
cutoff = require('../../src/utils/sessionCutoff');
app = buildRouteApp('/api/auth', require('../../src/routes/auth'));
}, 120000);
beforeEach(async () => {
await db('admin_users').where({ id: adminId }).update({ is_active: 1, password_changed_at: null });
await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null });
await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0,
expires_at: new Date(Date.now() + 86400000).toISOString() });
await cutoff.setSessionsValidAfter(0);
});
jest.mock('../../src/utils/dbCompat', () => ({
formatBoolean: (v) => (v ? 1 : 0),
}));
jest.mock('../../src/utils/tokenRevocation', () => ({
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
revokeToken: jest.fn(),
}));
jest.mock('../../src/utils/tokenUtils', () => ({
getAdminTokenFromRequest: (req) => {
const auth = req.headers.authorization;
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
return null;
},
getGalleryTokenFromRequest: () => null,
setAdminAuthCookie: jest.fn(),
setGalleryAuthCookies: jest.fn(),
clearAdminAuthCookie: jest.fn(),
clearGalleryAuthCookies: jest.fn(),
buildCookieOptionsWithExpiry: () => ({}),
}));
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) }));
// Mock sessionTimeout's isSessionExpired so each test controls the return.
// Default: not expired (so existing tests keep passing without setup).
jest.mock('../../src/middleware/sessionTimeout', () => ({
endSession: jest.fn(),
isSessionExpired: jest.fn(() => Promise.resolve(false)),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));
const authRouter = require('../../src/routes/auth');
function makeApp() {
const app = express();
app.use(express.json());
app.use('/auth', authRouter);
return app;
}
function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
const issuedAt = iat ?? Math.floor(Date.now() / 1000);
// Note: do NOT pass noTimestamp:true here — that strips iat from the
// payload entirely, defeating the password-change comparison. Provide
// iat (and exp) via the payload directly instead.
return jwt.sign(
{ id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 },
process.env.JWT_SECRET,
{ issuer: 'picpeak-auth' }
);
}
function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) {
return jwt.sign(
{ eventId, eventSlug, type: 'gallery', ...extra },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
}
describe('GET /auth/session — symmetry with protected middleware', () => {
beforeEach(() => {
fakeDb.adminUsers = [];
fakeDb.events = [];
fakeDb.revokedTokens = [];
});
it('returns valid:true for an active admin token', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: '[email protected]',
is_active: true,
password_changed_at: null,
});
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(res.body.type).toBe('admin');
});
it('returns valid:false when the admin user has been deactivated', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: '[email protected]',
is_active: false,
password_changed_at: null,
});
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false when the admin user no longer exists', async () => {
// adminUsers is empty
const token = signAdminToken({ id: 999 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false when password was changed after the token was issued', async () => {
// iat must be in the past, exp must be in the future so jwt.verify
// doesn't reject the token before /auth/session even gets to look
// at password_changed_at.
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
const tokenExp = tokenIssuedAt + 86400;
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: '[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);
});
});
afterAll(async () => {
await require('../../src/services/serviceShutdown').stopServices();
if (cleanup) await cleanup();
});
it('hydrates an active admin and its role', async () => {
const res = await session(sign());
expect(res.body).toMatchObject({ valid: true, type: 'admin', adminUser: { id: adminId, role: { name: 'super_admin' } } });
});
it.each(['disabled', 'password', 'deleted', 'idle'])('rejects an admin after %s', async reason => {
let bearer = sign();
if (reason === 'disabled') await db('admin_users').where({ id: adminId }).update({ is_active: 0 });
if (reason === 'password') await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date().toISOString() });
if (reason === 'deleted') bearer = sign({ id: 999999 });
if (reason === 'idle') bearer = sign({ iat: Math.floor(Date.now() / 1000) - 7200 });
expect((await session(bearer)).body.valid).toBe(false);
});
it('accepts a session issued after a previous password change', async () => {
await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date(Date.now() - 120000).toISOString() });
expect((await session(sign())).body.valid).toBe(true);
});
it.each(['archived', 'expired', 'draft', 'inactive'])('rejects a gallery that is %s', async reason => {
await db('events').where({ id: eventId }).update({
...(reason === 'archived' && { is_archived: 1 }), ...(reason === 'draft' && { is_draft: 1 }),
...(reason === 'inactive' && { is_active: 0 }), ...(reason === 'expired' && { expires_at: new Date(Date.now() - 1000).toISOString() }),
});
expect((await session(gallery())).body.valid).toBe(false);
});
it.each(['guest', 'client', 'customer'])('restores the %s gallery session kind', async kind => {
const res = await session(gallery(kind === 'customer' ? { via: 'customer', customerId } : { accessLevel: kind }));
expect(res.body).toMatchObject({ valid: true, accessLevel: kind === 'client' ? 'client' : 'guest', viaCustomer: kind === 'customer' });
});
it.each(['revoked', 'restore'])('invalidates both admin and gallery sessions after %s', async reason => {
const tokens = [sign(), gallery()];
if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
else for (const bearer of tokens) await require('../../src/utils/tokenRevocation').revokeToken(bearer, 'test');
for (const bearer of tokens) expect((await session(bearer)).body.valid).toBe(false);
});
it('refuses a deactivated customer gallery session', async () => {
const bearer = gallery({ via: 'customer', customerId });
expect((await session(bearer)).body.valid).toBe(true);
await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 });
expect((await session(bearer)).body.valid).toBe(false);
});
it('refuses an unrelated JWT type', async () => {
const res = await session(sign({ type: 'password-reset' }));
expect(res.status).toBe(403); expect(res.body.valid).toBe(false);
});
@@ -0,0 +1,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(
photoId,
`gallery_public_${eventId}_${Date.now()}`,
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600,
galleryAccess: require('../../src/services/galleryAccessService').grant({ id: eventId }, 'public') },
);
const view = (slug, photoId, token) => request(app)
@@ -114,7 +115,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-private-b', photoB, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this photo/i);
expect(res.body.code).toBe('INVALID_GALLERY_GRANT');
});
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
@@ -123,7 +124,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => {
// check (sessionId gallery A != URL gallery B) must catch it.
const res = await view('secimg-private-b', photoA, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this gallery/i);
expect(res.body.code).toBe('INVALID_GALLERY_GRANT');
});
it('lets a token read its own gallery + photo (binding passes)', async () => {
@@ -15,7 +15,7 @@
jest.mock('axios', () => ({ post: jest.fn() }));
jest.mock('../../src/utils/networkValidation', () => ({
validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok' })),
validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] })),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
@@ -48,7 +48,7 @@ beforeEach(() => {
process.env.EMAIL_WEBHOOK_SECRET = SECRET;
transport.__testing.setAllowPrivateUrls(false);
transport.__testing.resetSecretWarning();
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok' });
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] });
axios.post.mockResolvedValue({ status: 200, data: streamOf('') });
});
@@ -0,0 +1,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', () => {
const routes = ['gallery.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
const routes = ['gallery/media.js', 'gallery/downloads.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
it.each(routes)('%s sets no Content-Type from photo.mime_type directly', (name) => {
const src = fs.readFileSync(path.join(__dirname, '../../src/routes', name), 'utf8');
expect(src).not.toMatch(/'Content-Type':\s*photo\.mime_type/);
@@ -0,0 +1,79 @@
const dns = require('dns');
const http = require('http');
const axios = require('axios');
const { validateExternalUrlAsync } = require('../../src/utils/networkValidation');
const { pinnedRequestOptions } = require('../../src/utils/pinnedRequest');
afterEach(() => jest.restoreAllMocks());
it('never performs a second DNS lookup that could reach a private listener', async () => {
const received = jest.fn();
const server = http.createServer((req, res) => { received(); res.end('private'); });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const url = `http://rebind.example:${server.address().port}/hook`;
const preflight = jest.spyOn(dns.promises, 'lookup').mockResolvedValue([{ address: '192.0.2.1', family: 4 }]);
const unsafeLookup = jest.spyOn(dns, 'lookup').mockImplementation((_host, opts, cb) => {
if (typeof opts === 'function') { cb = opts; opts = {}; }
cb(null, ...(opts.all ? [[{ address: '127.0.0.1', family: 4 }]] : ['127.0.0.1', 4]));
});
let options;
try {
const check = await validateExternalUrlAsync(url);
options = pinnedRequestOptions(check);
await expect(axios.post(url, 'private-data', { ...options, timeout: 200 })).rejects.toThrow();
expect(preflight).toHaveBeenCalledTimes(1);
expect(unsafeLookup).not.toHaveBeenCalled(); expect(received).not.toHaveBeenCalled();
} finally {
options?.httpAgent.destroy(); options?.httpsAgent.destroy();
await new Promise(resolve => server.close(resolve));
}
});
it('preserves the original Host and refuses redirects while using the pinned address', async () => {
const hosts = [];
const server = http.createServer((req, res) => {
hosts.push(req.headers.host); res.writeHead(302, { Location: 'http://localhost/private' }); res.end();
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const host = `pinned.example:${server.address().port}`;
// Test the transport in isolation: production checks reject this private IP.
const options = pinnedRequestOptions({ valid: true, hostname: 'pinned.example', addresses: [{ address: '127.0.0.1', family: 4 }] });
try {
const res = await axios.post(`http://${host}/hook`, 'body', { ...options, validateStatus: () => true });
expect(res.status).toBe(302); expect(hosts).toEqual([host]); expect(options.proxy).toBe(false);
} finally { options.httpAgent.destroy(); options.httpsAgent.destroy(); await new Promise(resolve => server.close(resolve)); }
});
it('fails closed for missing DNS results', () => {
expect(() => pinnedRequestOptions({ valid: true })).toThrow('validated destination');
});
it('preserves TLS SNI and certificate hostname verification for a pinned connection', async () => {
const fs = require('fs/promises');
const path = require('path');
const dir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'picpeak-tls-pin-'));
const key = path.join(dir, 'key.pem'), cert = path.join(dir, 'cert.pem');
require('child_process').execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes',
'-keyout', key, '-out', cert, '-days', '1', '-subj', '/CN=pinned.example',
'-addext', 'subjectAltName=DNS:pinned.example'], { stdio: 'ignore' });
const certificate = await fs.readFile(cert);
const seen = [];
const server = require('https').createServer({ key: await fs.readFile(key), cert: certificate }, (req, res) => {
seen.push({ host: req.headers.host, servername: req.socket.servername }); res.end('ok');
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const makeOptions = hostname => {
const options = pinnedRequestOptions({ valid: true, hostname, addresses: [{ address: '127.0.0.1', family: 4 }] });
options.httpsAgent.options.ca = certificate;
return options;
};
const allowed = makeOptions('pinned.example'), wrong = makeOptions('wrong.example');
try {
const host = `pinned.example:${server.address().port}`;
expect((await axios.post(`https://${host}/hook`, 'data', { ...allowed, timeout: 2000 })).status).toBe(200);
expect(seen).toEqual([{ host, servername: 'pinned.example' }]);
await expect(axios.post(`https://wrong.example:${server.address().port}/hook`, 'data', { ...wrong, timeout: 2000 }))
.rejects.toMatchObject({ code: 'ERR_TLS_CERT_ALTNAME_INVALID' });
expect(seen).toHaveLength(1);
} finally {
for (const options of [allowed, wrong]) { options.httpAgent.destroy(); options.httpsAgent.destroy(); }
await new Promise(resolve => server.close(resolve));
await fs.rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,28 @@
const EventEmitter = require('events');
jest.mock('../../src/utils/logger', () => ({ info: jest.fn() }));
const logger = require('../../src/utils/logger');
const middleware = require('../../src/middleware/apiRequestLogger');
const { requestLogPath } = require('../../src/utils/requestLogPath');
const marker = 'SECRET_TEST_CAPABILITY';
it.each([
`/api/gallery/g/photos?token=${marker}&password=${marker}`,
`/api/gallery/g/verify-token/${marker}`,
`/api/gallery/g/show/${marker}/state`,
`/api/images/g/photo/1/signed/${marker}`,
`/api/secure-images/g/secure/1/${marker}`,
`/api/secure-images/g/secure-download/1/${marker}`,
`/api/public/contracts/${marker}/sign`,
`/api/customer/auth/password-reset/${marker}`,
`/api/public/newsletter/unsubscribe/${marker}`,
])('does not log capabilities on request or response: %s', (originalUrl) => {
logger.info.mockClear();
const res = new EventEmitter(); res.statusCode = 200;
middleware({ originalUrl, method: 'GET' }, res, jest.fn());
res.emit('finish');
expect(logger.info).toHaveBeenCalledTimes(2);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(marker);
});
it('retains useful non-secret routes and removes control characters', () => {
expect(requestLogPath('/api/admin/events/12?search=private')).toBe('/api/admin/events/12');
expect(requestLogPath('/api/admin/events\nforged')).not.toContain('\n');
});
@@ -0,0 +1,13 @@
const http = require('http');
it('directs Supertest to the actual IPv6 listener instead of an unrelated IPv4 port', async () => {
jest.resetModules();
const request = require('supertest');
const server = http.createServer((_req, res) => { res.end('actual test listener'); });
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '::1', resolve); });
try {
const response = await request(server).get('/');
expect(response.status).toBe(200);
expect(response.text).toBe('actual test listener');
expect(response.request.url).toContain('://[::1]:');
} finally { await new Promise(resolve => server.close(resolve)); }
});
+23
View File
@@ -1,3 +1,18 @@
// Supertest 6 binds an IPv6 wildcard listener but hardcodes an IPv4 URL.
// macOS can allocate that IPv6 port while a different IPv4 service owns it.
// Address the listener's actual family so a test cannot reach that service.
jest.mock('supertest/lib/test', () => {
const Test = jest.requireActual('supertest/lib/test');
const serverAddress = Test.prototype.serverAddress;
Test.prototype.serverAddress = function(app, path) {
const url = serverAddress.call(this, app, path);
return app.address()?.family === 'IPv6'
? url.replace('://127.0.0.1:', '://[::1]:')
: url;
};
return Test;
});
beforeAll(() => {
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-secret';
@@ -8,3 +23,11 @@ beforeAll(() => {
process.env.STORAGE_PATH = '/storage';
}
});
// Dispose resources loaded by this suite using the application's draining
// shutdown. Individual fixtures still own temporary files and other DB pools.
afterAll(async () => {
await require('./src/services/serviceShutdown').stopServices();
const loadedDb = require.cache[require.resolve('./src/database/db')];
if (typeof loadedDb?.exports.db?.destroy === 'function') await loadedDb.exports.db.destroy();
});
@@ -0,0 +1,16 @@
/** Fresh installs and upgraded databases expose the same event timestamp. */
exports.up = async function (knex) {
if (!await knex.schema.hasTable('events')) return;
if (!await knex.schema.hasColumn('events', 'updated_at')) {
await knex.schema.alterTable('events', table => {
table.timestamp('updated_at');
});
}
// Do not replace existing modification times on a repeated migration.
await knex('events').whereNull('updated_at').update({ updated_at: knex.ref('created_at') });
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('events') && await knex.schema.hasColumn('events', 'updated_at')) {
await knex.schema.alterTable('events', table => table.dropColumn('updated_at'));
}
};
+59 -48
View File
@@ -218,7 +218,7 @@ app.use((req, res, next) => {
});
// CORS configuration (apply only to API routes)
const { isAllowedOrigin, multipartOriginAllowed } = require('./src/utils/requestOrigin');
const { isAllowedOrigin } = require('./src/utils/requestOrigin');
const corsOptions = {
origin: function (origin, callback) {
@@ -528,43 +528,10 @@ app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' }));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
// CSRF protection: require JSON Content-Type on mutating API requests
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
app.use('/api', (req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const contentType = req.headers['content-type'] || '';
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
// Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
}
// multipart is exactly what a cross-site <form> can send without a
// preflight, and in a split-origin deployment (SameSite=None) the admin
// cookie rides along to the upload routes. Browsers label such a
// submission Sec-Fetch-Site: cross-site (and always send Origin on a
// cross-origin POST); non-browser clients send neither header and pass.
if (contentType.includes('multipart/form-data') && !multipartOriginAllowed(req)) {
return res.status(403).json({ error: 'Cross-site multipart request rejected' });
}
}
next();
});
// Validate the origin independently of body length/content type.
app.use('/api', require('./src/middleware/csrf'));
// Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => {
try {
const started = Date.now();
const ts = new Date().toISOString();
logger.info(`[${ts}] ${req.method} ${req.originalUrl}`);
res.on('finish', () => {
const ms = Date.now() - started;
const tsDone = new Date().toISOString();
logger.info(`[${tsDone}] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
});
} catch (_) {}
next();
};
app.use('/api', apiRequestLogger);
app.use('/api', require('./src/middleware/apiRequestLogger'));
// Maintenance mode middleware - add after body parsing but before routes
app.use(maintenanceMiddleware);
@@ -1098,6 +1065,30 @@ if (spaCatchAll) {
// Global error handler (must be last)
app.use(errorHandler);
// App construction is side-effect free with respect to listening and workers.
let httpServer;
let shutdownPromise;
// Docker stops a container 10 s after SIGTERM by default (compose sets no
// stop_grace_period), so the drain must finish inside that window.
const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 8000;
async function stopServer() {
if (shutdownPromise) return shutdownPromise;
shutdownPromise = (async () => {
const close = httpServer ? new Promise((resolve, reject) => httpServer.close(err => err ? reject(err) : resolve())) : Promise.resolve();
const timeout = setTimeout(() => httpServer?.closeAllConnections(), Math.floor(SHUTDOWN_TIMEOUT_MS / 2));
timeout.unref();
try {
await Promise.all([close, require('./src/services/serviceShutdown').stopServices()]);
} finally {
clearTimeout(timeout);
// Always release the pool: a rejected service stop must not leave
// ref'd sockets keeping the process alive until SIGKILL.
await db.destroy();
}
})();
return shutdownPromise;
}
// Initialize services
async function startServer() {
try {
@@ -1122,14 +1113,8 @@ async function startServer() {
const { initializeCleanupJob } = require('./src/utils/authSecurity');
initializeCleanupJob();
// Initialize temp upload cleanup job
const { cleanupTempUploads } = require('./src/utils/cleanupTempUploads');
// Run cleanup on startup
cleanupTempUploads();
// Schedule periodic cleanup every hour
setInterval(cleanupTempUploads, 60 * 60 * 1000);
logger.info('Temp upload cleanup scheduled');
require('./src/utils/cleanupTempUploads').startTempUploadCleanup();
// Start file watcher
startFileWatcher();
// External-media folder watcher (issue 1187): imports new files into
@@ -1149,6 +1134,10 @@ async function startServer() {
startTransferCleanup();
// Custom-resolution download archives (#858) are disposable renditions —
// sweep them once their TTL passes so .download-cache doesn't grow forever.
// Best-effort, as before the scheduler refactor: a transient DB error on
// this one UPDATE must not abort the whole server start.
await require('./src/services/downloadJobService').recoverOrphanedJobs()
.catch((err) => logger.error('Download job recovery failed', { error: err.message }));
startDownloadJobCleanup();
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
startRevealScheduler();
@@ -1306,7 +1295,7 @@ async function startServer() {
// lazy means they don't pay for a module graph they never use.
require('./src/services/faceQueue').start();
app.listen(PORT, () => {
httpServer = app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
@@ -1325,10 +1314,32 @@ async function startServer() {
});
} catch (error) {
logger.error('Failed to start server:', error);
process.exit(1);
await stopServer();
process.exitCode = 1;
}
}
startServer();
if (require.main === module) {
let stopping = false;
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => {
if (stopping) {
logger.warn(`Received ${signal} again during shutdown, exiting immediately`);
process.exit(1);
}
stopping = true;
// The drain itself has no deadline; a hung worker must not keep the
// process alive past the container's stop grace period.
setTimeout(() => {
logger.error(`Shutdown exceeded ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS).unref();
stopServer().catch(error => { logger.error('Shutdown failed', { error: error.message }); process.exitCode = 1; });
});
}
startServer();
}
app.startServer = startServer;
app.stopServer = stopServer;
module.exports = app; // For testing
@@ -26,6 +26,8 @@ jest.mock('../database/db', () => {
return { db: mockDb, withRetry };
});
jest.mock('../utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
jest.mock('../utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn().mockResolvedValue(false) }));
jest.mock('../utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
@@ -83,8 +85,12 @@ function mockEventAndAssignment({ event, assignment }) {
assignChain.where = jest.fn().mockReturnValue(assignChain);
assignChain.first = jest.fn().mockResolvedValue(assignment);
db.mockImplementationOnce(() => eventsChain)
.mockImplementationOnce(() => assignChain);
db.mockImplementation((table) => {
if (table === 'events') return eventsChain;
if (table === 'customer_accounts') return { ...eventsChain, first: jest.fn().mockResolvedValue({ id: 7 }) };
if (table === 'event_customer_assignments') return assignChain;
throw new Error('Unexpected table: ' + table);
});
return { eventsChain, assignChain };
}
@@ -101,7 +107,7 @@ describe('verifyGalleryAccess — customer-minted JWT with active assignment', (
it('allows access when the event_customer_assignments row exists', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
type: 'gallery', iat: Math.floor(Date.now() / 1000),
eventId: 42,
via: 'customer',
customerId: 7,
@@ -132,7 +138,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
type: 'gallery', iat: Math.floor(Date.now() / 1000),
eventId: 42,
via: 'customer',
customerId: 7,
@@ -162,7 +168,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
// and start 403'ing per-event-password sessions.
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
type: 'gallery', iat: Math.floor(Date.now() / 1000),
eventId: 42,
customerId: 7,
// intentionally no `via` claim
@@ -194,7 +200,7 @@ describe('verifyGalleryAccess — per-event-password JWT', () => {
it('does NOT touch event_customer_assignments and passes through', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
type: 'gallery', iat: Math.floor(Date.now() / 1000),
eventId: 42,
// No via, no customerId — this is the legacy per-event-password
// flow where every guest mints their own JWT after entering the
@@ -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();
};
+9 -100
View File
@@ -1,9 +1,5 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isMissingRolesSchema } = require('../utils/dbErrors');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const sessionAccess = require('../services/sessionAccessService');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
@@ -32,100 +28,10 @@ async function adminAuth(req, res, next) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
logger.warn('Revoked token used', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject any session issued before the global cutoff (set by a .picpeak
// restore, which can reassign admin ids). Forces every pre-restore admin
// session to re-authenticate against the restored data.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Verify token type
if (decoded.type !== 'admin') {
logger.warn('Non-admin token used for admin endpoint', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(403).json({ error: 'Insufficient permissions' });
}
// IP validation (optional - can be strict or just log)
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.warn('Token used from different IP', {
userId: decoded.id,
tokenIp: decoded.ip,
currentIp: currentIp
});
}
// Check if admin still exists and is active, including role info
// Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario)
let admin;
try {
admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
.select(
'admin_users.id',
'admin_users.username',
'admin_users.email',
'admin_users.password_changed_at',
'roles.id as role_id',
'roles.name as role_name'
)
.first();
} catch (joinError) {
// Fail CLOSED on anything that isn't a genuinely missing roles schema:
// the fallback below fabricates super_admin, so a transient query failure
// (connection reset, deadlock, statement timeout, pool exhaustion) must
// not become a free privilege upgrade for every scoped admin. Rethrow →
// outer catch → 401, which is already how every other transient DB fault
// in this try block behaves (isTokenRevoked / isTokenBeforeCutoff both
// hit the DB here). apiTokenAuth takes the same posture on the v1
// surface, differing only in its 500.
if (!isMissingRolesSchema(joinError)) throw joinError;
// Fallback: roles table may not exist yet during upgrade
// Query without role join - user will have no role info but can still authenticate
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'password_changed_at')
.first();
if (admin) {
admin.role_id = null;
admin.role_name = 'super_admin'; // Assume super_admin for existing users during upgrade
}
}
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if password was changed after token was issued. JWT `iat` has
// 1-second resolution; `password_changed_at` is sub-second. Floor the
// comparison so a token issued in the *same* second as the password
// change isn't incorrectly rejected — that race used to bite anyone
// logging in immediately after a password reset/change.
if (admin.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(admin.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
logger.warn('Token used after password change', { userId: decoded.id });
return res.status(401).json({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED'
});
}
const admin = await sessionAccess.admin(decoded);
const requestIp = req.ip || req.connection?.remoteAddress;
if (decoded.ip && requestIp && decoded.ip !== requestIp) {
logger.info('admin session IP changed', { accountId: admin.id, tokenIp: decoded.ip, requestIp });
}
// Add user info to request (enhanced with role)
@@ -146,7 +52,10 @@ async function adminAuth(req, res, next) {
next();
} catch (error) {
logger.error('Auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
res.status(error.statusCode || 401).json({
error: error.isOperational ? error.message : 'Authentication failed',
...(error.isOperational && { code: error.code }),
});
}
}
+15
View File
@@ -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();
};
+12 -72
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Customer Authentication Middleware
*
@@ -10,10 +11,7 @@
*/
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const sessionAccess = require('../services/sessionAccessService');
const logger = require('../utils/logger');
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
@@ -25,7 +23,7 @@ async function customerAuth(req, res, next) {
// normal (page polling, pre-login session probes). Bump to debug
// for noisy investigations only.
logger.debug('[customerAuth] no token on request', {
url: req.originalUrl,
url: requestLogPath(req.originalUrl),
hasCookieHeader: !!req.headers?.cookie,
cookieKeys: Object.keys(req.cookies || {}),
});
@@ -42,7 +40,7 @@ async function customerAuth(req, res, next) {
decoded = verified.payload;
} catch (err) {
logger.warn('[customerAuth] jwt verification failed', {
url: req.originalUrl,
url: requestLogPath(req.originalUrl),
errorName: err.name,
errorMessage: err.message,
});
@@ -52,71 +50,10 @@ async function customerAuth(req, res, next) {
return res.status(401).json({ error: 'Invalid token', code: 'JWT_INVALID' });
}
if (await isTokenRevoked(decoded)) {
logger.warn('[customerAuth] token revoked', {
url: req.originalUrl,
customerId: decoded.customerId,
tokenType: decoded.type,
iat: decoded.iat,
});
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
if (decoded.type !== 'customer') {
logger.warn('[customerAuth] wrong token type', {
url: req.originalUrl,
tokenType: decoded.type,
});
return res.status(403).json({ error: 'Insufficient permissions', code: 'WRONG_TOKEN_TYPE' });
}
// IP drift gets logged but doesn't reject — same lenient policy as
// adminAuth. Customers may roam between mobile networks frequently.
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.info('Customer token used from different IP', {
customerId: decoded.customerId,
tokenIp: decoded.ip,
currentIp,
});
}
const customer = await db('customer_accounts')
.where({ id: decoded.customerId, is_active: formatBoolean(true) })
.select('id', 'email', 'display_name', 'first_name', 'last_name', 'password_changed_at', 'preferred_language')
.first();
if (!customer) {
// Either deleted, deactivated, or the id was forged. 401 across the
// board so the frontend session-expiry handler kicks in.
logger.warn('[customerAuth] customer row not found / inactive', {
url: req.originalUrl,
customerId: decoded.customerId,
});
return res.status(401).json({ error: 'Invalid token', code: 'CUSTOMER_NOT_FOUND' });
}
if (customer.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(customer.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
logger.warn('[customerAuth] token rejected: password_changed_at', {
url: req.originalUrl,
customerId: decoded.customerId,
iat: decoded.iat,
passwordChangedSeconds,
});
return res.status(401).json({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED',
});
}
const customer = await sessionAccess.customer(decoded);
const requestIp = req.ip || req.connection?.remoteAddress;
if (decoded.ip && requestIp && decoded.ip !== requestIp) {
logger.info('customer session IP changed', { accountId: customer.id, tokenIp: decoded.ip, requestIp });
}
req.customer = {
@@ -131,7 +68,10 @@ async function customerAuth(req, res, next) {
next();
} catch (error) {
logger.error('Customer auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
res.status(error.statusCode || 401).json({
error: error.isOperational ? error.message : 'Authentication failed',
...(error.isOperational && { code: error.code }),
});
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Global error handler middleware.
* Catches all errors and returns standardized responses.
@@ -119,7 +120,7 @@ const errorHandler = (err, req, res, next) => {
// Log the error
const logContext = {
url: req.originalUrl,
url: requestLogPath(req.originalUrl),
method: req.method,
ip: req.ip,
statusCode,
@@ -161,7 +162,7 @@ const errorHandler = (err, req, res, next) => {
*/
const notFoundHandler = (req, res, next) => {
const { NotFoundError } = require('../utils/errors');
next(new NotFoundError('Route', req.originalUrl));
next(new NotFoundError('Route', requestLogPath(req.originalUrl)));
};
/**
+5 -1
View File
@@ -1,3 +1,4 @@
const cleanupTimers = new Set();
const crypto = require('crypto');
const { db } = require('../database/db');
const logger = require('../utils/logger');
@@ -211,7 +212,7 @@ function strictRateLimit(options = {}) {
const store = new Map();
// Clean up old entries periodically
setInterval(() => {
const cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, data] of store.entries()) {
if (data.resetTime < now) {
@@ -219,6 +220,8 @@ function strictRateLimit(options = {}) {
}
}
}, windowMs);
cleanupTimer.unref();
cleanupTimers.add(cleanupTimer);
return (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress;
@@ -255,6 +258,7 @@ function strictRateLimit(options = {}) {
}
module.exports = {
dispose() { cleanupTimers.forEach(clearInterval); cleanupTimers.clear(); },
feedbackRateLimit,
strictRateLimit,
generateGuestIdentifier,
+70 -233
View File
@@ -1,267 +1,104 @@
const jwt = require('jsonwebtoken');
const { db, withRetry } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const access = require('../services/galleryAccessService');
/**
* True when a logged-in admin is explicitly previewing this gallery (#868).
*
* Two conditions, both required:
* 1. The explicit intent flag `?admin_preview=1` is present. The plain share
* link stays byte-identical to a guest's, so the password gate is still
* testable as a guest while logged in as admin — and the bypass is visible
* in the URL without being reusable (it carries no secret).
* 2. A VERIFIED admin session — the httpOnly `admin_token` cookie (rides along
* on same-origin API calls) or an Authorization: Bearer header, never the
* URL. Must decode as `type: 'admin'`, issuer `picpeak-auth`.
*
* The cookie is tried FIRST and the Bearer is accepted only when it is itself an
* admin token (#981 review): the frontend attaches a gallery Bearer to gallery
* endpoints, and a header-first, type-blind read would let a coexisting gallery
* session shadow the admin cookie and wrongly disable the preview.
*
* Fails closed on any verification error. Replaces the old `?preview=<raw-JWT>`
* scheme, which leaked a 24h admin token into the address bar.
*/
// Cookie first: a coexisting gallery Bearer must not shadow an admin preview.
function decodeAdminPreview(req) {
if (req.query?.admin_preview !== '1') return null;
// Cookie first, then a Bearer — but only an admin-typed token satisfies it.
const candidates = [];
if (req.cookies?.admin_token) candidates.push(req.cookies.admin_token);
const candidates = [req.cookies?.admin_token];
const header = req.headers?.authorization;
if (header && header.startsWith('Bearer ')) candidates.push(header.slice(7));
for (const token of candidates) {
if (header?.startsWith('Bearer ')) candidates.push(header.slice(7));
for (const token of candidates.filter(Boolean)) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth', algorithms: ['HS256'],
});
if (decoded.type === 'admin') return decoded;
} catch { /* try the next candidate */ }
}
return null;
}
// Signature-only predicate retained for UI-intent callers. It never authorizes.
function isAdminPreview(req) {
return decodeAdminPreview(req) !== null;
}
/**
* The full session check behind the preview bypass. A verified signature is
* not a live session: adminAuth also rejects revoked tokens, tokens issued
* before the restore cutoff, deactivated admins and tokens minted before the
* admin's last password change. Without those a logged-out or deactivated
* admin token kept unlocking every draft and password gallery until `exp`
* (30 days with remember-me). Sets req.isAdminPreview on success so the
* downstream reveal-mode and logging checks read one verified flag.
*/
async function verifyAdminPreview(req) {
if (req.isAdminPreview === true) return true;
function attachAccess(req, event, grant) {
req.event = event;
req.galleryAccess = grant;
req.isAdminPreview = grant.kind === 'admin';
req.accessLevel = grant.session?.accessLevel || 'guest';
req.viaCustomer = grant.session?.via === 'customer';
req.sessionID = req.isAdminPreview ? `gallery_admin_preview_${event.id}`
: `gallery_${grant.kind === 'public' ? 'public_' : ''}${event.id}_${Date.now()}`;
const ip = req.ip || req.connection?.remoteAddress || 'unknown';
const userAgent = req.get?.('User-Agent') || 'unknown';
req.clientInfo = {
ip, userAgent, fingerprint: `${ip}-${userAgent}`.substring(0, 32), timestamp: Date.now(),
};
}
async function verifyAdminPreview(req, event) {
if (req.isAdminPreview && req.galleryAccess && (!event || event.id === req.event?.id)) return true;
const decoded = decodeAdminPreview(req);
if (!decoded) return false;
try {
if (await isTokenRevoked(decoded) || await isTokenBeforeCutoff(decoded)) return false;
const admin = await withRetry(async () => db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'password_changed_at')
.first());
if (!admin) return false;
if (admin.password_changed_at) {
const changedSeconds = Math.floor(new Date(admin.password_changed_at).getTime() / 1000);
if (decoded.iat < changedSeconds) return false;
}
} catch (err) {
logger.warn('Admin preview session check failed', { error: err.message });
const slug = req.params?.slug || req.requestedSlug;
if (!event && !slug) return false;
event = event || await db('events').where({ slug }).select('*').first();
if (!event) return false;
const grant = access.grant(event, 'admin', decoded);
await access.authorize(event, grant);
attachAccess(req, event, grant);
return true;
} catch (error) {
logger.debug('Admin gallery preview denied', { code: error.code });
req.adminPreviewDenied = error;
return false;
}
req.isAdminPreview = true;
return true;
}
// Middleware to verify gallery access
function decodeGalleryToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'], issuer: 'picpeak-auth' });
} catch (error) {
// Legacy gallery tokens lacked an issuer, but still need the same type,
// lifecycle and session checks as current tokens.
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
}
throw error;
}
}
async function verifyGalleryAccess(req, res, next) {
try {
const requestedSlug = req.params.slug || req.requestedSlug;
// Admin preview (#868) is resolved BEFORE any gallery credential (#981
// review): a coexisting gallery token/Bearer must not shadow it, and the
// admin session must never fall into the `type !== 'gallery'` reject path
// below. Per-request bypass — draft + password relaxed, NO gallery JWT
// minted (a lingering guest cookie would muddy the coexisting-cookies case).
// req.isAdminPreview flags downstream logging to keep it out of guest stats.
if (await verifyAdminPreview(req)) {
if (!requestedSlug) {
return res.status(401).json({ error: 'No token provided' });
}
const previewEvent = await withRetry(async () => db('events')
.where({ slug: requestedSlug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('*').first());
if (!previewEvent) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
req.event = previewEvent;
req.isAdminPreview = true;
req.sessionID = `gallery_admin_preview_${previewEvent.id}`;
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
timestamp: Date.now()
};
return next();
}
const token = getGalleryTokenFromRequest(req, requestedSlug);
let event;
if (!token) {
if (!requestedSlug) {
return res.status(401).json({ error: 'No token provided' });
}
event = await withRetry(async () => db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
})
.select('*').first());
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (!requiresPassword) {
req.event = event;
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
timestamp: Date.now()
};
return next();
}
return res.status(401).json({ error: 'No token provided' });
}
// Try to verify with issuer first, fallback to no issuer for backward compatibility
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth'
});
} catch (error) {
// If verification fails with issuer, try without issuer (backward compatibility)
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
} else {
throw error;
}
}
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
// Only gallery-scoped tokens grant gallery access. Every legitimate
// path (password login, share link, client access, customer-minted,
// slideshow) mints type:'gallery'. Reject anything else — e.g. a guest
// identity token (type:'guest', for feedback attribution) that carries a
// matching eventId — instead of relying on other token types incidentally
// lacking an eventId to fail the id match below.
if (decoded.type !== 'gallery') {
if (await verifyAdminPreview(req)) return next();
// The caller asked for a preview explicitly: report why it was refused
// instead of falling through to a misleading guest-token error.
if (req.adminPreviewDenied?.isOperational) throw req.adminPreviewDenied;
const slug = req.params.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, slug);
const decoded = token ? decodeGalleryToken(token) : null;
if (decoded && decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid token type for gallery access' });
}
// If we have a slug in the URL params or from pre-middleware, verify it matches.
// (Admin preview never reaches here — it returns above — so drafts stay
// filtered for every real gallery-token request.)
if (requestedSlug) {
// Verify by slug and ensure it matches the token's event
event = await withRetry(async () => db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
})
.select('*').first());
// Verify the token's eventId matches
if (event && event.id !== decoded.eventId) {
return res.status(403).json({ error: 'Token does not match requested gallery' });
}
} else {
// Fallback to using eventId from token
event = await withRetry(async () => db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
})
.select('*').first());
}
if (!event) {
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
return res.status(404).json({ error: 'Gallery not found or expired' });
}
// Customer-minted gallery JWTs (#354): when the customer obtained
// this token via /api/customer/events/:slug/access-token, the
// payload carries `via:'customer'` and `customerId`. The admin
// can revoke the customer's access at any time by removing the
// event_customer_assignments row from the "Manage galleries"
// dialog on the customer detail page. Re-check that row here so
// the revocation takes effect on the customer's very next
// request — no token-blacklisting machinery required.
if (decoded.via === 'customer' && decoded.customerId) {
const assignment = await withRetry(async () => {
return await db('event_customer_assignments')
.where({
event_id: event.id,
customer_account_id: decoded.customerId,
})
.first();
});
if (!assignment) {
logger.info('[verifyGalleryAccess] Customer assignment revoked, rejecting token', {
customerId: decoded.customerId,
eventId: event.id,
});
return res.status(403).json({
error: 'Access to this gallery has been revoked',
code: 'CUSTOMER_ASSIGNMENT_REVOKED',
});
}
}
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
req.event = event;
req.accessLevel = decoded.accessLevel || 'guest';
// Customer-portal provenance (#746/#849): portal-minted tokens carry
// via:'customer' but NO accessLevel (they default to guest), while
// PIN-client logins carry accessLevel:'client' without `via`. Activity
// attribution/dedup needs the distinction, so surface it explicitly.
req.viaCustomer = decoded.via === 'customer';
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
// Create client info for logging (similar to secureImageMiddleware but simpler)
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32), // Limit to 32 chars for DB column
timestamp: Date.now()
};
logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
next();
if (!slug && !decoded?.eventId) return res.status(401).json({ error: 'No token provided' });
const event = await withRetry(() => db('events').where(slug ? { slug } : { id: decoded.eventId }).select('*').first());
if (!event) return res.status(404).json({ error: 'Gallery not found or expired' });
const grant = access.grant(event, decoded ? 'gallery' : 'public', decoded);
await access.authorize(event, grant);
attachAccess(req, event, grant);
return next();
} catch (error) {
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
res.status(401).json({ error: 'Invalid token' });
if (!error.isOperational) logger.error('Error verifying gallery access', { error: error.message });
return res.status(error.statusCode || 401).json({
error: error.isOperational ? error.message : 'Invalid token',
...(error.code && error.isOperational && { code: error.code }),
});
}
}
+7 -1
View File
@@ -1,6 +1,11 @@
const { db } = require('../database/db');
const logger = require('../utils/logger');
function canAccessEvent(admin, event) {
return Boolean(admin && event && (admin.roleName === 'super_admin'
|| event.created_by == null || Number(event.created_by) === Number(admin.id)));
}
/**
* Middleware to enforce event ownership for non-super_admin users.
* Super admins bypass the check. Other admins can only access events they created.
@@ -23,7 +28,7 @@ function requireEventOwnership(req, res, next) {
return res.status(404).json({ error: 'Event not found' });
}
// Allow access if: event has no owner (legacy/system), or admin owns it
if (event.created_by && event.created_by !== req.admin.id) {
if (!canAccessEvent(req.admin, event)) {
return res.status(403).json({ error: 'Access denied' });
}
next();
@@ -165,6 +170,7 @@ function requireProjectOwnership(req, res, next) {
}
module.exports = {
canAccessEvent,
requireEventOwnership,
filterOwnedEventIds,
scopeEventsQuery,
+3 -2
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Permission Checking Middleware for RBAC
* Provides role-based access control with caching for performance
@@ -143,7 +144,7 @@ function requirePermission(permissions, options = { requireAll: false }) {
userId: req.admin.id,
username: req.admin.username,
requiredPermissions: permArray,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
method: req.method
});
throw new ForbiddenError('Insufficient permissions');
@@ -180,7 +181,7 @@ function requireSuperAdmin() {
logger.warn('Super admin access denied', {
userId: req.admin.id,
username: req.admin.username,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
method: req.method
});
throw new ForbiddenError('Super Admin access required');
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
const { db } = require('../database/db');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
@@ -10,12 +11,24 @@ class SecureImageMiddleware {
this.suspiciousIPs = new Set();
this.blockedFingerprints = new Set();
this.rateLimitViolations = new Map();
this.cleanupTimer = null;
}
start() {
if (this.cleanupTimer) return;
this.cleanupTimer = setInterval(() => this.cleanup(), 300000);
this.cleanupTimer.unref();
}
dispose() {
clearInterval(this.cleanupTimer); this.cleanupTimer = null;
this.suspiciousIPs.clear(); this.blockedFingerprints.clear(); this.rateLimitViolations.clear();
}
/**
* Main security middleware for image access
*/
secureImageAccess = async (req, res, next) => {
this.start();
try {
const startTime = Date.now();
const clientIP = this.getClientIP(req);
@@ -56,7 +69,7 @@ class SecureImageMiddleware {
error: error.message,
stack: error.stack,
ip: req.ip,
path: req.path
path: requestLogPath(req.originalUrl || req.path)
});
res.status(500).json({
@@ -328,7 +341,7 @@ class SecureImageMiddleware {
client_ip: req.clientInfo?.ip || req.ip,
client_fingerprint: req.clientInfo?.fingerprint,
user_agent: req.get('User-Agent')?.substring(0, 255),
request_path: req.path,
request_path: requestLogPath(req.originalUrl || req.path),
request_method: req.method,
details: JSON.stringify(details),
timestamp: new Date().toISOString()
@@ -409,9 +422,4 @@ class SecureImageMiddleware {
// Create singleton instance
const secureImageMiddleware = new SecureImageMiddleware();
// Setup cleanup interval
setInterval(() => {
secureImageMiddleware.cleanup();
}, 300000); // Every 5 minutes
module.exports = secureImageMiddleware;
+2 -1
View File
@@ -26,7 +26,7 @@ const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries
// behaviour is unchanged: the timer fires every 5 min as long as
// the server has anything else keeping the loop alive (HTTP server,
// other intervals), which is always.
setInterval(() => {
const cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [token, lastActivity] of sessions.entries()) {
if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) {
@@ -208,6 +208,7 @@ function getActiveSessions() {
}
module.exports = {
dispose: () => { clearInterval(cleanupTimer); sessions.clear(); cachedTimeout = null; cacheExpiry = 0; },
sessionTimeoutMiddleware,
isSessionExpired,
endSession,
+14 -578
View File
@@ -5,7 +5,7 @@
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../../database/db');
const { formatBoolean } = require('../../utils/dbCompat');
const { slugify } = require('../../utils/slug');
const { adminAuth } = require('../../middleware/auth');
const { requirePermission, userHasAllPermissions } = require('../../middleware/permissions');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../../utils/emailNormalization');
@@ -25,14 +25,13 @@ const eventTypeService = require('../../services/eventTypeService');
const { normaliseEventTimeTriple } = require('../../services/eventService');
const { hasColumnCached } = require('../../utils/schemaCache');
const { requireEventOwnership } = require('../../middleware/ownership');
const { getAppSetting } = require('../../utils/appSettings');
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
const { clampIntOrUndefined } = require('../../utils/numericHelpers');
const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl');
const downloadZipService = require('../../services/downloadZipService');
const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults');
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults,
getImageSecurityDefaults, resolveImageSecurityColumns, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
const { KEYBIND_MODES } = require('../../services/feedbackDefaults');
const { validateHeroImageAnchor, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade } = require('./helpers');
/**
* `events.slug` is UNIQUE, and both routes that mint one do a read-then-insert
@@ -174,7 +173,6 @@ async function queueGalleryCreatedEmail(event, { password, requirePassword } = {
module.exports = (router) => {
// Create new event
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').notEmpty().trim().custom(async (value) => {
@@ -240,7 +238,6 @@ module.exports = (router) => {
body('watermark_text').optional().trim(),
// #328 follow-up: per-event opt-in for presigned-URL "Download All".
// Bypasses watermarks; admin must enable knowingly.
body('allow_presigned_download').optional().isBoolean(),
// Feedback sub-toggles (#1044). Optional: omitting them inherits the
// global Settings > Events defaults.
body('allow_ratings').optional().isBoolean(),
@@ -298,571 +295,13 @@ module.exports = (router) => {
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
// Get field requirements from settings
const fieldRequirements = await getEventFieldRequirements();
const {
event_type,
event_name,
event_date,
// Migration 137 — calendar time fields. is_full_day defaults to
// true at the service layer when undefined (legacy form payloads).
event_time_start,
event_time_end,
is_full_day,
admin_email,
password,
welcome_message = '',
color_theme = null,
expiration_days = 30,
allow_user_uploads = false,
upload_category_id = null,
allow_downloads = true,
disable_right_click = false,
enable_devtools_protection: enableDevtoolsProtectionInput,
watermark_downloads = false,
watermark_text = null,
allow_presigned_download = false,
require_password: requirePasswordInput,
// Feedback settings. The allow_* sub-toggles deliberately have NO
// destructuring defaults: `undefined` means "the caller didn't say",
// which inherits the global Settings > Events default (#1044). The
// admin create form posts explicit values (it seeds its own panel
// from the same globals), so inheritance here is what covers the v1
// API and any other caller that omits them.
feedback_enabled: feedbackEnabledInput,
allow_ratings: allowRatingsInput,
allow_likes: allowLikesInput,
allow_comments: allowCommentsInput,
allow_favorites: allowFavoritesInput,
allow_reactions: allowReactionsInput,
allow_color_labels: allowColorLabelsInput,
keybind_mode: keybindModeInput,
require_name_email = false,
moderate_comments = true,
show_feedback_to_guests = true,
// The create form has always shown the identity-mode chooser and this
// route has never read it, so a gallery created as 'guest' quietly came
// out 'simple' and the photographer had to set it again on the event.
// Surfaced by adding a third mode (#1197); the fix is the same for all
// three. Unknown values fall back rather than reaching the column,
// which on Postgres is guarded by a CHECK constraint.
identity_mode: identityModeInput,
// CSS Template
css_template_id = null,
// Hero logo settings
hero_logo_visible = true,
// Header style settings
header_style = 'standard',
hero_divider_style = 'wave',
// Hero image anchor position (#162)
hero_image_anchor = 'center',
// Photo cap
photo_cap = null,
// Client access settings (#172)
client_access_enabled = false,
client_password = null,
// Draft mode
is_draft = true,
// Default photo sort
default_photo_sort = 'upload_date_desc',
// Banner overrides (#440 / #932) — see the insert below.
promo_mode = 'inherit',
promo_markdown = null,
info_mode = 'inherit',
info_markdown = null
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
const customerEmail = getCustomerEmailFromPayload(req.body);
// Phone field is opt-in via the global setting (#322). If disabled,
// ignore whatever the client posted — defence in depth against form
// bypass.
const phoneEnabled = await isPhoneFieldEnabled();
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
const customerColumnsAvailable = await hasCustomerContactColumns();
// Conditional validation based on settings
const validationErrors = [];
if (fieldRequirements.require_customer_name && !customerName) {
validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' });
}
if (fieldRequirements.require_customer_email && !customerEmail) {
validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' });
}
if (fieldRequirements.require_admin_email && !admin_email) {
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
}
if (fieldRequirements.require_event_date && !event_date) {
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
}
if (validationErrors.length > 0) {
return res.status(400).json({ errors: validationErrors });
}
// Default require_password from global "event_default_require_password"
// setting when the body omits it (#317 — admins want to flip the default).
let requirePasswordFallback = true;
if (requirePasswordInput === undefined) {
const setting = await readBooleanSetting('event_default_require_password');
if (setting !== undefined) requirePasswordFallback = setting;
}
const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
// Default feedback_enabled from global "event_default_feedback_enabled"
// setting when the body omits it (#520 — same pattern as require_password
// above, lets admins make Guest Feedback ON the out-of-box default for
// new events instead of toggling it on every time).
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await readBooleanSetting('event_default_feedback_enabled');
if (setting !== undefined) feedbackEnabledFallback = setting;
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
// Sub-toggle defaults from the global Settings > Events values (#1044).
// One batched read; an explicitly-sent body value still wins.
const feedbackDefaults = applyFeedbackDefaults({
allow_ratings: allowRatingsInput,
allow_likes: allowLikesInput,
allow_comments: allowCommentsInput,
allow_favorites: allowFavoritesInput,
allow_reactions: allowReactionsInput,
allow_color_labels: allowColorLabelsInput,
keybind_mode: keybindModeInput,
}, await resolveEventFeedbackDefaults());
// Debug logging
logger.debug('Download control values', {
allow_downloads,
disable_right_click,
watermark_downloads,
watermark_text,
require_password: requirePassword,
types: {
allow_downloads: typeof allow_downloads,
disable_right_click: typeof disable_right_click,
watermark_downloads: typeof watermark_downloads
}
});
let passwordValidation = null;
if (requirePassword) {
passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
}
// Generate unique slug. Uses the shared util so accented names
// (Família, Decoração, etc.) get transliterated instead of dropped
// — see backend/src/utils/slug.js for the why (#525).
const processedEventName = slugify(event_name);
// Use event_date in slug if provided, otherwise use random suffix
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds (random placeholder when not required)
const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date)
// If expiration is not required, expires_at will be null (never expires)
// If event_date is not provided, use current date as base for expiration
let expires_at = null;
if (fieldRequirements.require_expiration) {
const baseDate = event_date || new Date().toISOString().split('T')[0];
// Parse YYYY-MM-DD format as local date to avoid timezone issues
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(baseDate);
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
}
// Create folder structure
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
const eventPath = path.join(storagePath, 'events/active', slug);
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158).
let effectiveHeaderStyle = header_style;
let effectiveDividerStyle = hero_divider_style;
if (color_theme && (!req.body.header_style || !req.body.hero_divider_style)) {
try {
if (typeof color_theme === 'string' && color_theme.startsWith('{')) {
const parsed = JSON.parse(color_theme);
if (!req.body.header_style && parsed.headerStyle) {
effectiveHeaderStyle = parsed.headerStyle;
}
if (!req.body.hero_divider_style && parsed.heroDividerStyle) {
effectiveDividerStyle = parsed.heroDividerStyle;
}
}
} catch (_) {
// color_theme is not JSON nothing to extract
}
}
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
const brandingDefaults = await getBrandingDefaults();
// hero_logo_visible: store NULL ("inherit") unless the admin explicitly
// set it, so the global branding_logo_display_hero toggle keeps
// controlling this gallery afterwards (#756). Only an explicit per-event
// choice overrides the global. `!= null` treats an explicit null the same
// as omitted (both → inherit); otherwise formatBoolean(null) would coerce
// to 0/false on SQLite instead of NULL (the PUT handler already does this).
const effectiveHeroLogoVisible = req.body.hero_logo_visible != null
? formatBoolean(hero_logo_visible)
: null;
// NULL = inherit the global branding_logo_size (#756), resolved at read
// time. Only an explicit per-event size overrides it.
const effectiveHeroLogoSize = req.body.hero_logo_size || null;
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
// Inherit "Detect dev tools" from the global Image Security setting unless
// the request explicitly overrides it (#317 — admin disabled it globally
// but new events still got it ON because the column default is true).
const protectionDefaults = await getDownloadProtectionDefaults();
// #1296 — the other four Image-security settings, which were written,
// rendered as controls, and read by nothing. Same inheritance rule as
// the devtools setting below. Creation-time only; see
// getImageSecurityDefaults for why existing events are left alone.
const imageSecurityColumns = resolveImageSecurityColumns(
req.body,
await getImageSecurityDefaults(),
);
const effectiveEnableDevtoolsProtection =
enableDevtoolsProtectionInput !== undefined
? enableDevtoolsProtectionInput
: protectionDefaults.enable_devtools_protection !== undefined
? protectionDefaults.enable_devtools_protection
: true;
// Migration 137 — normalise calendar time triple. Throws AppError
// 400 when is_full_day=false but times are malformed/inverted.
const calendarTriple = normaliseEventTimeTriple({
event_time_start, event_time_end, is_full_day,
});
const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
// Insert into database
// Seed the new event's Live Slideshow display style from the PICPEAK-WIDE
// preset (app_settings, Settings → Slideshow). New events inherit it and the
// admin can still override per event. Watermark is left NULL = inherit the
// global watermark; the share token is minted on demand, not seeded. Guarded
// so un-migrated installs (mid-branch) don't reference missing columns.
let slideshowSeed = {};
if (await hasColumnCached('events', 'show_interval_ms')) {
try {
// parseInt-first: the previous `Number.isFinite(+v)` pre-check let
// NaN through for null/''/true (+null is 0, parseInt(null) is NaN),
// producing show_interval_ms=NaN in the INSERT — PG rejects that
// with "invalid input syntax for type integer" while SQLite
// silently stores NULL, so event creation 500'd on PG whenever the
// slideshow app_settings rows were absent.
const intP = (v, min, max) => clampIntOrUndefined(v, min, max);
const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined);
const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000);
const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS);
const tms = intP(await getAppSetting('slideshow_transition_ms', undefined), 100, 5000);
const cf = oneOf(await getAppSetting('slideshow_colorfilter', undefined), SLIDESHOW_COLORFILTERS);
if (i !== undefined) slideshowSeed.show_interval_ms = i;
if (tr) slideshowSeed.show_transition = tr;
if (tms !== undefined) slideshowSeed.show_transition_ms = tms;
if (cf) slideshowSeed.show_colorfilter = cf;
} catch (e) {
logger.warn('Failed to seed slideshow settings from global preset', { error: e.message });
}
}
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
...slideshowSeed,
event_date: event_date || null,
...(calendarColumnsExist ? {
event_time_start: calendarTriple.event_time_start,
event_time_end: calendarTriple.event_time_end,
is_full_day: formatBoolean(calendarTriple.is_full_day),
} : {}),
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName || null,
host_email: customerEmail || null,
admin_email: admin_email || null,
password_hash,
// Opt-in recoverable copy (#1271), written with the hash so the two
// can never disagree. Empty unless the security setting is on.
...(await galleryPasswordColumns({
...(requirePassword && password ? { password } : {}),
...(client_access_enabled && client_password ? { clientPassword: client_password } : {}),
})),
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads,
upload_category_id,
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection),
// Request value, else the global default, else the column default —
// a key absent here is one the database fills in (#1296).
...imageSecurityColumns,
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text,
allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'),
require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null,
// Already formatBoolean-coerced above, or null = inherit global (#756).
hero_logo_visible: effectiveHeroLogoVisible,
hero_logo_size: effectiveHeroLogoSize,
hero_logo_position: effectiveHeroLogoPosition,
// Banner overrides. Both were accepted by the validators above and
// then dropped here, so an API client could POST info_mode:'off' or a
// custom banner, get 201, and find the row still on 'inherit'.
// Markdown is only stored for 'custom' — same rule the PUT applies.
promo_mode: ['inherit', 'custom', 'off'].includes(promo_mode) ? promo_mode : 'inherit',
promo_markdown: promo_mode === 'custom' && typeof promo_markdown === 'string' && promo_markdown.trim()
? promo_markdown.trim() : null,
info_mode: ['inherit', 'custom', 'off'].includes(info_mode) ? info_mode : 'inherit',
info_markdown: info_mode === 'custom' && typeof info_markdown === 'string' && info_markdown.trim()
? info_markdown.trim() : null,
header_style: effectiveHeaderStyle || 'standard',
hero_divider_style: effectiveDividerStyle || 'wave',
hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null,
is_draft: formatBoolean(parseBooleanInput(is_draft, true)),
default_photo_sort: default_photo_sort || 'upload_date_desc',
// Client access (#172)
client_access_enabled: formatBoolean(client_access_enabled),
...(client_access_enabled && client_password ? {
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
client_share_token: crypto.randomBytes(32).toString('hex')
} : {}),
// Per-event opt-in for hero-photo OG share image (#474). Defaults
// false on create — admin opts in from the event detail page once
// they've picked a hero they're comfortable surfacing publicly.
og_image_share_enabled: formatBoolean(req.body.og_image_share_enabled === true),
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// #1271 — the setting was read before the hashes; re-check after the write
await dropCopiesIfStorageOff(eventId);
// Apply customer-account assignments (#354). Skip when the customer
// portal flag is off — the frontend hides the picker in that case,
// but a stale tab could still POST customer_account_ids; we ignore
// them rather than 403 the entire create.
if (Array.isArray(req.body.customer_account_ids)) {
try {
const customerAccountsService = require('../../services/customerAccountsService');
if (await customerAccountsService.isCustomerPortalEnabled()) {
await customerAccountsService.setAssignmentsForEvent(
eventId,
req.body.customer_account_ids,
req.admin.id
);
}
} catch (e) {
logger.error('Failed to set customer assignments on event create', {
eventId, error: e.message,
});
}
}
// Insert feedback settings if feedback is enabled
if (feedback_enabled) {
await db('event_feedback_settings').insert({
event_id: eventId,
feedback_enabled: formatBoolean(feedback_enabled),
allow_ratings: formatBoolean(feedbackDefaults.allow_ratings),
allow_likes: formatBoolean(feedbackDefaults.allow_likes),
allow_comments: formatBoolean(feedbackDefaults.allow_comments),
allow_favorites: formatBoolean(feedbackDefaults.allow_favorites),
allow_reactions: formatBoolean(feedbackDefaults.allow_reactions),
allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels),
keybind_mode: feedbackDefaults.keybind_mode,
require_name_email: formatBoolean(require_name_email),
moderate_comments: formatBoolean(moderate_comments),
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
identity_mode: ['simple', 'guest', 'shared'].includes(identityModeInput)
? identityModeInput
: 'simple',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
// Log activity
await logActivity('event_created',
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Fire event.created webhook (#327). If the event is being published
// immediately (not a draft), event.published also fires below.
// Payload uses canonical event subject (#341) so receivers always see
// the same shape (id/slug/event_name + customer contact + share_*).
try {
const webhookService = require('../../services/webhookService');
await webhookService.fire('event.created', {
event: {
...webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
}),
is_draft: parseBooleanInput(is_draft, true),
},
});
} catch (e) { /* webhookService.fire never throws but be defensive */ }
// Queue creation email (only if there is a recipient and event is not a draft)
// Language detection is handled by email processor
const isDraft = parseBooleanInput(is_draft, true);
if (customerEmail && !isDraft) {
// Build email data with optional client access info
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
};
// Include client access info in email when enabled (#172)
if (client_access_enabled && client_password) {
const createdEvent = await db('events').where('id', eventId).first();
// Same FRONTEND_URL-before-APP_URL order as before: APP_URL is
// passed as the override so it still outranks the general_site_url
// setting and the request origin. Chaining it after the resolver
// would make it dead code, because the resolver only returns falsy
// when NOTHING is configured (#1104).
const frontendUrl = await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL });
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
emailData.client_password = client_password;
}
await db('email_queue').insert({
event_id: eventId,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
// scheduled_at will use default value
});
}
// WhatsApp gallery_ready notification (#640D). Fires when the event is
// created NOT as a draft, the `whatsapp` flag is on, a config exists, and
// the customer supplied a phone number. Non-fatal: a queue failure should
// never block gallery creation.
if (!isDraft && customerPhone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
customer_name: customerName || '',
event_name,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : '',
expiry_date: expires_at ? expires_at.toISOString() : null,
language: null, // resolved by processor via general_default_language
});
}
} catch (waError) {
logger.warn('Failed to queue WhatsApp notification on create', { error: waError.message });
}
}
// Fire event.published when the event is created NOT as a draft. The
// separate /publish endpoint fires it for the draft → live transition;
// this covers the "create-and-publish in one shot" path.
if (!isDraft) {
try {
const webhookService = require('../../services/webhookService');
await webhookService.fire('event.published', {
event: webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
}),
});
} catch (e) { /* non-fatal */ }
}
res.json({
id: eventId,
slug,
event_name,
event_type,
customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
photo_cap: photo_cap || null,
is_draft: isDraft,
share_link: shareUrl,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
const created = await require('../../services/eventCreationService').createEvent(req.body, {
actor: req.admin,
frontendUrl: await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL }),
});
res.json(created);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json(error.responseBody || { error: error.message, code: error.code });
if (isDuplicateSlugError(error)) {
logger.warn('Event creation lost the slug race', { error: error.message });
return res.status(409).json(DUPLICATE_SLUG_RESPONSE);
@@ -1425,6 +864,7 @@ module.exports = (router) => {
share_token: shareToken,
expires_at: newExpiresAt ? newExpiresAt.toISOString() : null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads: source.allow_user_uploads,
upload_category_id: source.upload_category_id,
@@ -1441,7 +881,6 @@ module.exports = (router) => {
use_canvas_rendering: source.use_canvas_rendering,
watermark_downloads: source.watermark_downloads,
watermark_text: source.watermark_text,
allow_presigned_download: source.allow_presigned_download,
require_password: source.require_password,
css_template_id: source.css_template_id || null,
hero_logo_visible: source.hero_logo_visible,
@@ -1598,7 +1037,6 @@ module.exports = (router) => {
body('disable_right_click').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(),
body('allow_presigned_download').optional().isBoolean(),
body('source_mode').optional().isIn(['managed', 'reference']),
body('external_path').optional({ nullable: true }).isString().trim(),
body('external_watch').optional().isBoolean(),
@@ -2029,8 +1467,6 @@ module.exports = (router) => {
}
}
// Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158). This ensures the
// database columns stay in sync even if the frontend only sends the
@@ -2232,12 +1668,12 @@ module.exports = (router) => {
return res.status(404).json({ error: 'Event not found' });
}
const newStatus = !event.is_active;
const newStatus = !parseBooleanInput(event.is_active, false);
await db('events')
.where('id', id)
.update({
is_active: newStatus,
updated_at: new Date()
is_active: formatBoolean(newStatus),
updated_at: new Date().toISOString()
});
// Log activity
+2 -421
View File
@@ -1,392 +1,9 @@
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
// Shared helpers + module-level caches used across the adminEvents sub-routers.
const { db, logActivity } = require('../../database/db');
const fs = require('fs').promises;
const path = require('path');
const logger = require('../../utils/logger');
const { parseStringInput } = require('../../utils/parsers');
const settings = require('../../services/eventSettings');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (value) => {
if (['top', 'center', 'bottom'].includes(value)) return true;
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
}
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
};
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
// Helper to get event field requirements from settings
const getEventFieldRequirements = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email',
'event_require_event_date',
'event_require_expiration'
])
.select('setting_key', 'setting_value');
const requirements = {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
value = value === 'true';
}
}
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
});
return requirements;
} catch (error) {
logger.error('Failed to get event field requirements', { error: error.message });
return {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
}
};
// Helper to read app_settings booleans by key, used to inherit per-setting
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
// so callers can fall back to a legacy default.
/**
* Decode an app_settings value into the JS value it represents.
*
* setting_value is JSON text on SQLite and may already be decoded by the
* driver on a PG json column, so one parse does not normalise both. On top
* of that, the Image Security tab used to PUT back values it had read
* undecoded, wrapping another layer of quoting around each one on every
* save — the GET handler decodes now, but installs carry however many
* layers they accumulated before that.
*
* Every reader of app_settings has to agree about this, or the admin UI
* shows one thing while event creation does another.
*
* Terminates: each parse of a string is strictly shorter than its input.
*/
const decodeSettingValue = (raw) => {
let value = raw;
while (typeof value === 'string') {
let parsed;
try { parsed = JSON.parse(value); } catch { break; }
if (parsed === value) break;
value = parsed;
}
return value;
};
const readBooleanSetting = async (key) => {
try {
const setting = await db('app_settings').where('setting_key', key).first();
if (!setting) return undefined;
const value = decodeSettingValue(setting.setting_value);
return typeof value === 'boolean' ? value : undefined;
} catch (error) {
logger.error('Failed to read app setting', { key, error: error.message });
return undefined;
}
};
// Helper to read the global "enable_devtools_protection" admin setting so
// new events inherit it instead of always falling back to the DB column default
// (#317 — admin disabled it globally but new events still got it ON).
const getDownloadProtectionDefaults = async () => {
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
};
/**
* The rest of Settings → Image security, as creation defaults (#1296).
*
* Four settings in that panel were written, reloaded and rendered as
* controls, and read by nothing:
*
* default_protection_level → events.protection_level
* default_image_quality → events.image_quality
* enable_canvas_rendering → events.use_canvas_rendering
*
* Each maps onto a column migration 038 already created, and each is
* labelled "… by default", so applying them at creation is what the panel
* has always claimed to do. `enable_devtools_protection` above is the only
* one of the five that was ever wired.
*
* Creation-time only, deliberately. Applying them to EXISTING events would
* silently change live galleries on upgrade — an install with
* enable_canvas_rendering already on would switch every grid to canvas
* rendering, which is memory-expensive at scale and is the profile under
* investigation in #1287. New events only; existing rows untouched.
*
* Any value that is missing or malformed comes back undefined so the caller
* falls through to the column default, exactly as before this existed.
*/
const PROTECTION_LEVELS = ['basic', 'standard', 'enhanced', 'maximum'];
// parseInt would rescue malformed settings instead of rejecting them:
// parseInt('72oops') is 72, parseInt(72.5) is 72, parseInt([72]) is 72.
// That matters because the settings PUT stores whatever JSON it is handed
// without validating the value (adminImageSecurity.js writes
// JSON.stringify(value) for any allow-listed key), so those shapes really
// can be sitting in app_settings. Accept only a genuine integer, or a
// string that is exactly one.
const toInteger = (value) => {
if (typeof value === 'number') return Number.isInteger(value) ? value : undefined;
if (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim())) return Number(value.trim());
return undefined;
};
const getImageSecurityDefaults = async (trx = null) => {
const defaults = {};
try {
// Accepts a transaction the way getAppSetting does. It matters on
// sqlite3, whose pool holds a single connection: a caller already inside
// db.transaction() that read through the global `db` would block on the
// connection its own transaction holds until the acquire timeout, and
// the catch below would then quietly swallow it and drop the defaults.
const query = trx || db;
const rows = await query('app_settings')
.whereIn('setting_key', [
'default_protection_level',
'default_image_quality',
'enable_canvas_rendering',
])
.select('setting_key', 'setting_value');
// app_settings holds JSON text on SQLite, while a PG json column comes
// back already decoded — so one parse is not enough to normalise both.
// Worse, GET /api/admin/image-security/settings returns setting_value
// without decoding it and the settings tab PUTs the whole fetched object
// straight back through JSON.stringify, so opening the tab and saving
// re-encodes every value it read as text. After one such round trip
// `true` is stored as "\"true\"" and a single parse yields the string
// 'true', which the type checks below reject — the settings would go
// quietly dead again, which is the bug this whole change exists to fix.
// The GET handler now decodes, so this stops accumulating — but installs
// that already stacked N layers have to keep working, and N is however
// many times someone opened that tab. So unwrap until it stops being a
// JSON string rather than to a fixed depth; this terminates because each
// parse of a string is strictly shorter than its input.
const read = (key) => {
const row = rows.find((r) => r.setting_key === key);
if (!row) return undefined;
return decodeSettingValue(row.setting_value);
};
const level = read('default_protection_level');
if (typeof level === 'string' && PROTECTION_LEVELS.includes(level)) {
defaults.protection_level = level;
}
// The column is an integer percentage; anything outside 1..100 is a
// misconfiguration and falls through rather than being clamped into
// something the operator did not choose.
const quality = toInteger(read('default_image_quality'));
if (quality !== undefined && quality >= 1 && quality <= 100) {
defaults.image_quality = quality;
}
const canvas = read('enable_canvas_rendering');
if (typeof canvas === 'boolean') {
defaults.use_canvas_rendering = canvas;
}
} catch (error) {
// A settings read must never block event creation; the column defaults
// are a correct fallback.
logger.error('Failed to read image-security defaults', { error: error.message });
}
return defaults;
};
/**
* Build the image-security columns for a NEW event: an explicit request
* value wins, then the global default, then the column default (the key is
* omitted entirely so the database supplies it).
*
* Shared by the admin create route and POST /api/v1/events so the configured
* security level cannot depend on which entry point created the gallery —
* the same split that made #592 (devtools) a separate bug from #317.
*
* `body` values are already validated by the route's express-validator
* chain; `defaults` come from getImageSecurityDefaults(), which validates
* them itself.
*/
const resolveImageSecurityColumns = (body = {}, defaults = {}) => {
const { formatBoolean } = require('../../utils/dbCompat');
const columns = {};
// express-validator runs isInt/isIn/isBoolean element-wise on arrays, so a
// single-element array like `image_quality: [72]` passes the route's chain
// and arrives here still an array. The routes reject those with
// .not().isArray(); this guard means any future caller cannot write one
// into a scalar column (a PG insert error, or `[false]` coerced to true).
const scalar = (v) => (v !== null && typeof v === 'object' ? undefined : v);
const pick = (key) => {
const fromBody = scalar(body[key]);
return fromBody !== undefined ? fromBody : defaults[key];
};
const level = pick('protection_level');
if (level !== undefined) columns.protection_level = level;
const quality = pick('image_quality');
if (quality !== undefined) columns.image_quality = quality;
const canvas = pick('use_canvas_rendering');
if (canvas !== undefined) columns.use_canvas_rendering = formatBoolean(canvas);
return columns;
};
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
//
// Note: `branding_logo_position` (header bar — left/center/right) is a
// different concept from `hero_logo_position` (hero block — top/center/
// bottom) and must NOT be mapped here. A previous version copied the
// branding value over, which wrote 'left'/'right' into per-event
// hero_logo_position columns and broke any subsequent PUT validation
// (#357). Migration 084 heals existing rows.
const getBrandingDefaults = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'branding_logo_display_hero',
'branding_logo_size'
])
.select('setting_key', 'setting_value');
const defaults = {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
}
if (s.setting_key === 'branding_logo_display_hero') {
defaults.hero_logo_visible = value !== false;
}
if (s.setting_key === 'branding_logo_size' && value) {
defaults.hero_logo_size = value;
}
});
return defaults;
} catch (error) {
logger.error('Failed to get branding defaults', { error: error.message });
return {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
}
};
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
// Whether the global "phone field" toggle (#322) is enabled. Cached for
// the request via a module-level read; drift is acceptable since this
// only governs whether to persist the field, not security boundaries.
const isPhoneFieldEnabled = async () => {
try {
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
if (!row) return false;
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return value === true;
} catch (error) {
logger.debug('Failed to read event_phone_field_enabled', { error: error.message });
return false;
}
};
const RECOVERABLE_PASSWORD_COLUMNS = ['password_recoverable', 'client_password_recoverable'];
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
customer_phone,
// Bound only to exclude the secrets from `...rest` — never read.
password_hash: _ph, client_password_hash: _cph,
...rest
} = event;
// #1271 — the encrypted copies never leave the server except via
// /:id/password. Removed by name (not destructured) so a secret scanner
// does not read the binding as a hard-coded password.
for (const column of RECOVERABLE_PASSWORD_COLUMNS) delete rest[column];
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null,
customer_phone: customer_phone ?? null
};
};
let customerColumnCache = null;
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
logger.debug('Failed to detect customer_email column', { error: error.message });
return false;
}
};
// Cascade-delete a single event: photos, audit/access logs, queued emails,
// the event row itself (in one transaction), then the on-disk folder /
// archive zip / hero logo (best-effort — file failures don't unwind the DB
// changes since the source of truth is the database). Used by both the
// per-event DELETE /:id route and the bulk-delete route to avoid drift.
//
// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the
// bulk-delete loop can report it as a per-id failure without aborting the
// whole batch. Any other error propagates and is the caller's problem.
async function deleteEventCascade(eventId, adminContext) {
const event = await db('events').where('id', eventId).first();
if (!event) {
@@ -678,40 +295,4 @@ async function deleteEventCascade(eventId, adminContext) {
return { id: event.id, name: event.event_name };
}
// ---------------------------------------------------------------------------
// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live
// events that auto-picks-up new uploads (migration 138). Mirrors the
// client-access second-token pattern: the link is minted on demand, rotatable
// and disable-able, independent of the gallery password / share link.
// ---------------------------------------------------------------------------
// Allowed slide transition styles (kept in sync with the SlideshowPage).
// dipwhite/dipblack = fade through highlights / lowlights between images.
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
// Allowed per-slide color filters.
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
// Allowed slideshow play orders (#202). 'chronological' = upload order,
// 'random' = client-side shuffle.
const SLIDESHOW_ORDERS = ['chronological', 'random'];
module.exports = {
RECOVERABLE_PASSWORD_COLUMNS,
validateHeroImageAnchor,
getStoragePath,
getEventFieldRequirements,
readBooleanSetting,
decodeSettingValue,
getDownloadProtectionDefaults,
getImageSecurityDefaults,
resolveImageSecurityColumns,
getBrandingDefaults,
getCustomerNameFromPayload,
getCustomerEmailFromPayload,
getCustomerPhoneFromPayload,
isPhoneFieldEnabled,
mapEventForApi,
hasCustomerContactColumns,
deleteEventCascade,
SLIDESHOW_ORDERS,
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
};
module.exports = { ...settings, deleteEventCascade };
+31 -100
View File
@@ -1,6 +1,8 @@
const { isGalleryAvailable } = require('../utils/galleryLifecycle');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
@@ -420,7 +422,7 @@ router.post('/gallery/verify', [
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
if (!isGalleryAvailable(event)) {
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
await bcrypt.compare(password || '', DUMMY_BCRYPT_HASH);
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
@@ -496,6 +498,9 @@ router.post('/gallery/verify', [
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
@@ -545,7 +550,7 @@ router.post('/gallery/:slug/client-login', [
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event || !event.client_access_enabled || !event.client_password_hash) {
if (!isGalleryAvailable(event) || !event.client_access_enabled || !event.client_password_hash) {
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid credentials' });
}
@@ -570,6 +575,9 @@ router.post('/gallery/:slug/client-login', [
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
accessLevel: 'client',
ip: ipAddress,
loginTime: Date.now()
@@ -631,14 +639,14 @@ router.post('/gallery/share-login', [
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
if (!isGalleryAvailable(event)) {
const resolved = await resolveShareIdentifier(slug);
if (resolved?.event) {
event = resolved.event;
}
}
if (!event) {
if (!isGalleryAvailable(event)) {
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
return res.status(404).json({ error: 'Gallery not found' });
}
@@ -666,6 +674,9 @@ router.post('/gallery/share-login', [
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
@@ -743,106 +754,26 @@ router.get('/session', async (req, res) => {
issuer: 'picpeak-auth'
});
// Check if token has been revoked (e.g. after logout)
const { isTokenRevoked } = require('../utils/tokenRevocation');
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ valid: false, error: 'Session has been invalidated' });
}
// The redirect loop reported on the v3.32.4-beta.0 release came
// from /auth/session reporting valid: true while the protected
// adminAuth / galleryAuth middleware rejected the same token for
// reasons /auth/session never checked: the admin user was
// deactivated, the admin's password had been changed since iat,
// or the gallery event was archived/deleted. Mirror those checks
// here so the session endpoint is always at least as strict as
// what the protected endpoints will enforce next.
// Full user payload for admin sessions — the SSO callback establishes
// the session via redirect (no JSON response the SPA could store), so
// session restoration must be able to hydrate the user object (#798).
const sessions = require('../services/sessionAccessService');
let adminUser = null;
if (decoded.type === 'admin') {
let admin = null;
try {
admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
.select(
'admin_users.id', 'admin_users.username', 'admin_users.email',
'admin_users.password_changed_at', 'admin_users.must_change_password',
'roles.name as role_name', 'roles.display_name as role_display_name'
)
.first();
} catch (lookupErr) {
// admin_users table not present (test fixture, fresh DB) — fall
// through and trust the token. Real deployments always have it.
admin = null;
// intentional swallow; if the table is missing we do not want
// to fail-closed during e.g. early bootstrap.
}
if (admin === null) {
// Lookup didn't run because the table is missing; skip the
// existence/password checks and treat the token as valid.
} else if (!admin) {
return res.json({ valid: false, error: 'Admin account no longer active' });
} else if (admin.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(admin.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
return res.json({ valid: false, error: 'Token invalid due to password change' });
}
}
// Mirror the session-timeout check that sessionTimeoutMiddleware
// enforces on every /api/admin endpoint. Without this, /auth/session
// returns valid:true for an idle/old-iat token that protected
// endpoints reject with 401 SESSION_TIMEOUT — the same redirect-loop
// shape as the issuer-claim and password-change asymmetries (issue
// #350 recurrence on v3.39.1-beta.0).
try {
const { isSessionExpired } = require('../middleware/sessionTimeout');
if (await isSessionExpired(token, decoded)) {
return res.json({ valid: false, error: 'Session expired' });
}
} catch (timeoutErr) {
// Helper lookup failed (test stub may not export it) — fall through
// and trust the token. Real deployments always have the middleware.
}
if (admin) {
adminUser = {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
};
const admin = await sessions.admin(decoded, { includeProfile: true });
const { isSessionExpired } = require('../middleware/sessionTimeout');
if (await isSessionExpired(token, decoded)) {
return res.json({ valid: false, error: 'Session expired' });
}
adminUser = {
id: admin.id, username: admin.username, email: admin.email,
mustChangePassword: !!admin.must_change_password,
role: admin.role_name ? { name: admin.role_name, displayName: admin.role_display_name } : null,
};
} else if (decoded.type === 'gallery') {
try {
const event = await db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
})
.first();
if (!event) {
return res.json({ valid: false, error: 'Gallery no longer available' });
}
if (event.expires_at && new Date(event.expires_at) < new Date()) {
return res.json({ valid: false, error: 'Gallery has expired' });
}
} catch (galleryLookupErr) {
// events table missing in this context — same fallback as
// admin path; trust the token rather than fail-closed.
}
const access = require('../services/galleryAccessService');
const event = await db('events').where({ id: decoded.eventId }).first();
if (!event) return res.json({ valid: false, error: 'Gallery no longer available' });
await access.authorize(event, access.grant(event, 'gallery', decoded));
} else {
return res.status(403).json({ valid: false, error: 'Invalid token type' });
}
// Calculate remaining time
+9 -3
View File
@@ -1,3 +1,4 @@
const { isGalleryAvailable, isGalleryExpired } = require('../utils/galleryLifecycle');
/**
* Customer dashboard routes
*
@@ -14,6 +15,7 @@
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { body, param, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { getBcryptRounds, MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
@@ -165,10 +167,12 @@ router.get('/events/:slug/access-token', [
if (event.is_archived) {
return res.status(410).json({ error: 'This gallery has been archived' });
}
if (event.expires_at && new Date(event.expires_at) < new Date()) {
if (isGalleryExpired(event)) {
return res.status(410).json({ error: 'This gallery has expired' });
}
if (!isGalleryAvailable(event)) return res.status(404).json({ error: 'Event not found' });
const hasAccess = await customerAccountsService.customerHasAccessToEvent(
req.customer.id,
event.id
@@ -189,10 +193,12 @@ router.get('/events/:slug/access-token', [
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
ip: ipAddress,
loginTime: Date.now(),
// Optional bookkeeping claim — surfaces the originating customer in
// logs when the token is later used. Doesn't affect authorization.
// Rechecked on each gallery/media request, including account status.
via: 'customer',
customerId: req.customer.id,
}, process.env.JWT_SECRET, {
File diff suppressed because it is too large Load Diff
+945
View File
@@ -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;
+639
View File
@@ -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;
+249
View File
@@ -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;
+192
View File
@@ -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;
+293
View File
@@ -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;
+44
View File
@@ -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;
+41
View File
@@ -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;
+211
View File
@@ -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;
+19 -13
View File
@@ -6,6 +6,7 @@ const { verifyGalleryAccess } = require('../middleware/gallery');
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
const watermarkService = require('../services/watermarkService');
const secureImageService = require('../services/secureImageService');
const galleryAccessService = require('../services/galleryAccessService');
const { getStorage } = require('../services/storage');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver');
const { withLocalCopy } = require('../services/imageProcessor');
@@ -19,16 +20,13 @@ const router = express.Router();
/**
* Generate a signed URL token for image access
*/
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false) {
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false, galleryAccess) {
const secret = process.env.JWT_SECRET;
const expires = Date.now() + (expiresIn * 1000);
// Third segment (#838): whether the minting context bypasses reveal mode
// (slideshow/client/admin). Fourth segment: whether the minter was a
// PIN-client, allowing the serve route to still deliver a photo that was
// hidden AFTER minting (TOCTOU) — a guest's token carries 0, so it stops
// working the moment the photo is hidden. Old shorter tokens verify
// unchanged and read both flags as no-bypass.
const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}:${clientBypass ? 1 : 0}`;
// Bind the URL to its issuing access grant. Old tokens without a grant
// must be refreshed: they cannot prove session revocation or ownership.
const grant = Buffer.from(JSON.stringify(galleryAccess)).toString('base64url');
const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}:${clientBypass ? 1 : 0}:${grant}`;
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
return `${Buffer.from(data).toString('base64')}.${signature}`;
}
@@ -41,7 +39,7 @@ function verifyImageToken(token) {
const secret = process.env.JWT_SECRET;
const [data, signature] = token.split('.');
const decoded = Buffer.from(data, 'base64').toString();
const [photoId, expires, bypassFlag, clientFlag] = decoded.split(':');
const [photoId, expires, bypassFlag, clientFlag, grant] = decoded.split(':');
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
@@ -50,7 +48,7 @@ function verifyImageToken(token) {
}
// Check expiration
if (Date.now() > parseInt(expires)) {
if (!Number.isFinite(Number(expires)) || Date.now() >= Number(expires) || !grant) {
return null;
}
@@ -59,6 +57,7 @@ function verifyImageToken(token) {
expires: parseInt(expires),
revealBypass: bypassFlag === '1',
clientBypass: clientFlag === '1',
galleryAccess: JSON.parse(Buffer.from(grant, 'base64url').toString()),
};
} catch (error) {
return null;
@@ -170,6 +169,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery
res.send(finalImage);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving protected image:', error);
res.status(500).json({ error: 'Failed to serve image' });
}
@@ -207,6 +207,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
// Generate secure token. clientBypass lets a client's token keep serving
// a photo hidden after minting; a guest's stops at the serve route.
const token = secureImageService.generateSecureToken(photoId, req.sessionID || 'anonymous', {
galleryAccess: req.galleryAccess,
expiresIn,
maxUses: protectionLevel === 'maximum' ? 1 : 3,
clientFingerprint,
@@ -222,6 +223,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
});
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error generating secure token:', error);
res.status(500).json({ error: 'Failed to generate token' });
}
@@ -262,7 +264,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
// Generate signed token. The client-bypass flag lets a PIN-client's
// token keep serving a photo hidden after minting; a guest's token
// (clientBypass=0) stops the moment the photo is hidden.
const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel));
const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel), req.galleryAccess);
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
res.json({
@@ -271,6 +273,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
});
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error generating signed URL:', error);
res.status(500).json({ error: 'Failed to generate URL' });
}
@@ -299,6 +302,8 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
return res.status(404).json({ error: 'Event not found' });
}
await galleryAccessService.authorize(event, tokenData.galleryAccess);
// Reveal mode (#838): a signed URL minted before a re-hide must not keep
// serving hidden photos; tokens minted by bypass contexts carry the flag.
if (isGalleryHidden(event) && !tokenData.revealBypass) {
@@ -338,7 +343,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Length': imageBuffer.length,
'Cache-Control': 'private, max-age=3600',
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff'
});
@@ -346,9 +351,10 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
res.send(imageBuffer);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving signed image:', error);
res.status(500).json({ error: 'Failed to serve image' });
}
});
module.exports = router;
module.exports = router;
+12
View File
@@ -4,6 +4,7 @@ const { db } = require('../database/db');
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
const secureImageService = require('../services/secureImageService');
const galleryAccessService = require('../services/galleryAccessService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
@@ -58,6 +59,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
// Generate secure token with appropriate settings
const tokenOptions = {
galleryAccess: req.galleryAccess,
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
maxUses: accessType === 'download' ? 1 : 3,
clientFingerprint,
@@ -96,6 +98,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
});
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error generating secure token', {
error: error.message,
photoId: req.body.photoId,
@@ -151,6 +154,10 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Gallery not found' });
}
// Revalidate the issuing session, ownership and gallery lifecycle at
// every use, including capabilities minted before logout or restore.
await galleryAccessService.authorize(event, tokenValidation.data?.galleryAccess);
// Bind the token to the gallery + photo it was minted for
// (GHSA-g94x-8vv8-3c9f). This route serves via <img src> with the
// token in the URL, so it can't require verifyGalleryAccess like the
@@ -248,6 +255,7 @@ router.get('/:slug/secure/:photoId/:token',
res.send(processedImage);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving secure image', {
error: error.message,
photoId,
@@ -292,6 +300,8 @@ router.get('/:slug/secure-download/:photoId/:token',
return res.status(403).json({ error: 'Invalid or expired token' });
}
await galleryAccessService.authorize(req.event, tokenValidation.data?.galleryAccess);
// Bind the token to the photo it was minted for (GHSA-crxv) — the
// /secure serve route does this, but secure-download did not, so a
// token minted for photo A could download photo B (incl. a hidden one).
@@ -386,6 +396,7 @@ router.get('/:slug/secure-download/:photoId/:token',
res.send(fileBuffer);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving secure download', {
error: error.message,
photoId: req.params.photoId
@@ -414,6 +425,7 @@ router.get('/security/stats', adminAuth, requirePermission('settings.view'), asy
res.json(stats);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error getting security stats', { error: error.message });
res.status(500).json({ error: 'Failed to get security stats' });
}
@@ -1,283 +1,74 @@
/**
* Regression tests for issue #550.
*
* Two related bugs in POST /v1/events:
* 1. color_theme was not accepted on the request body and never written
* to the events row. Editing such an event later in the admin UI
* snapped the theme picker to GALLERY_THEME_PRESETS.default and
* saving overwrote whatever theme was inherited visually.
* 2. event_feedback_settings row was never created, so the gallery UI
* read it as "feedback off" regardless of the global
* event_default_feedback_enabled toggle (#520).
*
* Test pattern mirrors events.category.test.js queue up db() chains
* with db.__setImplementations() in the exact order the handler invokes
* them, then assert against the captured payloads.
*/
/** Persisted contracts, not a mock tied to the number/order of Knex calls. */
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../../../../__tests__/integration/helpers/crmDb');
const request = require('supertest');
const express = require('express');
const buildChain = ({ firstResult, insertResult, returningResult, selectResult } = {}) => {
const chain = {
where: jest.fn().mockReturnThis(),
whereIn: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
// `select` resolves to an array so `await db(...).whereIn(...).select(...)`
// gives an iterable result (used by the branding-defaults probe added in
// #592 follow-up). Tests that don't need it leave selectResult undefined
// and get `[]`, which is a safe no-op for any caller that iterates.
select: jest.fn().mockResolvedValue(selectResult ?? []),
first: jest.fn().mockResolvedValue(firstResult),
insert: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]),
};
return chain;
};
jest.mock('../../../database/db', () => {
const dbMock = jest.fn();
dbMock.raw = jest.fn();
dbMock.__setImplementations = (...chains) => {
dbMock.mockReset();
chains.forEach((chain) => {
dbMock.mockImplementationOnce(() => chain);
});
};
return {
db: dbMock,
logActivity: jest.fn().mockResolvedValue(undefined),
};
let db, cleanup, app, adminId, adminToken, apiToken;
const base = { event_type: 'wedding', event_name: 'Creation parity', event_date: '2030-06-15',
customer_name: 'Ada', customer_email: '[email protected]', admin_email: '[email protected]',
require_password: false, is_draft: false, expires_at: '2030-07-15T00:00:00.000Z' };
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb()); ({ adminId } = await seedMinimal(db)); await assignAdminRole(db, adminId);
adminToken = mintAdminToken(adminId);
const generated = require('../../../middleware/apiTokenAuth').generateApiToken(); apiToken = generated.plaintext;
await db('api_tokens').insert({ name: 'parity', hashed_token: generated.hashed, scopes: 'admin', created_by: adminId });
app = express(); app.use(express.json());
app.use('/admin', require('../../adminEvents'));
app.use('/v1', require('../events'));
}, 120000);
afterAll(async () => { await require('../../../services/serviceShutdown').stopServices(); await cleanup(); });
async function create(source, extra) {
const input = { ...base, ...extra };
if (source === 'legacy') return require('../../../services/eventService').createEvent(input, { actor: { id: adminId } });
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send(input);
expect(response.status).toBe(source === 'admin' ? 200 : 201);
return response.body;
}
it.each(['admin', 'v1', 'legacy'])('%s stores theme, owner, dates and feedback defaults through one use case', async source => {
const theme = JSON.stringify({ primaryColor: '#ff0066' });
const created = await create(source, { color_theme: theme, feedback_enabled: true });
const row = await db('events').where({ id: created.id }).first();
expect(row).toMatchObject({ color_theme: theme, created_by: adminId, event_name: base.event_name, customer_email: base.customer_email });
expect(require('../../../utils/dateNormalize').toIso(row.expires_at)).toBe(base.expires_at);
expect([false, 0]).toContain(row.require_password);
expect(row.updated_at).toBeTruthy(); expect(row.share_token).toBeTruthy(); expect(row.password_hash).toBeTruthy();
const feedback = await db('event_feedback_settings').where({ event_id: row.id }).first();
for (const key of ['feedback_enabled','allow_ratings','allow_likes','allow_comments','allow_favorites','allow_reactions','moderate_comments','show_feedback_to_guests']) expect([true, 1]).toContain(feedback[key]);
expect([false, 0]).toContain(feedback.allow_color_labels); expect(feedback.keybind_mode).toBe('colors');
});
// RBAC is enforced on these routes since GHSA-9697 (requirePermission), but
// this suite mocks the database, so a real permission lookup would 500. These
// tests cover route logic, not authorization — the intersection of token
// scopes and role permissions is pinned in __tests__/routes/v1EventOwnership.
jest.mock('../../../middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
userHasAnyPermission: async () => true,
userHasAllPermissions: async () => true,
}));
jest.mock('../../../middleware/apiTokenAuth', () => ({
apiTokenAuth: (req, _res, next) => {
req.apiToken = { id: 1, admin_id: 1, scopes: ['admin'] };
req.admin = { id: 1, username: 'token-admin' };
next();
},
requireApiScope: () => (_req, _res, next) => next(),
}));
// bcrypt.hash is awaited twice per request (real path + dummy path).
// Stub it to a constant so tests don't burn CPU on bcrypt rounds.
jest.mock('bcrypt', () => ({
hash: jest.fn().mockResolvedValue('$2b$10$mocked-hash'),
}));
jest.mock('../../../services/shareLinkService', () => ({
buildShareLinkVariants: jest.fn().mockResolvedValue({
shareUrl: 'https://example.test/gallery/some-slug?t=abc',
shareLinkToStore: '/gallery/some-slug?t=abc',
}),
}));
// Webhook fire is in a try/catch; stub to silence the predictable
// failure log so test output stays clean.
jest.mock('../../../services/webhookService', () => ({
fire: jest.fn().mockResolvedValue(undefined),
buildEventSubject: jest.fn().mockReturnValue({}),
}));
// event_type is validated against the live event_types catalog (#800) —
// that lookup would consume the first queued db() chain and shift the
// call sequence these tests pin. Stub it valid; the invalid path has its
// own test below.
jest.mock('../../../services/eventTypeService', () => ({
isValidEventType: jest.fn().mockResolvedValue(true),
}));
const { db } = require('../../../database/db');
const { isValidEventType } = require('../../../services/eventTypeService');
const eventsRouter = require('../events');
const buildApp = () => {
const app = express();
app.use(express.json());
app.use('/', eventsRouter);
return app;
};
const BASE_BODY = {
event_name: 'Issue 550 Wedding',
event_type: 'wedding',
event_date: '2026-06-15',
require_password: false,
};
// db() call sequence for BASE_BODY (no feedback / devtools provided,
// require_password supplied so its probe is skipped, no customer_phone,
// no slug collision):
// 1. app_settings.where('event_default_feedback_enabled').first() (#550)
// 2. app_settings.where('enable_devtools_protection').first() (#592)
// 3. app_settings.whereIn([branding_logo_display_hero,...]).select(...) (#592 follow-up)
// Then slug probe, events insert, optional feedback insert.
const baseSettingsChains = () => [
buildChain({ firstResult: null }), // feedback default
buildChain({ firstResult: null }), // devtools default
buildChain({ selectResult: [] }), // image-security whereIn → empty rows (#1296)
buildChain({ selectResult: [] }), // branding whereIn → empty rows
];
describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('persists color_theme to the events row when provided', async () => {
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 42 }] });
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, color_theme: 'default' })
.expect(201);
const insertedRow = insertChain.insert.mock.calls[0][0];
expect(insertedRow).toMatchObject({
event_name: 'Issue 550 Wedding',
color_theme: 'default',
});
});
it('accepts a JSON-encoded theme string and persists it verbatim', async () => {
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 43 }] });
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
const customTheme = JSON.stringify({ primaryColor: '#ff0066' });
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, color_theme: customTheme })
.expect(201);
const insertedRow = insertChain.insert.mock.calls[0][0];
expect(insertedRow.color_theme).toBe(customTheme);
});
it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => {
// feedback_enabled provided → feedback probe SKIPPED. Sequence:
// 1. devtools probe
// 2. image-security probe (whereIn → select, #1296)
// 3. branding probe (whereIn → select)
// 4. slug probe
// 5. events insert
// 6. feedback sub-toggle defaults probe (whereIn → select, #1044)
// 7. event_feedback_settings insert
const devtoolsChain = buildChain({ firstResult: null });
const imageSecurityChain = buildChain({ selectResult: [] });
const brandingChain = buildChain({ selectResult: [] });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 50 }] });
const feedbackDefaultsChain = buildChain({ selectResult: [] });
const feedbackInsertChain = buildChain();
db.__setImplementations(
devtoolsChain, imageSecurityChain, brandingChain, slugChain, insertChain,
feedbackDefaultsChain, feedbackInsertChain,
);
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, feedback_enabled: true })
.expect(201);
expect(db).toHaveBeenNthCalledWith(7, 'event_feedback_settings');
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
expect(feedbackRow).toMatchObject({ event_id: 50 });
// formatBoolean() returns 1/0 on SQLite and true/false on PG. Either
// way the value must be truthy/falsy in the right places — assert by
// coercion so the test stays driver-agnostic.
expect(Boolean(feedbackRow.feedback_enabled)).toBe(true);
expect(Boolean(feedbackRow.allow_ratings)).toBe(true);
expect(Boolean(feedbackRow.allow_likes)).toBe(true);
expect(Boolean(feedbackRow.allow_comments)).toBe(true);
expect(Boolean(feedbackRow.allow_favorites)).toBe(true);
// #1044: this insert used to omit allow_reactions entirely, so v1-created
// events only got reactions by accident of the column default.
expect(Boolean(feedbackRow.allow_reactions)).toBe(true);
// Colour labels are opt-in, so they stay off until the global is flipped.
expect(Boolean(feedbackRow.allow_color_labels)).toBe(false);
expect(feedbackRow.keybind_mode).toBe('colors');
expect(Boolean(feedbackRow.require_name_email)).toBe(false);
expect(Boolean(feedbackRow.moderate_comments)).toBe(true);
expect(Boolean(feedbackRow.show_feedback_to_guests)).toBe(true);
});
it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => {
// Feedback probe returns serialized "true" → fallback kicks in and
// the feedback insert runs. Sequence: feedback probe, devtools probe,
// image-security probe (#1296), branding probe, slug, insert, sub-toggle
// defaults probe (#1044), feedback insert (8 calls total).
const feedbackProbe = buildChain({
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' },
});
const devtoolsChain = buildChain({ firstResult: null });
const imageSecurityChain = buildChain({ selectResult: [] });
const brandingChain = buildChain({ selectResult: [] });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 51 }] });
const feedbackDefaultsChain = buildChain({ selectResult: [] });
const feedbackInsertChain = buildChain();
db.__setImplementations(
feedbackProbe, devtoolsChain, imageSecurityChain, brandingChain, slugChain,
insertChain, feedbackDefaultsChain, feedbackInsertChain,
);
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
expect(db).toHaveBeenNthCalledWith(8, 'event_feedback_settings');
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
});
it('does NOT create a feedback row when global setting is unset and body omits feedback_enabled', async () => {
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 52 }] });
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
// 6 db() calls: feedback + devtools + image-security + branding probes,
// slug, insert. event_feedback_settings is never touched.
expect(db).toHaveBeenCalledTimes(6);
expect(db).not.toHaveBeenCalledWith('event_feedback_settings');
});
it('rejects non-boolean feedback_enabled with 400', async () => {
// Validators run before any db() call, so no chain queueing needed.
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
.expect(400);
});
it('rejects an event_type unknown to the catalog with 400 (#800)', async () => {
isValidEventType.mockResolvedValueOnce(false);
const res = await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, event_type: 'nope' })
.expect(400);
expect(isValidEventType).toHaveBeenCalledWith('nope');
expect(JSON.stringify(res.body.errors)).toContain('event_type');
expect(db).not.toHaveBeenCalled();
});
it('inherits global feedback and preserves explicit overrides for every entry point', async () => {
await db('app_settings').insert({ setting_key: 'event_default_feedback_enabled', setting_value: 'true', setting_type: 'boolean' })
.onConflict('setting_key').merge({ setting_value: 'true' });
for (const source of ['admin', 'v1', 'legacy']) {
const inherited = await create(source, {});
expect(await db('event_feedback_settings').where({ event_id: inherited.id }).first()).toBeTruthy();
const override = await create(source, { feedback_enabled: false });
expect(await db('event_feedback_settings').where({ event_id: override.id }).first()).toBeUndefined();
}
});
it('queues a publication email only for published galleries', async () => {
const draft = await create('admin', { is_draft: true });
expect(await db('email_queue').where({ event_id: draft.id })).toHaveLength(0);
const published = await create('v1', {});
expect(await db('email_queue').where({ event_id: published.id, email_type: 'gallery_created' })).toHaveLength(1);
});
it('rejects a required password that is missing with 400 on both routes', async () => {
for (const source of ['admin', 'v1']) {
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send({ ...base, require_password: true });
expect(response.status).toBe(400);
}
});
it('keeps accepting "0"/"1" string booleans on the v1 surface', async () => {
const created = await create('v1', { require_password: '0', feedback_enabled: '1' });
const row = await db('events').where({ id: created.id }).first();
expect([false, 0]).toContain(row.require_password);
expect(await db('event_feedback_settings').where({ event_id: row.id }).first()).toBeTruthy();
});
it.each([{ feedback_enabled: 'maybe' }, { event_type: 'unknown' }])('rejects invalid creation data before persistence: %j', async extra => {
for (const source of ['admin', 'v1']) {
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send({ ...base, ...extra });
expect(response.status).toBe(400);
}
});
+7 -259
View File
@@ -30,15 +30,13 @@ const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ow
// requirePermission gates supply the missing half; they key on req.admin.id,
// which apiTokenAuth populates.
const { requirePermission } = require('../../middleware/permissions');
const { resolveEventFeedbackDefaults } = require('../../services/feedbackDefaults');
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { generateThumbnail } = require('../../services/imageProcessor');
const logger = require('../../utils/logger');
const { slugify } = require('../../utils/slug');
const { formatBoolean } = require('../../utils/dbCompat');
const { parseBooleanInput } = require('../../utils/parsers');
const { getImageSecurityDefaults, resolveImageSecurityColumns, decodeSettingValue } = require('../adminEvents/helpers');
const { isValidEventType } = require('../../services/eventTypeService');
const { replacePhoto } = require('../../services/photoReplacementService');
const { getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings');
@@ -195,262 +193,12 @@ router.post(
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const {
event_name, event_type, event_date,
customer_name = null, customer_email = null, customer_phone = null,
admin_email = null,
require_password: requirePasswordInput,
password,
expires_at = null,
color_theme = null,
feedback_enabled: feedbackEnabledInput,
enable_devtools_protection: devtoolsInput,
hero_logo_visible: heroLogoVisibleInput,
hero_logo_size: heroLogoSizeInput,
hero_logo_position: heroLogoPositionInput
} = req.body;
// Issue #550 — mirror the admin POST path so API-created events
// pick up the global "Enable Guest Feedback by default" toggle
// (event_default_feedback_enabled). Without this, the UI reads
// a missing event_feedback_settings row as "feedback off"
// regardless of the admin's chosen default.
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'event_default_feedback_enabled').first();
if (setting) {
try {
const parsed = JSON.parse(setting.setting_value);
if (typeof parsed === 'boolean') feedbackEnabledFallback = parsed;
} catch { /* keep false */ }
}
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
// Issue #592 — same shape as the feedback fallback above. The
// events table column default is `true`, so without this an admin
// who disabled devtools detection globally still gets it ON for
// every API-created gallery. Mirrors adminEvents.js behaviour.
let devtoolsFallback = true;
if (devtoolsInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'enable_devtools_protection').first();
if (setting) {
// Shared decoder: a legacy row can carry several layers of JSON
// quoting, and a single parse would leave the string 'false' here,
// reject it, and quietly enable protection the operator disabled.
const parsed = decodeSettingValue(setting.setting_value);
if (typeof parsed === 'boolean') devtoolsFallback = parsed;
}
}
const enable_devtools_protection = parseBooleanInput(devtoolsInput, devtoolsFallback);
// #1296 — same shape again, for the four Image Security settings that
// were stored and applied nowhere. Shared with the admin create route
// so a gallery's security level does not depend on which endpoint made
// it; #592 above is the bug this would otherwise repeat.
const imageSecurityColumns = resolveImageSecurityColumns(
req.body,
await getImageSecurityDefaults(),
);
// Same shape as the feedback / devtools fallbacks: honour the global
// event_default_require_password toggle (#317). Without this an admin
// who disabled "require password by default" globally still got
// password-required galleries through the API.
let requirePasswordFallback = true;
if (requirePasswordInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'event_default_require_password').first();
if (setting) {
try {
const parsed = JSON.parse(setting.setting_value);
if (typeof parsed === 'boolean') requirePasswordFallback = parsed;
} catch { /* keep true */ }
}
}
const require_password = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
// Branding inheritance (Feature 7) — mirror adminEvents.js
// getBrandingDefaults so API-created events inherit the global
// hero logo visibility + size. hero_logo_position is intentionally
// NOT settings-backed (see migration 084 / #357 — branding_logo_position
// is the *header bar*, a different concept than the hero block).
let heroLogoVisibleFallback = true;
let heroLogoSizeFallback = 'medium';
const brandingRows = await db('app_settings')
.whereIn('setting_key', ['branding_logo_display_hero', 'branding_logo_size'])
.select('setting_key', 'setting_value');
for (const row of brandingRows) {
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
if (row.setting_key === 'branding_logo_display_hero') heroLogoVisibleFallback = value !== false;
if (row.setting_key === 'branding_logo_size' && value) heroLogoSizeFallback = value;
}
const hero_logo_visible = heroLogoVisibleInput !== undefined ? heroLogoVisibleInput : heroLogoVisibleFallback;
const hero_logo_size = heroLogoSizeInput || heroLogoSizeFallback;
const hero_logo_position = heroLogoPositionInput || 'top';
if (require_password && (!password || password.length < 6)) {
return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' });
}
// Honour global phone-field toggle (#322).
let persistPhone = null;
if (customer_phone) {
const setting = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
const enabled = setting ? JSON.parse(setting.setting_value) === true : false;
persistPhone = enabled ? customer_phone : null;
}
// Generate unique slug.
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date || crypto.randomBytes(3).toString('hex')}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) slug = `${baseSlug}-${counter++}`;
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// password_hash is NOT NULL; use a random placeholder when no
// password is required so the column constraint is satisfied.
const bcrypt = require('bcrypt');
const passwordHash = require_password
? await bcrypt.hash(password, 10)
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10);
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
event_date: event_date || null,
host_name: customer_name,
host_email: customer_email,
admin_email,
password_hash: passwordHash,
// #1271 — recoverable copy rides with the hash; only when there is one
...(require_password && password ? await galleryPasswordColumns({ password }) : {}),
require_password,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at || null,
created_at: new Date().toISOString(),
created_by: req.admin.id,
is_draft: false,
// Issue #550 — without this, editing an API-created event in the
// admin UI snaps the theme picker to GALLERY_THEME_PRESETS.default
// and saving overwrites whatever theme was inherited visually.
color_theme,
// Issue #592 — write the resolved devtools setting (input value
// or global fallback) so the column default doesn't shadow it.
enable_devtools_protection: formatBoolean(enable_devtools_protection),
// Request value, else the global default, else the column default.
...imageSecurityColumns,
// Branding inheritance — resolved value from body or app_settings.
hero_logo_visible: formatBoolean(hero_logo_visible),
hero_logo_size,
hero_logo_position,
...(customer_name ? { customer_name } : {}),
...(customer_email ? { customer_email } : {}),
...(persistPhone ? { customer_phone: persistPhone } : {})
}).returning('id');
const id = insertResult[0]?.id || insertResult[0];
if (require_password && password) await dropCopiesIfStorageOff(id);
// Issue #550 — mirror adminEvents.js: create event_feedback_settings
// row when feedback is enabled, so the gallery actually shows feedback
// UI. The sub-flags come from the shared global defaults (#1044) rather
// than a hard-coded list, which is how this path silently shipped
// without allow_reactions for two releases.
if (feedback_enabled) {
const feedbackDefaults = await resolveEventFeedbackDefaults();
await db('event_feedback_settings').insert({
event_id: id,
feedback_enabled: formatBoolean(true),
allow_ratings: formatBoolean(feedbackDefaults.allow_ratings),
allow_likes: formatBoolean(feedbackDefaults.allow_likes),
allow_comments: formatBoolean(feedbackDefaults.allow_comments),
allow_favorites: formatBoolean(feedbackDefaults.allow_favorites),
allow_reactions: formatBoolean(feedbackDefaults.allow_reactions),
allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels),
keybind_mode: feedbackDefaults.keybind_mode,
require_name_email: formatBoolean(false),
moderate_comments: formatBoolean(true),
show_feedback_to_guests: formatBoolean(true),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
await logActivity('event_created', { via: 'api_v1', event_type }, id, {
type: 'admin', id: req.admin.id, name: req.admin.username
const created = await require('../../services/eventCreationService').createEvent(req.body, {
actor: req.admin, source: 'v1',
});
// Customer notifications (#647 follow-up). v1 events go live in the
// same call (not draft-aware), so the gallery_created email + WhatsApp
// fire here — mirroring the adminEvents.js create-and-publish path.
// Both are best-effort: a queue failure must not block the API response.
const expiryIso = expires_at ? new Date(expires_at).toISOString() : null;
if (customer_email) {
try {
const { queueEmail } = require('../../services/emailProcessor');
await queueEmail(id, customer_email, 'gallery_created', {
customer_name: customer_name || '',
customer_email,
host_name: customer_name || '',
event_name,
event_date: event_date || null,
gallery_link: shareUrl,
gallery_password: require_password ? password : 'No password required',
expiry_date: expiryIso,
welcome_message: ''
});
} catch (emailError) {
logger.warn('v1 POST /events: failed to queue gallery_created email', { error: emailError.message });
}
}
if (persistPhone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
await queueWhatsapp(id, persistPhone, 'gallery_created', {
customer_name: customer_name || '',
event_name,
gallery_link: shareUrl,
gallery_password: require_password ? password : '',
expiry_date: expiryIso,
language: null,
});
}
} catch (waError) {
logger.warn('v1 POST /events: failed to queue WhatsApp notification', { error: waError.message });
}
}
// Webhook lifecycle (#327). v1 events are not draft-aware, so they're
// both created AND published in the same call. Canonical event
// subject (#341) — customer contact + share_token always included.
try {
const webhookService = require('../../services/webhookService');
const eventSubject = webhookService.buildEventSubject({
id,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name,
customer_email,
customer_phone,
});
await webhookService.fire('event.created', { event: eventSubject });
await webhookService.fire('event.published', { event: eventSubject });
} catch (e) { /* non-fatal */ }
res.status(201).json({ id, slug, share_url: shareUrl, share_token: shareToken });
res.status(201).json({ id: created.id, slug: created.slug, share_url: created.share_link, share_token: created.share_token });
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json(error.responseBody || { error: error.message, code: error.code });
logger.error('v1 POST /events failed', { error: error.message, stack: error.stack });
res.status(500).json({ error: 'Failed to create event', detail: error.message });
}
+5 -6
View File
@@ -338,14 +338,13 @@ async function cleanupExpiredUploads() {
return expiredIds.length;
}
// Run cleanup every hour. unref so this module-level housekeeping timer
// never holds the process open on its own — in production the HTTP
// listener keeps the loop alive, and in Jest this exact handle kept the
// runner from exiting for every suite that requires adminPhotos (#908;
// it is why adminPhotos.reference sits on the CI ignore list).
setInterval(cleanupExpiredUploads, 60 * 60 * 1000).unref();
const cleanupTask = require('./scheduledTask').scheduledTask(cleanupExpiredUploads, {
interval: 60 * 60 * 1000
});
cleanupTask.start();
module.exports = {
stop: () => cleanupTask.stop(),
initializeUpload,
uploadChunk,
completeUpload,
@@ -12,21 +12,13 @@
* schedulers don't all wake at once.
*/
const cron = require('node-cron');
const { scheduledTask } = require('./scheduledTask');
const logger = require('../utils/logger');
const downloadJobService = require('./downloadJobService');
function startDownloadJobCleanup() {
// A restart leaves any in-flight build with no worker. Fail those rows once
// at startup so their owners get a clear error instead of polling forever.
downloadJobService.recoverOrphanedJobs().catch((err) =>
logger.error('Download job recovery failed', { error: err.message }));
cron.schedule('7,27,47 * * * *', async () => {
await runDownloadJobCleanup();
});
logger.info('Download job cleanup scheduler started');
}
const task = scheduledTask(runDownloadJobCleanup, { schedule: '7,27,47 * * * *' });
function startDownloadJobCleanup() { task.start(); }
const stopDownloadJobCleanup = () => task.stop();
async function runDownloadJobCleanup() {
try {
@@ -37,6 +29,7 @@ async function runDownloadJobCleanup() {
}
module.exports = {
stopDownloadJobCleanup,
startDownloadJobCleanup,
// exported for tests / manual invocation
runDownloadJobCleanup,
@@ -37,6 +37,13 @@ class DownloadZipService {
this.versions = new Map(); // eventId -> generation counter
}
async stop() {
for (const timer of this.debounceTimers.values()) clearTimeout(timer);
this.debounceTimers.clear();
await Promise.allSettled([...this.activeBuilds.values()].map(build => build.promise));
this.versions.clear();
}
/**
* Relative storage key for the cached zip.
*/
+4 -7
View File
@@ -532,11 +532,8 @@ async function pollOnce() {
}
/** Start the 1-minute poll loop (mirrors the outgoing queue cadence). */
function startIncomingMailPoller() {
const run = () => pollOnce().catch((e) => logger.error?.(`emailIntake: ${e.message}`));
setTimeout(run, 15000); // first run shortly after boot
setInterval(run, 60 * 1000);
logger.info?.('Incoming-mail poller started (every 60s when enabled)');
}
const mailPoller = require('./scheduledTask').scheduledTask(pollOnce, { interval: 60000, initialDelay: 15000 });
function startIncomingMailPoller() { mailPoller.start(); }
const stopIncomingMailPoller = () => mailPoller.stop();
module.exports = { pollOnce, startIncomingMailPoller, listFolders, testConnection, roundTripTest, _internal: { getImapConfig, isEnabled, saveAttachment } };
module.exports = { stopIncomingMailPoller, pollOnce, startIncomingMailPoller, listFolders, testConnection, roundTripTest, _internal: { getImapConfig, isEnabled, saveAttachment } };
+5 -31
View File
@@ -1514,39 +1514,13 @@ async function testEmailConnection() {
}
}
// Start email queue processor
let emailQueueInterval = null;
const emailTask = require('./scheduledTask').scheduledTask(processEmailQueue, { interval: 60000, initialDelay: 0 });
function startEmailQueueProcessor() {
logger.info('Email queue processor: Attempting to start...');
if (!emailQueueInterval) {
// Process immediately on start
processEmailQueue().catch(err => {
logger.error('Email queue processor: Initial processing failed:', err);
});
// Then process every minute
emailQueueInterval = setInterval(() => {
processEmailQueue().catch(err => {
logger.error('Email queue processor: Periodic processing failed:', err);
});
}, 60000);
processorStatus.started = true;
logger.info('Email queue processor started successfully');
} else {
logger.info('Email queue processor: Already running');
}
emailTask.start(); processorStatus.started = true;
}
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
@@ -25,6 +25,7 @@ const fs = require('fs').promises;
const axios = require('axios');
const logger = require('../utils/logger');
const { signPayload } = require('./webhookService');
const { pinnedRequestOptions } = require('../utils/pinnedRequest');
const { validateExternalUrlAsync } = require('../utils/networkValidation');
const SIGNATURE_HEADER = 'X-PicPeak-Signature';
@@ -167,6 +168,7 @@ async function send(mail) {
// Vetted before every send, not once at startup: DNS answers change, and the
// check is what stops an operator-supplied URL becoming a request to link
// local metadata or a service on the host network.
let connectionOptions = {};
if (!allowPrivateUrls) {
// https for anything leaving the machine. The HMAC proves who sent the
// body, not who can read it — and these bodies carry password-reset links
@@ -190,6 +192,7 @@ async function send(mail) {
+ 'private network (a container or LAN address).'
);
}
connectionOptions = pinnedRequestOptions(check);
}
const payload = {
@@ -218,6 +221,7 @@ async function send(mail) {
let response;
try {
response = await axios.post(url, rawBody, {
...connectionOptions,
headers: {
'Content-Type': 'application/json',
[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 };
+7 -163
View File
@@ -10,11 +10,11 @@ const crypto = require('crypto');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const { hasColumnCached } = require('../utils/schemaCache');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('./shareLinkService');
const { getBcryptRounds } = require('../utils/passwordValidation');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('./eventTypeService');
const { AppError } = require('../utils/errors');
@@ -154,167 +154,11 @@ const createEventFolders = async (slug) => {
* @param {Object} eventData - Event data
* @returns {Promise<Object>} - Created event
*/
const createEvent = async (eventData) => {
const {
event_type,
event_name,
event_date,
customer_name,
customer_email,
admin_email,
password,
require_password = true,
welcome_message,
color_theme,
expiration_days = 30,
// Feedback settings
feedback_enabled,
allow_ratings,
allow_likes,
allow_comments,
allow_favorites,
require_name_email,
moderate_comments,
show_feedback_to_guests,
// Upload settings
allow_user_uploads,
upload_category_id,
// Photo cap
photo_cap,
// Migration 137 — calendar time fields. Defaults to full-day when
// the caller (legacy create-event form) doesn't know about them.
event_time_start,
event_time_end,
is_full_day
} = eventData;
const requirePassword = parseBooleanInput(require_password, true);
const customerColumnsAvailable = await hasCustomerContactColumns();
// Validate + normalise the calendar time triple up front so we throw
// before bcrypt + folder creation if the payload is bad.
const timeTriple = normaliseEventTimeTriple({
event_time_start, event_time_end, is_full_day,
const createEvent = async (eventData, options = {}) => {
return require('./eventCreationService').createEvent(eventData, {
...options,
actor: options.actor || (eventData.created_by ? { id: eventData.created_by } : undefined),
});
// 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
};
};
/**
+403
View File
@@ -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,
};
+5 -9
View File
@@ -1,4 +1,4 @@
const cron = require('node-cron');
const { scheduledTask } = require('./scheduledTask');
const { db } = require('../database/db');
const { archiveEvent } = require('./archiveService');
const { queueEmail, getSupportEmail } = require('./emailProcessor');
@@ -6,14 +6,9 @@ const { buildShareLinkVariants } = require('./shareLinkService');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
function startExpirationChecker() {
// Check every hour for expired events and warnings
cron.schedule('0 * * * *', async () => {
await checkExpirations();
});
logger.info('Expiration checker started');
}
const task = scheduledTask(checkExpirations, { schedule: '0 * * * *' });
function startExpirationChecker() { task.start(); }
const stopExpirationChecker = () => task.stop();
async function checkExpirations() {
try {
@@ -248,6 +243,7 @@ async function handleExpiredEvent(event, { sendLegacyEmails = true } = {}) {
}
module.exports = {
stopExpirationChecker,
startExpirationChecker,
// Reused by the workflow notify_gallery_* actions so the engine path sends the
// exact same emails as the legacy hourly checker.
+19 -4
View File
@@ -28,7 +28,21 @@ const watcherConcurrency = Number.isFinite(configuredConcurrency)
: 2;
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() {
if (watcher) return watcher;
// Auto-import via filesystem watching only works with the local storage
// 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
@@ -39,7 +53,7 @@ function startFileWatcher() {
return null;
}
const watcher = chokidar.watch(WATCH_PATH(), {
watcher = chokidar.watch(WATCH_PATH(), {
ignored: /(^|[/\\])\../, // ignore dotfiles
persistent: true,
awaitWriteFinish: {
@@ -50,17 +64,18 @@ function startFileWatcher() {
watcher
.on('add', (filePath) => {
processLimit(() => processNewPhoto(filePath)).catch((error) => {
enqueue(() => processNewPhoto(filePath)).catch((error) => {
logger.error('Error processing new photo:', error);
});
})
.on('unlink', (filePath) => {
processLimit(() => removePhoto(filePath)).catch((error) => {
enqueue(() => removePhoto(filePath)).catch((error) => {
logger.error('Error removing photo:', error);
});
});
logger.info('File watcher started');
return watcher;
}
/**
@@ -222,4 +237,4 @@ async function removePhoto(filePath) {
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();
+30
View File
@@ -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 };
+37
View File
@@ -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 };
+567
View File
@@ -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.
*/
const cron = require('node-cron');
const { scheduledTask } = require('./scheduledTask');
const invoiceService = require('./invoiceService');
const eventReminderService = require('./eventReminderService');
const quoteService = require('./quoteService');
const logger = require('../utils/logger');
let task = null;
async function runTick() {
try {
@@ -71,31 +71,8 @@ async function runTick() {
}
}
function startInvoiceScheduler() {
if (task) {
logger.info('Invoice scheduler already running');
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');
}
}
const task = scheduledTask(runTick, { schedule: '11 * * * *', initialDelay: 0 });
function startInvoiceScheduler() { task.start(); return task; }
const stopInvoiceScheduler = () => task.stop();
module.exports = { startInvoiceScheduler, stopInvoiceScheduler };
+5 -5
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
const rateLimit = require('express-rate-limit');
const { MemoryStore } = require('express-rate-limit');
const jwt = require('jsonwebtoken');
@@ -250,19 +251,18 @@ async function createRateLimiter(store = new MemoryStore()) {
// Enhanced logging for production analysis
logger.warn('Rate limit exceeded', {
ip: clientIp,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
method: req.method,
authenticated: isAuthenticated(req),
tokenType: req.tokenType,
userAgent: req.headers['user-agent'],
referer: req.headers['referer'],
origin: req.headers['origin'],
timestamp: new Date().toISOString(),
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip']
},
requestUrl: req.originalUrl,
requestUrl: requestLogPath(req.originalUrl || req.path),
rateLimitInfo: {
limit: req.rateLimit?.limit,
current: req.rateLimit?.current,
@@ -318,7 +318,7 @@ async function createAuthRateLimiter(store = new MemoryStore()) {
// Enhanced logging for auth failures
logger.warn('Auth rate limit exceeded', {
ip: clientIp,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
method: req.method,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
@@ -326,7 +326,7 @@ async function createAuthRateLimiter(store = new MemoryStore()) {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip']
},
requestUrl: req.originalUrl,
requestUrl: requestLogPath(req.originalUrl || req.path),
authType: req.path.includes('admin') ? 'admin' : 'gallery',
rateLimitInfo: {
limit: req.rateLimit?.limit,
+6 -6
View File
@@ -10,7 +10,7 @@
* 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 { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
@@ -68,9 +68,9 @@ async function checkScheduledReveals() {
}
}
function startRevealScheduler() {
cron.schedule('* * * * *', checkScheduledReveals);
logger.info('Reveal scheduler started');
}
const task = scheduledTask(checkScheduledReveals, { schedule: '* * * * *' });
function startRevealScheduler() { task.start(); }
const stopRevealScheduler = () => task.stop();
module.exports = { startRevealScheduler, checkScheduledReveals };
module.exports = {
stopRevealScheduler, startRevealScheduler, checkScheduledReveals };
+11 -16
View File
@@ -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
// eligible for import. This is the eventual-consistency gate.
const previousSnapshot = new Map();
let intervalHandle = null;
let stopped = false;
async function tick() {
async function runTick() {
if (stopped) return;
const storage = getStorage();
if (storage.kind() !== 's3') return; // no-op for local fs
@@ -157,24 +157,19 @@ async function processEvent(event, storage) {
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() {
if (!ENABLED) return null;
if (intervalHandle) return intervalHandle;
if (!ENABLED) return;
stopped = false;
// Run once on startup so admins see import activity in logs without
// 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;
pollingTask.start();
}
function stopS3AutoImporter() {
async function stopS3AutoImporter() {
stopped = true;
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
}
await pollingTask.stop();
previousSnapshot.clear();
}
+29
View File
@@ -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 };
+34 -12
View File
@@ -9,6 +9,25 @@ class SecureImageService {
this.tokenCache = new Map();
this.sessionTokens = 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
// delivering a photo hidden AFTER minting (TOCTOU). A guest's token
// carries false, so it stops the moment the photo is hidden.
clientBypass = false
clientBypass = false,
galleryAccess = null
} = options;
if (!Number.isFinite(Number(expiresIn)) || Number(expiresIn) <= 0 || Number(expiresIn) > 3600) {
throw new (require('../utils/errors').ValidationError)('Invalid image token lifetime');
}
const tokenData = {
photoId: parseInt(photoId),
sessionId,
@@ -40,6 +64,7 @@ class SecureImageService {
protectionLevel,
revealBypass,
clientBypass,
galleryAccess,
createdAt: Date.now()
};
@@ -56,10 +81,8 @@ class SecureImageService {
// Cache token with metadata
this.tokenCache.set(token, tokenData);
// Set cleanup timer
setTimeout(() => {
this.tokenCache.delete(token);
}, expiresIn * 1000 + 60000); // Add 1 minute buffer
// One owned timer per service, not one live handle per issued token.
this.start();
return token;
}
@@ -217,9 +240,7 @@ class SecureImageService {
}
image = image.withMetadata({
exif: {
[sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}`
}
exif: { IFD0: { ImageDescription: `Protected:${fingerprint}` } }
});
return await image.toBuffer();
@@ -261,9 +282,7 @@ class SecureImageService {
// Embed fingerprint in metadata
image = image.withMetadata({
exif: {
[sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}`
}
exif: { IFD0: { ImageDescription: `Protected:${fingerprint}` } }
});
}
@@ -430,6 +449,9 @@ class SecureImageService {
cleanup() {
// Clear expired rate limit entries
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()) {
const recent = requests.filter(timestamp => timestamp > now - 60000);
if (recent.length === 0) {
@@ -441,4 +463,4 @@ class SecureImageService {
}
}
module.exports = new SecureImageService();
module.exports = new SecureImageService();
+28
View File
@@ -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.)
*/
const cron = require('node-cron');
const { scheduledTask } = require('./scheduledTask');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
@@ -26,13 +26,9 @@ const transferService = require('./transferService');
const DAY_MS = 24 * 60 * 60 * 1000;
function startTransferCleanup() {
// Hourly at :15 — staggered from the gallery expiration checker (:00).
cron.schedule('15 * * * *', async () => {
await runTransferCleanup();
});
logger.info('Transfer cleanup scheduler started');
}
const task = scheduledTask(runTransferCleanup, { schedule: '15 * * * *' });
function startTransferCleanup() { task.start(); }
const stopTransferCleanup = () => task.stop();
async function runTransferCleanup() {
try {
@@ -138,6 +134,7 @@ async function deleteRetiredTransfers() {
}
module.exports = {
stopTransferCleanup,
startTransferCleanup,
// exported for tests / manual invocation
runTransferCleanup,
+11 -14
View File
@@ -2,6 +2,7 @@ const axios = require('axios');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { signPayload, renderTemplate } = require('./webhookService');
const { pinnedRequestOptions } = require('../utils/pinnedRequest');
const { validateExternalUrlAsync } = require('../utils/networkValidation');
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)
];
let intervalHandle = null;
let stopped = false;
// Tracks deliveries currently being processed in this tick — guards
// 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
// resolves to an internal IP is rejected). Admin can opt out via
// WEBHOOK_ALLOW_PRIVATE_URLS=true for local-receiver dev runs.
let connectionOptions = {};
if (!allowPrivateUrls) {
const urlCheck = await validateExternalUrlAsync(webhook.url);
if (!urlCheck.valid) {
@@ -111,6 +113,7 @@ async function deliverOne(row) {
}
return;
}
connectionOptions = pinnedRequestOptions(urlCheck);
}
const envelopeBody = typeof row.payload === 'string' ? row.payload : JSON.stringify(row.payload);
@@ -139,6 +142,7 @@ async function deliverOne(row) {
let networkError;
try {
response = await axios.post(webhook.url, rawBody, {
...connectionOptions,
headers: {
'Content-Type': contentType,
[SIGNATURE_HEADER]: signature,
@@ -266,7 +270,7 @@ function stringifyBody(data) {
try { return JSON.stringify(data); } catch { return String(data); }
}
async function tick() {
async function runTick() {
if (stopped) return;
try {
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() {
if (intervalHandle) return; // idempotent
stopped = false;
intervalHandle = setInterval(tick, POLL_INTERVAL_MS);
logger.info(
`[webhookWorker] started — interval=${POLL_INTERVAL_MS}ms, concurrency=${CONCURRENCY}, ` +
`max_attempts=${MAX_ATTEMPTS}, allow_private=${allowPrivateUrls}`
);
pollingTask.start();
}
function stopWebhookDeliveryWorker() {
async function stopWebhookDeliveryWorker() {
stopped = true;
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
}
await pollingTask.stop();
}
module.exports = {
+4 -24
View File
@@ -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 MAX_RETRIES = 3;
let pollHandle = null;
/**
* Resolve a Meta template language code from whatever's in the message_data
@@ -301,29 +301,9 @@ async function processWhatsAppQueue() {
}
}
function startWhatsAppQueueProcessor() {
if (pollHandle) {
logger.info('WhatsApp queue processor already running — skipping start');
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');
}
}
const whatsappTask = require('./scheduledTask').scheduledTask(processWhatsAppQueue, { interval: POLL_INTERVAL_MS, initialDelay: 5000 });
function startWhatsAppQueueProcessor() { whatsappTask.start(); }
const stopWhatsAppQueueProcessor = () => whatsappTask.stop();
module.exports = {
queueWhatsapp,
+11 -12
View File
@@ -34,24 +34,23 @@ async function startWorkers() {
logger.info('All background workers started successfully');
} catch (error) {
logger.error('Failed to start background workers:', error);
process.exit(1);
process.exitCode = 1;
await handleShutdown('startup failure');
}
}
function handleShutdown(signal) {
if (isShuttingDown) {
logger.info('Shutdown already in progress...');
return;
}
async function handleShutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
logger.info(`Received ${signal}. Shutting down gracefully...`);
// Give time for cleanup
setTimeout(() => {
try {
await require('./serviceShutdown').stopServices();
await require('../database/db').db.destroy();
logger.info('Worker manager shutdown complete');
process.exit(0);
}, 1000);
} catch (error) {
logger.error('Worker shutdown failed', { error: error.message });
process.exitCode = 1;
}
}
// Handle shutdown signals
+4 -7
View File
@@ -342,15 +342,12 @@ async function cleanupOldAttempts() {
/**
* Initialize cleanup job
*/
function initializeCleanupJob() {
// Run cleanup every 24 hours
setInterval(cleanupOldAttempts, 24 * 60 * 60 * 1000);
// Run initial cleanup
cleanupOldAttempts();
}
const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupOldAttempts, { interval: 24 * 60 * 60 * 1000, initialDelay: 0 });
function initializeCleanupJob() { cleanupTask.start(); }
const stopCleanupJob = () => cleanupTask.stop();
module.exports = {
stopCleanupJob,
trackFailedAttempt,
trackSuccessfulLogin,
checkAccountLockout,
+6 -15
View File
@@ -60,19 +60,10 @@ async function cleanupTempUploads() {
* Start periodic cleanup of temp uploads
* Runs every hour
*/
function startTempUploadCleanup() {
// Run immediately on startup
cleanupTempUploads();
// Then run every hour
setInterval(() => {
cleanupTempUploads();
}, 60 * 60 * 1000); // 1 hour
logger.info('Temp upload cleanup service started');
}
const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupTempUploads, {
interval: 60 * 60 * 1000, initialDelay: 0
});
function startTempUploadCleanup() { cleanupTask.start(); }
function stopTempUploadCleanup() { return cleanupTask.stop(); }
module.exports = {
cleanupTempUploads,
startTempUploadCleanup
};
module.exports = { cleanupTempUploads, startTempUploadCleanup, stopTempUploadCleanup };
+12 -1
View File
@@ -33,4 +33,15 @@ function toIso(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 };
+38
View File
@@ -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 };
+23 -20
View File
@@ -190,30 +190,30 @@ function validateExternalUrl(urlString) {
* literal isPrivateIP check alone can't see that. Fails closed on resolution
* failure. IP literals are decided by isPrivateIP without a lookup.
*
* Residual: a determined attacker who controls DNS can still rebind between
* this check and the client's own resolution (TOCTOU). Fully closing that
* needs pinning the connection to the vetted IP, which the underlying
* clients (nodemailer/imap/ssh/aws-sdk) don't cleanly support; these actions
* are admin-only, so resolve-and-vet is the proportionate mitigation.
* HTTP clients for admin-configured URLs (webhook delivery, the email webhook
* transport) must use the returned addresses from validateExternalUrlAsync
* with pinnedRequestOptions; a separate preflight alone cannot stop rebinding.
* The analytics tracker proxy, the tracker adapters and OIDC discovery still
* rely on the preflight only.
*
* @param {string} hostname
* @returns {Promise<boolean>} true when safe to connect
*/
async function classifyHost(hostname) {
if (!hostname || typeof hostname !== 'string') return 'invalid';
// Literal check first: IP literals, blocked names, .internal/.local/.localhost.
if (isPrivateIP(hostname)) return 'private';
// An IP literal is fully decided above — no name to resolve.
async function resolveHost(hostname) {
if (!hostname || typeof hostname !== 'string') return { reason: 'invalid' };
if (isPrivateIP(hostname)) return { reason: 'private' };
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;
try {
addresses = await dns.lookup(hostname, { all: true });
} catch {
return 'unresolved'; // transient/NXDOMAIN — caller decides retry vs reject
}
if (!addresses.length) return 'unresolved';
return addresses.every((a) => !isPrivateIP(a.address)) ? 'ok' : 'private';
try { addresses = await dns.lookup(hostname, { all: true }); }
catch { return { reason: 'unresolved' }; }
if (!addresses.length) return { reason: 'unresolved' };
if (addresses.some(a => !net.isIP(a.address) || isPrivateIP(a.address))) return { reason: 'private' };
return { reason: 'ok', addresses };
}
async function classifyHost(hostname) {
return (await resolveHost(hostname)).reason;
}
async function isHostAllowed(hostname) {
@@ -237,11 +237,14 @@ async function validateExternalUrlAsync(urlString) {
} catch {
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') {
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 };
+28
View File
@@ -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 };
+5 -4
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('./requestLogPath');
/**
* Rate Limiting Security Utilities
* Provides secure rate limiting that prevents bypass attempts
@@ -42,7 +43,7 @@ function hasValidAdminToken(req) {
// Must be admin type to skip rate limiting
if (decoded.type !== 'admin') {
logger.warn('Non-admin token attempted to bypass rate limit', {
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
tokenType: decoded.type,
ip: req.ip
});
@@ -55,7 +56,7 @@ function hasValidAdminToken(req) {
if (tokenAge > maxAge) {
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',
ip: req.ip
});
@@ -70,7 +71,7 @@ function hasValidAdminToken(req) {
// Log attempts with invalid tokens (potential attacks)
if (error.name === 'JsonWebTokenError') {
logger.warn('Invalid token attempted to bypass rate limit', {
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
error: error.message,
ip: req.ip
});
@@ -105,7 +106,7 @@ function createSecureSkipFunction() {
function logRateLimitHit(req, res) {
logger.warn('Rate limit exceeded', {
ip: req.ip,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
userAgent: req.headers['user-agent'],
remaining: res.getHeader('X-RateLimit-Remaining'),
limit: res.getHeader('X-RateLimit-Limit')
+12
View File
@@ -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 };
+19 -14
View File
@@ -21,22 +21,27 @@ function isAllowedOrigin(origin) {
return allowedOrigins.indexOf(origin) !== -1;
}
// Origin check for multipart bodies (see the Content-Type gate below).
// Same-origin installs proxy /api through nginx and may not have FRONTEND_URL
// set, so an Origin matching the request Host is accepted alongside the CORS
// allowlist; Sec-Fetch-Site is authoritative when a browser sends it.
function multipartOriginAllowed(req) {
// Check every browser mutation, including an empty form POST. Explicitly
// configured frontend origins may be cross-site; a sibling origin alone is
// not trusted. Non-browser clients without Origin/Fetch Metadata still work.
function mutationOriginAllowed(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'];
if (site) return site !== 'cross-site';
if (site === 'same-origin') return true;
const origin = req.headers.origin;
if (!origin) return true;
if (isAllowedOrigin(origin)) return true;
try {
return new URL(origin).host === req.headers.host;
} catch {
return false;
if (origin) {
if (isAllowedOrigin(origin)) return true;
try {
const parsed = new URL(origin);
return parsed.origin !== 'null' && parsed.host === req.headers.host
&& (!req.protocol || parsed.protocol === `${req.protocol}:`);
} catch { return false; }
}
return !site || site === 'none';
}
module.exports = { isAllowedOrigin, multipartOriginAllowed };
// Compatibility export for existing callers.
const multipartOriginAllowed = mutationOriginAllowed;
module.exports = { isAllowedOrigin, mutationOriginAllowed, multipartOriginAllowed };
+2 -1
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Route helper utilities for standardized request handling.
* 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 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, {
error: error instanceof Error ? error.message : error,
stack: error instanceof Error ? error.stack : undefined
+5 -8
View File
@@ -145,15 +145,12 @@ async function cleanupExpiredRevocations() {
/**
* Initialize cleanup job for expired revocations
*/
function initializeRevocationCleanup() {
// Run cleanup every 6 hours
setInterval(cleanupExpiredRevocations, 6 * 60 * 60 * 1000);
// Run initial cleanup
cleanupExpiredRevocations();
}
const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupExpiredRevocations, { interval: 6 * 60 * 60 * 1000, initialDelay: 0 });
function initializeRevocationCleanup() { cleanupTask.start(); }
const stopRevocationCleanup = () => cleanupTask.stop();
module.exports = {
module.exports = { buildTokenId,
stopRevocationCleanup,
revokeToken,
isTokenRevoked,
revokeAllUserTokens,
+81 -26
View File
@@ -1413,32 +1413,7 @@
"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",
"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"
]
"route_signatures": []
},
"galleryFeedback.js": {
"decision": "excluded",
@@ -1603,6 +1578,86 @@
"GET /events/:id/share-link",
"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": {
+1 -1
View File
@@ -22,7 +22,7 @@ export default tseslint.config([
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@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',
'no-useless-escape': 'off',
'no-case-declarations': 'off',
+12
View File
@@ -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,
# 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
@@ -10,6 +18,10 @@ map $http_x_forwarded_proto $real_proto {
}
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;
server_name localhost;
server_tokens off;
+13 -1
View File
@@ -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 /
# frontend/nginx.conf for full rationale).
map $http_x_forwarded_proto $real_proto {
@@ -6,6 +14,10 @@ map $http_x_forwarded_proto $real_proto {
}
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;
server_name localhost;
root /usr/share/nginx/html;
@@ -55,4 +67,4 @@ server {
location / {
try_files $uri $uri/ /index.html;
}
}
}
+344 -472
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -22,14 +22,14 @@
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
"@tiptap/extension-code-block-lowlight": "^2.26.1",
"@tiptap/extension-hard-break": "^2.26.1",
"@tiptap/extension-link": "^2.25.0",
"@tiptap/extension-placeholder": "^2.26.1",
"@tiptap/extension-text-align": "^2.26.1",
"@tiptap/react": "^2.25.0",
"@tiptap/starter-kit": "^2.25.0",
"@tiptap/extension-character-count": "^3.31.3",
"@tiptap/extension-code-block-lowlight": "^3.31.3",
"@tiptap/extension-hard-break": "^3.31.3",
"@tiptap/extension-link": "^3.31.3",
"@tiptap/extension-placeholder": "^3.31.3",
"@tiptap/extension-text-align": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9",
@@ -57,7 +57,7 @@
"react-image-gallery": "^1.2.11",
"react-intersection-observer": "^9.4.3",
"react-photo-album": "^3.4.0",
"react-router-dom": "^6.8.0",
"react-router-dom": "^7.18.3",
"react-toastify": "11.0.5",
"signature_pad": "^5.1.3",
"swiper": "^12.1.0",
@@ -1,3 +1,4 @@
import { usePhotoSelection } from '../../hooks/usePhotoSelection';
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 { toast } from 'react-toastify';
@@ -29,15 +30,21 @@ interface AdminPhotoViewerProps {
categories: Array<{ id: number; name: string; slug: string }>;
}
export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
photos,
initialIndex,
eventId,
onClose,
onPhotoDeleted,
categories
export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = (props) => {
const selection = usePhotoSelection(props.photos, props.initialIndex);
if (!selection.currentPhoto) return null;
return <AdminPhotoViewerContent {...props} {...selection} currentPhoto={selection.currentPhoto} />;
};
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 { t } = useTranslation();
// 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 { formatDateTime: fmtDateTime } = useLocalizedDate();
const currentPhoto = photos[currentIndex];
const isVideo = currentPhoto
? (currentPhoto.media_type === '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 favoriteCount = currentPhoto?.favorite_count ?? 0;
if (!currentPhoto) {
return null;
}
// Fetch feedback for current photo
const { data: feedbackData } = useQuery<AdminFeedbackResponse>({
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
@@ -231,7 +233,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentIndex]);
}, [currentIndex, setCurrentIndex, photos.length, onClose]);
return (
<div className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center">
+30 -22
View File
@@ -52,6 +52,29 @@ interface CMSEditorProps {
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 }) => {
const { t } = useTranslation();
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 editor = useEditor({
shouldRerenderOnTransaction: true,
extensions: [
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
codeBlock: false, // We'll use CodeBlockLowlight instead
}),
@@ -148,7 +176,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
React.useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
editor.commands.setContent(content, { emitUpdate: false });
}
}, [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 = () => {
setIsFullscreen(!isFullscreen);
@@ -31,6 +31,29 @@ interface EmailTemplateEditorProps {
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> = ({
content,
onChange,
@@ -44,8 +67,13 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
const [showVariables, setShowVariables] = useState(false);
const editor = useEditor({
shouldRerenderOnTransaction: true,
extensions: [
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.configure({
@@ -75,7 +103,7 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
// Sync editor when content prop changes externally
React.useEffect(() => {
if (editor && !isSourceMode && content !== editor.getHTML()) {
editor.commands.setContent(content);
editor.commands.setContent(content, { emitUpdate: false });
setSourceContent(content);
}
}, [content, editor, isSourceMode]);
@@ -134,27 +162,7 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
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 (
<div className="border border-neutral-300 dark:border-neutral-600 rounded-lg overflow-hidden">
+18 -247
View File
@@ -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 { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -34,9 +38,7 @@ import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext';
import type { FilterType, FeedbackFilterType } from './GalleryFilter';
import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { api } from '../../config/api';
import { Upload, Menu, Eye, EyeOff, Shield, X, Download, ChevronLeft, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { Upload, Menu, Eye, EyeOff, Shield, X, Download, ChevronLeft } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
import { feedbackService, type ColorLabel } from '../../services/feedback.service';
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 [showUploadModal, setShowUploadModal] = 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 [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const { watermarkEnabled } = useWatermarkSettings();
@@ -188,19 +188,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
const [guestId, setGuestId] = useState<string>('');
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
useEffect(() => {
// 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)
// This ensures counts are always calculated from the full dataset
const { data, isLoading, error, refetch } = useGalleryPhotos(slug, 'all', guestId);
const { isSelectionMode, setIsSelectionMode, selectedPhotos, setSelectedPhotos } = useGallerySelection(data?.photos);
// Set protection level when data is available
useEffect(() => {
@@ -251,109 +239,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
return () => { timers.forEach(clearTimeout); clearInterval(interval); };
}, [hiddenUntilReveal, revealArmed, revealAtMs, refetch]);
// Post-upload refresh (P4-E.01). A guest upload is *queued*: the route
// 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 { uploadProcessing, handleUploadComplete } = useGalleryUpload(slug, refetch, () => setShowUploadModal(false));
const handleUploadComplete = (uploadIds: string[] = []) => {
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;
const uploadProcessingNotice = <UploadProcessingNotice processing={uploadProcessing} />;
// Get individual protection settings from event
const disableRightClick = data?.event?.disable_right_click === true;
@@ -427,8 +315,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
queryFn: async () => {
try {
// Use public endpoint to get feedback settings
const response = await api.get(`/gallery/${slug}/feedback-settings`);
return response.data;
return await feedbackService.getGalleryFeedbackSettings(slug);
} catch (error) {
console.error('Error fetching feedback settings:', error);
// 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([]);
setPeopleMatchAny(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}, []);
}, [setSelectedPhotos]);
// The address bar is the source of truth, so Back/Forward walk in and out of
// folders instead of leaving the gallery.
@@ -769,130 +656,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
}, [setSelectedPhotos]);
// Filter and sort photos
const filteredPhotos = useMemo(() => {
if (!data?.photos) return [];
// Folder containment (#1160) comes FIRST: at root this drops every photo that
// 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]);
const filteredPhotos = useGalleryFiltering({
sourcePhotos: data?.photos, categories: data?.categories, folderId: openFolder?.id ?? null,
selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug,
activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds,
selectedPersonIds, peopleMatchAny,
});
// Counts shown in the filter chips ("Liked (N)", etc.). In guest
// 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
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 (
<>
{/* 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 */}
{skipEmptyLayoutChild ? null : <LayoutComponent {...layoutProps} />}
<LayoutComponent {...layoutProps} />
{/* Lightbox - skip for full-page layouts which have their own lightbox */}
{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