fix: enforce gallery access and consolidate gallery workflows
Apply shared session, permission, ownership and lifecycle checks across gallery access, media grants and session restoration. Validate mutation origins, pin webhook DNS resolution and redact token-bearing request URLs. Consolidate gallery creation and queries, extract frontend state hooks, fix hook ordering and resource cleanup, and repair the fresh event schema. Update affected dependencies and restore excluded CI suites with regression and cross-database coverage.
This commit is contained in:
@@ -6,11 +6,6 @@ name: Tests
|
||||
# calendar) plus the photo / settings / OG / auth surface — wiring them
|
||||
# into CI makes regressions visible at PR time instead of post-merge.
|
||||
#
|
||||
# Six backend suites are excluded via --testPathIgnorePatterns. They
|
||||
# fail on `upstream/beta` too (pre-existing mock/infra issues, NOT CRM
|
||||
# regressions). Excluding them here keeps CI green from day 1; revisit
|
||||
# each individually as its own fix.
|
||||
#
|
||||
# Triggers on any change that could affect either suite. The backend
|
||||
# job intentionally omits frontend paths and vice versa so unrelated
|
||||
# PRs don't pay both build costs.
|
||||
@@ -89,18 +84,7 @@ jobs:
|
||||
# Un-gates the real-Postgres cases in the .picpeak restore suites
|
||||
# (see the `services:` note above). Absent it they silently skip.
|
||||
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
|
||||
run: |
|
||||
# Excluded suites — fail on upstream/beta too, tracked
|
||||
# separately as test-infra debt:
|
||||
# adminSettings.logo — supertest fixture
|
||||
# integration/adminPhotos.reference — supertest fixture
|
||||
# integration/webhookDelivery — supertest fixture
|
||||
# services/backupService.enhanced — knex mock chain
|
||||
# routes/__tests__/adminAuth — supertest fixture
|
||||
# (adminNotifications was excluded; #597 fix re-enables it.)
|
||||
npx jest \
|
||||
--testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \
|
||||
--ci
|
||||
run: npx jest --ci
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -121,6 +105,10 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Lint frontend (including Rules of Hooks)
|
||||
working-directory: ./frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Run Vitest suite
|
||||
working-directory: ./frontend
|
||||
run: npm test -- --run
|
||||
|
||||
@@ -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 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,7 +111,7 @@ 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);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
const knex = require('knex');
|
||||
const migration = require('../../migrations/core/210_events_updated_at');
|
||||
const { toTimestamp } = require('../../src/utils/dateNormalize');
|
||||
const { randomUUID } = require('crypto');
|
||||
|
||||
const engines = [['sqlite', null], ...(process.env.PICPEAK_PG_TEST_URL ? [['pg', process.env.PICPEAK_PG_TEST_URL]] : [])];
|
||||
describe.each(engines)('event timestamp migration contract (%s)', (engine, connection) => {
|
||||
let db, owner, schema;
|
||||
beforeEach(async () => {
|
||||
if (engine === 'pg') {
|
||||
schema = `event_contract_${randomUUID().replace(/-/g, '')}`;
|
||||
owner = knex({ client: 'pg', connection });
|
||||
await owner.schema.createSchema(schema);
|
||||
db = knex({ client: 'pg', connection, searchPath: [schema] });
|
||||
} else db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
});
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
|
||||
});
|
||||
it('upgrades legacy data, is repeatable and preserves subsequent edits', async () => {
|
||||
await db.schema.createTable('events', table => {
|
||||
table.increments('id'); table.timestamp('created_at').defaultTo(db.fn.now()); table.boolean('is_active').defaultTo(true);
|
||||
});
|
||||
const created = '2026-01-02T03:04:05.000Z';
|
||||
await db('events').insert({ created_at: created });
|
||||
await migration.up(db); await migration.up(db);
|
||||
let row = await db('events').first();
|
||||
expect(toTimestamp(row.updated_at)).toBe(Date.parse(created));
|
||||
await db('events').where({ id: row.id }).update({ updated_at: db.fn.now(), is_active: engine === 'pg' ? false : 0 });
|
||||
const changed = (await db('events').first()).updated_at;
|
||||
await migration.up(db); row = await db('events').first();
|
||||
expect(toTimestamp(row.updated_at)).toBe(toTimestamp(changed)); expect([false, 0]).toContain(row.is_active);
|
||||
});
|
||||
it('handles a fresh table and an already present updated_at column', async () => {
|
||||
await db.schema.createTable('events', table => { table.increments('id'); table.timestamp('created_at'); table.timestamp('updated_at'); });
|
||||
await migration.up(db);
|
||||
expect(await db.schema.hasColumn('events', 'updated_at')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
const knex = require('knex');
|
||||
const { randomUUID } = require('crypto');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const request = require('supertest');
|
||||
const pgUrl = process.env.PICPEAK_PG_TEST_URL;
|
||||
(pgUrl ? describe : describe.skip)('fresh PostgreSQL gallery contract', () => {
|
||||
let owner, db, schema, tmpDir, cleanup, previousClient;
|
||||
beforeAll(async () => {
|
||||
schema = `fresh_gallery_${randomUUID().replace(/-/g, '')}`;
|
||||
owner = knex({ client: 'pg', connection: pgUrl });
|
||||
await owner.schema.createSchema(schema);
|
||||
previousClient = process.env.DATABASE_CLIENT;
|
||||
process.env.DATABASE_CLIENT = 'pg';
|
||||
process.env.JWT_SECRET = 'fresh-pg-gallery-test-secret-at-least-32-characters';
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-fresh-pg-'));
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
jest.doMock('../../knexfile', () => ({ client: 'pg', connection: pgUrl, searchPath: [schema] }));
|
||||
({ db } = require('../../src/database/db'));
|
||||
// bootCrmDb runs the complete core chain against the shared db singleton.
|
||||
({ cleanup } = await require('../integration/helpers/crmDb').bootCrmDb());
|
||||
}, 120000);
|
||||
afterAll(async () => {
|
||||
await require('../../src/services/serviceShutdown').stopServices();
|
||||
if (cleanup) await cleanup(); else if (db) await db.destroy();
|
||||
if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); }
|
||||
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
if (previousClient === undefined) delete process.env.DATABASE_CLIENT; else process.env.DATABASE_CLIENT = previousClient;
|
||||
jest.dontMock('../../knexfile');
|
||||
});
|
||||
it('creates through the real admin route, then toggles a typed boolean and timestamp', async () => {
|
||||
const { seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId);
|
||||
const app = buildRouteApp('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
const bearer = `Bearer ${mintAdminToken(adminId)}`;
|
||||
const created = await request(app).post('/api/admin/events').set('Authorization', bearer).send({
|
||||
event_type: 'wedding', event_name: 'Fresh PostgreSQL', event_date: '2026-10-01',
|
||||
customer_name: 'Customer', customer_email: 'customer@example.test', admin_email: 'admin@example.test',
|
||||
password: 'Strong-Test-Photo-Pass-924!', expiration_days: 30, feedback_enabled: true,
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
const event = await db('events').where({ event_name: 'Fresh PostgreSQL' }).first();
|
||||
expect(event.created_by).toBe(adminId);
|
||||
expect(event.is_active).toBe(true);
|
||||
expect(event.updated_at).toBeInstanceOf(Date);
|
||||
expect(await db('event_feedback_settings').where({ event_id: event.id }).first()).toBeTruthy();
|
||||
const toggled = await request(app).post(`/api/admin/events/${event.id}/toggle-status`).set('Authorization', bearer).send({});
|
||||
expect(toggled.status).toBe(200);
|
||||
const row = await db('events').where({ id: event.id }).first();
|
||||
expect(row.is_active).toBe(false);
|
||||
expect(row.updated_at).toBeInstanceOf(Date);
|
||||
await require('../../migrations/core/210_events_updated_at').up(db);
|
||||
expect((await db('events').where({ id: event.id }).first()).updated_at).toEqual(row.updated_at);
|
||||
});
|
||||
});
|
||||
@@ -1,460 +1,82 @@
|
||||
/**
|
||||
* Regression test for the /admin/login → /admin/dashboard → /admin/login
|
||||
* redirect loop reported on v3.32.4-beta.0.
|
||||
*
|
||||
* Cause: GET /auth/session was less strict than the adminAuth middleware.
|
||||
* The session endpoint accepted tokens that the protected endpoints
|
||||
* subsequently rejected with 401, which the frontend's interceptor
|
||||
* translated into a hard redirect to /admin/login. /auth/session then
|
||||
* said "valid: true" again on the next page load and the cycle closed.
|
||||
*
|
||||
* /auth/session must reject the same admin tokens adminAuth would
|
||||
* reject, specifically: deactivated admin user, deleted admin user,
|
||||
* password changed since iat. Same for gallery: archived event.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
/** Session restoration uses the same live policy as protected routes. */
|
||||
const request = require('supertest');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
process.env.JWT_SECRET = 'session-symmetry-test-secret';
|
||||
|
||||
const fakeDb = {
|
||||
adminUsers: [],
|
||||
events: [],
|
||||
revokedTokens: [],
|
||||
};
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const formatBoolean = (v) => (v ? 1 : 0);
|
||||
void formatBoolean;
|
||||
function dbFn(table) {
|
||||
if (table === 'admin_users') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
// The session route joins roles for the adminUser payload (#798);
|
||||
// fake rows carry no role fields, so the join is a pass-through.
|
||||
leftJoin() {
|
||||
return this;
|
||||
},
|
||||
where(criteria) {
|
||||
rowFilter = (row) => {
|
||||
return Object.entries(criteria).every(([rawKey, v]) => {
|
||||
// Joined queries prefix columns ('admin_users.id') — the fake
|
||||
// rows use bare names.
|
||||
const k = rawKey.replace(/^admin_users\./, '');
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
};
|
||||
return this;
|
||||
},
|
||||
select(...cols) {
|
||||
this._cols = cols;
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
const row = fakeDb.adminUsers.find(rowFilter);
|
||||
if (!row) return undefined;
|
||||
if (!this._cols) return row;
|
||||
const out = {};
|
||||
for (const c of this._cols) {
|
||||
// Support 'table.col' and 'table.col as alias' shapes.
|
||||
const [source, alias] = c.split(/\s+as\s+/i);
|
||||
const bare = source.includes('.') ? source.split('.').pop() : source;
|
||||
out[alias || bare] = row[bare];
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === 'events') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
rowFilter = (row) =>
|
||||
Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return fakeDb.events.find(rowFilter);
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table: ${table}`);
|
||||
}
|
||||
return { db: dbFn, formatBoolean: () => 1 };
|
||||
const crypto = require('crypto');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
process.env.JWT_SECRET = 'session-symmetry-test-secret-with-at-least-32-characters';
|
||||
let db, cleanup, app, adminId, customerId, eventId, cutoff;
|
||||
const slug = 'session-symmetry';
|
||||
const sign = (claims = {}) => jwt.sign({ type: 'admin', id: adminId, username: 'tester',
|
||||
iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims },
|
||||
process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '4h' });
|
||||
const gallery = (claims = {}) => sign({ type: 'gallery', eventId, eventSlug: slug, ...claims });
|
||||
const session = bearer => request(app).get(`/api/auth/session?slug=${slug}`).set('Authorization', `Bearer ${bearer}`);
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId);
|
||||
const row = await require('../../src/services/eventCreationService').createEvent({
|
||||
event_type: 'wedding', event_name: 'Session symmetry', event_date: '2026-10-01',
|
||||
slug, password: 'Session-Strong-Password-924!', expiration_days: 30,
|
||||
customer_email: 'customer@example.test', admin_email: 'admin@example.test',
|
||||
}, { actor: { id: adminId }, source: 'v1' });
|
||||
eventId = row.id;
|
||||
await db('events').where({ id: eventId }).update({ slug });
|
||||
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
|
||||
cutoff = require('../../src/utils/sessionCutoff');
|
||||
app = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||
}, 120000);
|
||||
beforeEach(async () => {
|
||||
await db('admin_users').where({ id: adminId }).update({ is_active: 1, password_changed_at: null });
|
||||
await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null });
|
||||
await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0,
|
||||
expires_at: new Date(Date.now() + 86400000).toISOString() });
|
||||
await cutoff.setSessionsValidAfter(0);
|
||||
});
|
||||
|
||||
jest.mock('../../src/utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({
|
||||
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
|
||||
revokeToken: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenUtils', () => ({
|
||||
getAdminTokenFromRequest: (req) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
|
||||
return null;
|
||||
},
|
||||
getGalleryTokenFromRequest: () => null,
|
||||
setAdminAuthCookie: jest.fn(),
|
||||
setGalleryAuthCookies: jest.fn(),
|
||||
clearAdminAuthCookie: jest.fn(),
|
||||
clearGalleryAuthCookies: jest.fn(),
|
||||
buildCookieOptionsWithExpiry: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) }));
|
||||
// Mock sessionTimeout's isSessionExpired so each test controls the return.
|
||||
// Default: not expired (so existing tests keep passing without setup).
|
||||
jest.mock('../../src/middleware/sessionTimeout', () => ({
|
||||
endSession: jest.fn(),
|
||||
isSessionExpired: jest.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/auth', authRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
|
||||
const issuedAt = iat ?? Math.floor(Date.now() / 1000);
|
||||
// Note: do NOT pass noTimestamp:true here — that strips iat from the
|
||||
// payload entirely, defeating the password-change comparison. Provide
|
||||
// iat (and exp) via the payload directly instead.
|
||||
return jwt.sign(
|
||||
{ id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 },
|
||||
process.env.JWT_SECRET,
|
||||
{ issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) {
|
||||
return jwt.sign(
|
||||
{ eventId, eventSlug, type: 'gallery', ...extra },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
describe('GET /auth/session — symmetry with protected middleware', () => {
|
||||
beforeEach(() => {
|
||||
fakeDb.adminUsers = [];
|
||||
fakeDb.events = [];
|
||||
fakeDb.revokedTokens = [];
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.type).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user has been deactivated', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: false,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user no longer exists', async () => {
|
||||
// adminUsers is empty
|
||||
const token = signAdminToken({ id: 999 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when password was changed after the token was issued', async () => {
|
||||
// iat must be in the past, exp must be in the future so jwt.verify
|
||||
// doesn't reject the token before /auth/session even gets to look
|
||||
// at password_changed_at.
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true when password was changed BEFORE the token was issued', async () => {
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60;
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is archived', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: true,
|
||||
expires_at: null,
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is expired', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() - 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true for an active gallery token', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* What KIND of gallery session this is (#1149).
|
||||
*
|
||||
* The frontend used to keep this in sessionStorage, which is per-TAB while
|
||||
* the cookie is per-browser: a gallery reopened in a second tab lost
|
||||
* 'client' even though the backend still served it as one, and the UI hid
|
||||
* the only control that clears the privileged cookie. Reported from the
|
||||
* token so a restored session knows what it actually is.
|
||||
*/
|
||||
describe('gallery session kind', () => {
|
||||
beforeEach(() => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a PIN-client session as client', async () => {
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken({ accessLevel: 'client' })}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('client');
|
||||
expect(res.body.viaCustomer).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a customer-portal session, which looks like a guest', async () => {
|
||||
// via:'customer' runs at accessLevel 'guest' but bypasses reveal mode,
|
||||
// so it is a credential that does not look like one.
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken({ via: 'customer', customerId: 7 })}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('guest');
|
||||
expect(res.body.viaCustomer).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a plain guest as neither', async () => {
|
||||
// The flags have to discriminate, or they would just hand every visitor
|
||||
// a Logout button back.
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken()}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('guest');
|
||||
expect(res.body.viaCustomer).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns valid:false when the token is revoked', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
fakeDb.revokedTokens.push(1);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
// Session-timeout symmetry — issue #350 recurrence on v3.39.1-beta.0.
|
||||
// sessionTimeoutMiddleware (mounted on /api/admin) rejects idle/old-iat
|
||||
// tokens with 401 SESSION_TIMEOUT, but /auth/session previously didn't.
|
||||
// The new isSessionExpired helper closes that asymmetry.
|
||||
describe('session-timeout symmetry', () => {
|
||||
const { isSessionExpired } = require('../../src/middleware/sessionTimeout');
|
||||
|
||||
beforeEach(() => {
|
||||
isSessionExpired.mockReset();
|
||||
// Default to "active session" so the other admin checks above also
|
||||
// pass when this branch runs.
|
||||
isSessionExpired.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when isSessionExpired reports the token has timed out', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockResolvedValue(true);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.error).toBe('Session expired');
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token (helper says not expired)', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockResolvedValue(false);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(isSessionExpired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call isSessionExpired for gallery tokens', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(isSessionExpired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through (treats as valid) if the helper itself throws', async () => {
|
||||
// Defensive: the require() in auth.js is wrapped in try/catch so a
|
||||
// missing/broken helper doesn't fail-closed during early bootstrap.
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockRejectedValue(new Error('boom'));
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await require('../../src/services/serviceShutdown').stopServices();
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
it('hydrates an active admin and its role', async () => {
|
||||
const res = await session(sign());
|
||||
expect(res.body).toMatchObject({ valid: true, type: 'admin', adminUser: { id: adminId, role: { name: 'super_admin' } } });
|
||||
});
|
||||
it.each(['disabled', 'password', 'deleted', 'idle'])('rejects an admin after %s', async reason => {
|
||||
let bearer = sign();
|
||||
if (reason === 'disabled') await db('admin_users').where({ id: adminId }).update({ is_active: 0 });
|
||||
if (reason === 'password') await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date().toISOString() });
|
||||
if (reason === 'deleted') bearer = sign({ id: 999999 });
|
||||
if (reason === 'idle') bearer = sign({ iat: Math.floor(Date.now() / 1000) - 7200 });
|
||||
expect((await session(bearer)).body.valid).toBe(false);
|
||||
});
|
||||
it('accepts a session issued after a previous password change', async () => {
|
||||
await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date(Date.now() - 120000).toISOString() });
|
||||
expect((await session(sign())).body.valid).toBe(true);
|
||||
});
|
||||
it.each(['archived', 'expired', 'draft', 'inactive'])('rejects a gallery that is %s', async reason => {
|
||||
await db('events').where({ id: eventId }).update({
|
||||
...(reason === 'archived' && { is_archived: 1 }), ...(reason === 'draft' && { is_draft: 1 }),
|
||||
...(reason === 'inactive' && { is_active: 0 }), ...(reason === 'expired' && { expires_at: new Date(Date.now() - 1000).toISOString() }),
|
||||
});
|
||||
expect((await session(gallery())).body.valid).toBe(false);
|
||||
});
|
||||
it.each(['guest', 'client', 'customer'])('restores the %s gallery session kind', async kind => {
|
||||
const res = await session(gallery(kind === 'customer' ? { via: 'customer', customerId } : { accessLevel: kind }));
|
||||
expect(res.body).toMatchObject({ valid: true, accessLevel: kind === 'client' ? 'client' : 'guest', viaCustomer: kind === 'customer' });
|
||||
});
|
||||
it.each(['revoked', 'restore'])('invalidates both admin and gallery sessions after %s', async reason => {
|
||||
const tokens = [sign(), gallery()];
|
||||
if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||
else for (const bearer of tokens) await require('../../src/utils/tokenRevocation').revokeToken(bearer, 'test');
|
||||
for (const bearer of tokens) expect((await session(bearer)).body.valid).toBe(false);
|
||||
});
|
||||
it('refuses a deactivated customer gallery session', async () => {
|
||||
const bearer = gallery({ via: 'customer', customerId });
|
||||
expect((await session(bearer)).body.valid).toBe(true);
|
||||
await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 });
|
||||
expect((await session(bearer)).body.valid).toBe(false);
|
||||
});
|
||||
it('refuses an unrelated JWT type', async () => {
|
||||
const res = await session(sign({ type: 'password-reset' }));
|
||||
expect(res.status).toBe(403); expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/** Real routes + migrated SQLite: the same session policy protects lists and media. */
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
process.env.JWT_SECRET = 'gallery-policy-regression-secret-at-least-32-characters';
|
||||
|
||||
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
|
||||
secureImageAccess: (req, _res, next) => {
|
||||
req.clientInfo = { fingerprint: 'policy-test', ip: '127.0.0.1', userAgent: 'jest' };
|
||||
next();
|
||||
},
|
||||
getSecurityStatus: (_req, res) => res.json({}),
|
||||
}));
|
||||
|
||||
let db, cleanup, app, adminId, customerId, foreignId, event, secure, cutoff, revokeToken;
|
||||
const eventId = 70001, photoId = 70002, slug = 'policy-test';
|
||||
const token = (claims = {}) => jwt.sign({ type: 'gallery', eventId, eventSlug: slug,
|
||||
iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims },
|
||||
process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '1h' });
|
||||
const get = (url, bearer) => {
|
||||
const req = request(app).get(url);
|
||||
return bearer ? req.set('Authorization', `Bearer ${bearer}`) : req;
|
||||
};
|
||||
const endpoints = [`/api/gallery/${slug}/photos`, `/api/gallery/${slug}/photo/${photoId}`,
|
||||
`/api/gallery/${slug}/thumbnail/${photoId}`, `/api/gallery/${slug}/download/${photoId}`];
|
||||
const expectDirect = async (bearer, status, suffix = '') => {
|
||||
for (const url of endpoints) expect((await get(url + suffix, bearer)).status).toBe(status);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId);
|
||||
const [row] = await db('admin_users').insert({ username: 'foreign', email: 'foreign@example.test', password_hash: 'unused', is_active: 1 }).returning('id');
|
||||
foreignId = row.id ?? row;
|
||||
await assignAdminRole(db, foreignId, 'viewer');
|
||||
await db('events').insert({ id: eventId, slug, event_type: 'wedding', event_name: 'Policy test',
|
||||
event_date: '2026-01-01', host_email: 'h@example.test', admin_email: 'a@example.test', password_hash: 'unused',
|
||||
share_link: '/gallery/policy-test', created_by: adminId });
|
||||
const file = path.join(process.env.STORAGE_PATH, `events/active/${slug}/individual/fixture.jpg`);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await require('sharp')({ create: { width: 8, height: 8, channels: 3, background: '#228844' } }).jpeg().toFile(file);
|
||||
await db('photos').insert({ id: photoId, event_id: eventId, filename: 'fixture.jpg', path: `${slug}/individual/fixture.jpg`,
|
||||
type: 'individual', mime_type: 'image/jpeg', processing_status: 'complete', size_bytes: (await fs.stat(file)).size });
|
||||
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
|
||||
secure = require('../../src/services/secureImageService');
|
||||
jest.spyOn(secure, 'createClientFingerprint').mockReturnValue('policy-test');
|
||||
cutoff = require('../../src/utils/sessionCutoff');
|
||||
({ revokeToken } = require('../../src/utils/tokenRevocation'));
|
||||
app = express(); app.use(express.json()); app.use(cookieParser());
|
||||
app.use('/api', require('../../src/middleware/csrf'));
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/images', require('../../src/routes/protectedImages'));
|
||||
app.use('/api/secure-images', require('../../src/routes/secureImages'));
|
||||
}, 120000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0, require_password: 1,
|
||||
expires_at: new Date(Date.now() + 86400000).toISOString(), reveal_mode: 0 });
|
||||
await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null });
|
||||
if (!await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).first()) {
|
||||
await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId });
|
||||
}
|
||||
await cutoff.setSessionsValidAfter(0);
|
||||
event = await db('events').where({ id: eventId }).first();
|
||||
});
|
||||
afterAll(async () => { secure?.dispose(); if (cleanup) await cleanup(); });
|
||||
|
||||
it('serves a valid session as a real list and JPEG', async () => {
|
||||
const bearer = token();
|
||||
const list = await get(endpoints[0], bearer);
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.photos).toEqual(expect.arrayContaining([expect.objectContaining({ id: photoId })]));
|
||||
const image = await get(endpoints[1], bearer);
|
||||
expect(image.status).toBe(200); expect(image.headers['content-type']).toMatch(/image\/jpeg/);
|
||||
expect(image.body.length).toBeGreaterThan(100);
|
||||
});
|
||||
it('scopes draft previews to the owner and current read permissions', async () => {
|
||||
await db('events').where({ id: eventId }).update({ is_draft: 1 });
|
||||
await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1');
|
||||
await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1');
|
||||
// Ownership alone does not grant a user without a role read access.
|
||||
const role = (await db('admin_users').where({ id: adminId }).first()).role_id;
|
||||
await db('admin_users').where({ id: adminId }).update({ role_id: null });
|
||||
try { await expectDirect(mintAdminToken(adminId), 403, '?admin_preview=1'); }
|
||||
finally { await db('admin_users').where({ id: adminId }).update({ role_id: role }); }
|
||||
});
|
||||
it.each(['revocation', 'restore'])('rejects gallery sessions after %s', async (reason) => {
|
||||
const bearer = token();
|
||||
if (reason === 'revocation') expect(await revokeToken(bearer, 'test')).toBe(true);
|
||||
else await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||
await expectDirect(bearer, 401);
|
||||
});
|
||||
it.each(['deactivated', 'password changed'])('rejects an assigned customer when %s', async (reason) => {
|
||||
const bearer = token({ via: 'customer', customerId });
|
||||
await expectDirect(bearer, 200);
|
||||
await db('customer_accounts').where({ id: customerId }).update(reason === 'deactivated'
|
||||
? { is_active: 0 } : { password_changed_at: new Date().toISOString() });
|
||||
await expectDirect(bearer, 401);
|
||||
});
|
||||
it.each(['ISO', 'epoch'])('enforces expiry immediately for public and JWT access (%s)', async (format) => {
|
||||
const expiry = Date.now() - 1000;
|
||||
await db('events').where({ id: eventId }).update({ require_password: 0, expires_at: format === 'ISO' ? new Date(expiry).toISOString() : expiry });
|
||||
await expectDirect(undefined, 404); await expectDirect(token(), 404);
|
||||
await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1');
|
||||
});
|
||||
it.each(['revocation', 'restore', 'expiry', 'customer'])('rechecks signed and secure image grants after %s', async (reason) => {
|
||||
const bearer = token(reason === 'customer' ? { via: 'customer', customerId } : {});
|
||||
const signed = await request(app).post(`/api/images/${slug}/photo/${photoId}/generate-url`).set('Authorization', `Bearer ${bearer}`).send({});
|
||||
expect(signed.status).toBe(200);
|
||||
const minted = await request(app).post(`/api/secure-images/${slug}/generate-token`).set('Authorization', `Bearer ${bearer}`).send({ photoId });
|
||||
expect(minted.status).toBe(200);
|
||||
const secureUrl = `/api/secure-images/${slug}/secure/${photoId}/${minted.body.token}`;
|
||||
expect((await get(signed.body.url)).status).toBe(200);
|
||||
expect((await get(secureUrl)).status).toBe(200);
|
||||
if (reason === 'revocation') await revokeToken(bearer, 'test');
|
||||
if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||
if (reason === 'expiry') await db('events').where({ id: eventId }).update({ expires_at: new Date(Date.now() - 1000).toISOString() });
|
||||
if (reason === 'customer') await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 });
|
||||
const status = reason === 'expiry' ? 404 : 401;
|
||||
expect((await get(signed.body.url)).status).toBe(status);
|
||||
expect((await get(secureUrl)).status).toBe(status);
|
||||
});
|
||||
|
||||
it('blocks an empty cross-site cookie POST before the reveal state changes', async () => {
|
||||
await db('events').where({ id: eventId }).update({ reveal_mode: 1, revealed_at: null });
|
||||
const cookie = `admin_token=${mintAdminToken(adminId)}`;
|
||||
const url = `/api/admin/events/${eventId}/reveal`;
|
||||
const blocked = await request(app).post(url).set('Cookie', cookie).set('Origin', 'https://attacker.example')
|
||||
.set('Sec-Fetch-Site', 'cross-site').set('Content-Type', 'application/x-www-form-urlencoded').send('');
|
||||
expect(blocked.status).toBe(403);
|
||||
expect((await db('events').where({ id: eventId }).first()).revealed_at).toBeNull();
|
||||
process.env.ADMIN_URL = 'https://admin.example.test';
|
||||
try {
|
||||
const allowed = await request(app).post(url).set('Cookie', cookie).set('Origin', process.env.ADMIN_URL)
|
||||
.set('Sec-Fetch-Site', 'cross-site').send({});
|
||||
expect(allowed.status).toBe(200);
|
||||
expect((await db('events').where({ id: eventId }).first()).revealed_at).not.toBeNull();
|
||||
} finally { delete process.env.ADMIN_URL; }
|
||||
});
|
||||
|
||||
it('toggles status on a fully migrated fresh database and records updated_at', async () => {
|
||||
const response = await request(app).post(`/api/admin/events/${eventId}/toggle-status`)
|
||||
.set('Authorization', `Bearer ${mintAdminToken(adminId)}`).send({});
|
||||
expect(response.status).toBe(200);
|
||||
const row = await db('events').where({ id: eventId }).first();
|
||||
expect([false, 0]).toContain(row.is_active);
|
||||
expect(Number.isFinite(require('../../src/utils/dateNormalize').toTimestamp(row.updated_at))).toBe(true);
|
||||
});
|
||||
it('paginates after feedback filtering, with a total independent of page size', async () => {
|
||||
const ids = [70003, 70004, 70005];
|
||||
await db('photos').insert(ids.map(id => ({ id, event_id: eventId, filename: `${id}.jpg`, path: 'unused',
|
||||
type: 'individual', like_count: 1, processing_status: 'complete' })));
|
||||
await db('event_feedback_settings').insert({ event_id: eventId, feedback_enabled: 1, show_feedback_to_guests: 1 });
|
||||
try {
|
||||
const bearer = token();
|
||||
const first = await get(`${endpoints[0]}?filter=liked&limit=2&page=1&sort=filename&order=asc`, bearer);
|
||||
const second = await get(`${endpoints[0]}?filter=liked&limit=2&page=2&sort=filename&order=asc`, bearer);
|
||||
expect(first.status).toBe(200); expect(second.status).toBe(200);
|
||||
expect(first.body.pagination).toMatchObject({ total: 3, has_more: true });
|
||||
expect(second.body.pagination).toMatchObject({ total: 3, has_more: false });
|
||||
expect([...first.body.photos, ...second.body.photos].map(photo => photo.id)).toEqual(ids);
|
||||
} finally {
|
||||
await db('photos').whereIn('id', ids).del();
|
||||
await db('event_feedback_settings').where({ event_id: eventId }).del();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['assignment removed', 'anonymized'])('rejects an existing customer grant after %s', async reason => {
|
||||
const bearer = token({ via: 'customer', customerId });
|
||||
await expectDirect(bearer, 200);
|
||||
if (reason === 'assignment removed') await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).del();
|
||||
else await require('../../src/services/customerAccountsService').eraseCustomer(customerId, adminId);
|
||||
await expectDirect(bearer, reason === 'assignment removed' ? 403 : 401);
|
||||
});
|
||||
it('denies foreign editors and allows the editor who owns the gallery', async () => {
|
||||
await assignAdminRole(db, foreignId, 'editor');
|
||||
try {
|
||||
await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1');
|
||||
await db('events').where({ id: eventId }).update({ created_by: foreignId });
|
||||
await expectDirect(mintAdminToken(foreignId), 200, '?admin_preview=1');
|
||||
} finally {
|
||||
await db('events').where({ id: eventId }).update({ created_by: adminId });
|
||||
await assignAdminRole(db, foreignId, 'viewer');
|
||||
}
|
||||
});
|
||||
it('bounds a large gallery response while retaining the complete count', async () => {
|
||||
const rows = Array.from({ length: 5000 }, (_, index) => ({ id: 80000 + index, event_id: eventId,
|
||||
filename: `large-${index}.jpg`, path: 'unused', type: 'individual', processing_status: 'complete' }));
|
||||
try {
|
||||
await db.batchInsert('photos', rows, 100);
|
||||
const response = await get(`${endpoints[0]}?limit=999999&page=1`, token());
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.photos).toHaveLength(250);
|
||||
expect(response.body.pagination).toMatchObject({ total: 5001, limit: 250, has_more: true });
|
||||
} finally { await db('photos').where('id', '>=', 80000).where({ event_id: eventId }).del(); }
|
||||
});
|
||||
@@ -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,27 @@
|
||||
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}`,
|
||||
])('does not log capabilities on request or response: %s', (originalUrl) => {
|
||||
logger.info.mockClear();
|
||||
const res = new EventEmitter(); res.statusCode = 200;
|
||||
middleware({ originalUrl, method: 'GET' }, res, jest.fn());
|
||||
res.emit('finish');
|
||||
expect(logger.info).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(marker);
|
||||
});
|
||||
it('retains useful non-secret routes and removes control characters', () => {
|
||||
expect(requestLogPath('/api/admin/events/12?search=private')).toBe('/api/admin/events/12');
|
||||
expect(requestLogPath('/api/admin/events\nforged')).not.toContain('\n');
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
const http = require('http');
|
||||
it('directs Supertest to the actual IPv6 listener instead of an unrelated IPv4 port', async () => {
|
||||
jest.resetModules();
|
||||
const request = require('supertest');
|
||||
const server = http.createServer((_req, res) => { res.end('actual test listener'); });
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '::1', resolve); });
|
||||
try {
|
||||
const response = await request(server).get('/');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('actual test listener');
|
||||
expect(response.request.url).toContain('://[::1]:');
|
||||
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
@@ -1,3 +1,18 @@
|
||||
// Supertest 6 binds an IPv6 wildcard listener but hardcodes an IPv4 URL.
|
||||
// macOS can allocate that IPv6 port while a different IPv4 service owns it.
|
||||
// Address the listener's actual family so a test cannot reach that service.
|
||||
jest.mock('supertest/lib/test', () => {
|
||||
const Test = jest.requireActual('supertest/lib/test');
|
||||
const serverAddress = Test.prototype.serverAddress;
|
||||
Test.prototype.serverAddress = function(app, path) {
|
||||
const url = serverAddress.call(this, app, path);
|
||||
return app.address()?.family === 'IPv6'
|
||||
? url.replace('://127.0.0.1:', '://[::1]:')
|
||||
: url;
|
||||
};
|
||||
return Test;
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
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'));
|
||||
}
|
||||
};
|
||||
+41
-48
@@ -218,7 +218,7 @@ app.use((req, res, next) => {
|
||||
});
|
||||
|
||||
// CORS configuration (apply only to API routes)
|
||||
const { isAllowedOrigin, multipartOriginAllowed } = require('./src/utils/requestOrigin');
|
||||
const { isAllowedOrigin } = require('./src/utils/requestOrigin');
|
||||
|
||||
const corsOptions = {
|
||||
origin: function (origin, callback) {
|
||||
@@ -528,43 +528,10 @@ app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||||
|
||||
// CSRF protection: require JSON Content-Type on mutating API requests
|
||||
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
||||
// Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls
|
||||
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
|
||||
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
|
||||
}
|
||||
// multipart is exactly what a cross-site <form> can send without a
|
||||
// preflight, and in a split-origin deployment (SameSite=None) the admin
|
||||
// cookie rides along to the upload routes. Browsers label such a
|
||||
// submission Sec-Fetch-Site: cross-site (and always send Origin on a
|
||||
// cross-origin POST); non-browser clients send neither header and pass.
|
||||
if (contentType.includes('multipart/form-data') && !multipartOriginAllowed(req)) {
|
||||
return res.status(403).json({ error: 'Cross-site multipart request rejected' });
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
// Validate the origin independently of body length/content type.
|
||||
app.use('/api', require('./src/middleware/csrf'));
|
||||
|
||||
// Request logging for API routes (with timestamps)
|
||||
const apiRequestLogger = (req, res, next) => {
|
||||
try {
|
||||
const started = Date.now();
|
||||
const ts = new Date().toISOString();
|
||||
logger.info(`[${ts}] ${req.method} ${req.originalUrl}`);
|
||||
res.on('finish', () => {
|
||||
const ms = Date.now() - started;
|
||||
const tsDone = new Date().toISOString();
|
||||
logger.info(`[${tsDone}] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
|
||||
});
|
||||
} catch (_) {}
|
||||
next();
|
||||
};
|
||||
app.use('/api', apiRequestLogger);
|
||||
app.use('/api', require('./src/middleware/apiRequestLogger'));
|
||||
|
||||
// Maintenance mode middleware - add after body parsing but before routes
|
||||
app.use(maintenanceMiddleware);
|
||||
@@ -1098,6 +1065,27 @@ 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;
|
||||
const tempCleanupTask = require('./src/services/scheduledTask').scheduledTask(
|
||||
() => require('./src/utils/cleanupTempUploads').cleanupTempUploads(),
|
||||
{ interval: 60 * 60 * 1000, initialDelay: 0 },
|
||||
);
|
||||
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(), 30000);
|
||||
timeout.unref();
|
||||
try {
|
||||
await Promise.all([close, tempCleanupTask.stop(), require('./src/services/serviceShutdown').stopServices()]);
|
||||
await db.destroy();
|
||||
} finally { clearTimeout(timeout); }
|
||||
})();
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
async function startServer() {
|
||||
try {
|
||||
@@ -1122,14 +1110,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');
|
||||
|
||||
tempCleanupTask.start();
|
||||
|
||||
// Start file watcher
|
||||
startFileWatcher();
|
||||
// External-media folder watcher (issue 1187): imports new files into
|
||||
@@ -1149,6 +1131,7 @@ 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.
|
||||
await require('./src/services/downloadJobService').recoverOrphanedJobs();
|
||||
startDownloadJobCleanup();
|
||||
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
|
||||
startRevealScheduler();
|
||||
@@ -1306,7 +1289,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 +1308,20 @@ 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) {
|
||||
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||
process.once(signal, () => {
|
||||
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();
|
||||
};
|
||||
@@ -1,9 +1,5 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isMissingRolesSchema } = require('../utils/dbErrors');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
|
||||
const sessionAccess = require('../services/sessionAccessService');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
@@ -32,100 +28,10 @@ async function adminAuth(req, res, next) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Check if token is revoked
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
logger.warn('Revoked token used', {
|
||||
userId: decoded.id,
|
||||
tokenType: decoded.type
|
||||
});
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// Reject any session issued before the global cutoff (set by a .picpeak
|
||||
// restore, which can reassign admin ids). Forces every pre-restore admin
|
||||
// session to re-authenticate against the restored data.
|
||||
if (await isTokenBeforeCutoff(decoded)) {
|
||||
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
|
||||
}
|
||||
|
||||
// Verify token type
|
||||
if (decoded.type !== 'admin') {
|
||||
logger.warn('Non-admin token used for admin endpoint', {
|
||||
userId: decoded.id,
|
||||
tokenType: decoded.type
|
||||
});
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
// IP validation (optional - can be strict or just log)
|
||||
const currentIp = req.ip || req.connection.remoteAddress;
|
||||
if (decoded.ip && decoded.ip !== currentIp) {
|
||||
logger.warn('Token used from different IP', {
|
||||
userId: decoded.id,
|
||||
tokenIp: decoded.ip,
|
||||
currentIp: currentIp
|
||||
});
|
||||
}
|
||||
|
||||
// Check if admin still exists and is active, including role info
|
||||
// Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario)
|
||||
let admin;
|
||||
try {
|
||||
admin = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
|
||||
.select(
|
||||
'admin_users.id',
|
||||
'admin_users.username',
|
||||
'admin_users.email',
|
||||
'admin_users.password_changed_at',
|
||||
'roles.id as role_id',
|
||||
'roles.name as role_name'
|
||||
)
|
||||
.first();
|
||||
} catch (joinError) {
|
||||
// Fail CLOSED on anything that isn't a genuinely missing roles schema:
|
||||
// the fallback below fabricates super_admin, so a transient query failure
|
||||
// (connection reset, deadlock, statement timeout, pool exhaustion) must
|
||||
// not become a free privilege upgrade for every scoped admin. Rethrow →
|
||||
// outer catch → 401, which is already how every other transient DB fault
|
||||
// in this try block behaves (isTokenRevoked / isTokenBeforeCutoff both
|
||||
// hit the DB here). apiTokenAuth takes the same posture on the v1
|
||||
// surface, differing only in its 500.
|
||||
if (!isMissingRolesSchema(joinError)) throw joinError;
|
||||
// Fallback: roles table may not exist yet during upgrade
|
||||
// Query without role join - user will have no role info but can still authenticate
|
||||
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
|
||||
admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.select('id', 'username', 'email', 'password_changed_at')
|
||||
.first();
|
||||
if (admin) {
|
||||
admin.role_id = null;
|
||||
admin.role_name = 'super_admin'; // Assume super_admin for existing users during upgrade
|
||||
}
|
||||
}
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Check if password was changed after token was issued. JWT `iat` has
|
||||
// 1-second resolution; `password_changed_at` is sub-second. Floor the
|
||||
// comparison so a token issued in the *same* second as the password
|
||||
// change isn't incorrectly rejected — that race used to bite anyone
|
||||
// logging in immediately after a password reset/change.
|
||||
if (admin.password_changed_at) {
|
||||
const passwordChangedSeconds = Math.floor(
|
||||
new Date(admin.password_changed_at).getTime() / 1000
|
||||
);
|
||||
if (decoded.iat < passwordChangedSeconds) {
|
||||
logger.warn('Token used after password change', { userId: decoded.id });
|
||||
return res.status(401).json({
|
||||
error: 'Token invalid due to password change',
|
||||
code: 'PASSWORD_CHANGED'
|
||||
});
|
||||
}
|
||||
const admin = await sessionAccess.admin(decoded);
|
||||
const requestIp = req.ip || req.connection?.remoteAddress;
|
||||
if (decoded.ip && requestIp && decoded.ip !== requestIp) {
|
||||
logger.info('admin session IP changed', { accountId: admin.id, tokenIp: decoded.ip, requestIp });
|
||||
}
|
||||
|
||||
// Add user info to request (enhanced with role)
|
||||
@@ -146,7 +52,10 @@ async function adminAuth(req, res, next) {
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
res.status(error.statusCode || 401).json({
|
||||
error: error.isOperational ? error.message : 'Authentication failed',
|
||||
...(error.isOperational && { code: error.code }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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'];
|
||||
if (hasBody && !['application/json', 'multipart/form-data'].includes(contentType)) {
|
||||
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
const { requestLogPath } = require('../utils/requestLogPath');
|
||||
/**
|
||||
* Customer Authentication Middleware
|
||||
*
|
||||
@@ -10,10 +11,7 @@
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
|
||||
const sessionAccess = require('../services/sessionAccessService');
|
||||
const logger = require('../utils/logger');
|
||||
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
@@ -25,7 +23,7 @@ async function customerAuth(req, res, next) {
|
||||
// normal (page polling, pre-login session probes). Bump to debug
|
||||
// for noisy investigations only.
|
||||
logger.debug('[customerAuth] no token on request', {
|
||||
url: req.originalUrl,
|
||||
url: requestLogPath(req.originalUrl),
|
||||
hasCookieHeader: !!req.headers?.cookie,
|
||||
cookieKeys: Object.keys(req.cookies || {}),
|
||||
});
|
||||
@@ -42,7 +40,7 @@ async function customerAuth(req, res, next) {
|
||||
decoded = verified.payload;
|
||||
} catch (err) {
|
||||
logger.warn('[customerAuth] jwt verification failed', {
|
||||
url: req.originalUrl,
|
||||
url: requestLogPath(req.originalUrl),
|
||||
errorName: err.name,
|
||||
errorMessage: err.message,
|
||||
});
|
||||
@@ -52,71 +50,10 @@ async function customerAuth(req, res, next) {
|
||||
return res.status(401).json({ error: 'Invalid token', code: 'JWT_INVALID' });
|
||||
}
|
||||
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
logger.warn('[customerAuth] token revoked', {
|
||||
url: req.originalUrl,
|
||||
customerId: decoded.customerId,
|
||||
tokenType: decoded.type,
|
||||
iat: decoded.iat,
|
||||
});
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// Reject sessions issued before the global restore cutoff.
|
||||
if (await isTokenBeforeCutoff(decoded)) {
|
||||
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
|
||||
}
|
||||
|
||||
if (decoded.type !== 'customer') {
|
||||
logger.warn('[customerAuth] wrong token type', {
|
||||
url: req.originalUrl,
|
||||
tokenType: decoded.type,
|
||||
});
|
||||
return res.status(403).json({ error: 'Insufficient permissions', code: 'WRONG_TOKEN_TYPE' });
|
||||
}
|
||||
|
||||
// IP drift gets logged but doesn't reject — same lenient policy as
|
||||
// adminAuth. Customers may roam between mobile networks frequently.
|
||||
const currentIp = req.ip || req.connection.remoteAddress;
|
||||
if (decoded.ip && decoded.ip !== currentIp) {
|
||||
logger.info('Customer token used from different IP', {
|
||||
customerId: decoded.customerId,
|
||||
tokenIp: decoded.ip,
|
||||
currentIp,
|
||||
});
|
||||
}
|
||||
|
||||
const customer = await db('customer_accounts')
|
||||
.where({ id: decoded.customerId, is_active: formatBoolean(true) })
|
||||
.select('id', 'email', 'display_name', 'first_name', 'last_name', 'password_changed_at', 'preferred_language')
|
||||
.first();
|
||||
|
||||
if (!customer) {
|
||||
// Either deleted, deactivated, or the id was forged. 401 across the
|
||||
// board so the frontend session-expiry handler kicks in.
|
||||
logger.warn('[customerAuth] customer row not found / inactive', {
|
||||
url: req.originalUrl,
|
||||
customerId: decoded.customerId,
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid token', code: 'CUSTOMER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
if (customer.password_changed_at) {
|
||||
const passwordChangedSeconds = Math.floor(
|
||||
new Date(customer.password_changed_at).getTime() / 1000
|
||||
);
|
||||
if (decoded.iat < passwordChangedSeconds) {
|
||||
logger.warn('[customerAuth] token rejected: password_changed_at', {
|
||||
url: req.originalUrl,
|
||||
customerId: decoded.customerId,
|
||||
iat: decoded.iat,
|
||||
passwordChangedSeconds,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: 'Token invalid due to password change',
|
||||
code: 'PASSWORD_CHANGED',
|
||||
});
|
||||
}
|
||||
const customer = await sessionAccess.customer(decoded);
|
||||
const requestIp = req.ip || req.connection?.remoteAddress;
|
||||
if (decoded.ip && requestIp && decoded.ip !== requestIp) {
|
||||
logger.info('customer session IP changed', { accountId: customer.id, tokenIp: decoded.ip, requestIp });
|
||||
}
|
||||
|
||||
req.customer = {
|
||||
@@ -131,7 +68,10 @@ async function customerAuth(req, res, next) {
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Customer auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
res.status(error.statusCode || 401).json({
|
||||
error: error.isOperational ? error.message : 'Authentication failed',
|
||||
...(error.isOperational && { code: error.code }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { requestLogPath } = require('../utils/requestLogPath');
|
||||
/**
|
||||
* Global error handler middleware.
|
||||
* Catches all errors and returns standardized responses.
|
||||
@@ -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)));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const cleanupTimers = new Set();
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -211,7 +212,7 @@ function strictRateLimit(options = {}) {
|
||||
const store = new Map();
|
||||
|
||||
// Clean up old entries periodically
|
||||
setInterval(() => {
|
||||
const cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, data] of store.entries()) {
|
||||
if (data.resetTime < now) {
|
||||
@@ -219,6 +220,8 @@ function strictRateLimit(options = {}) {
|
||||
}
|
||||
}
|
||||
}, windowMs);
|
||||
cleanupTimer.unref();
|
||||
cleanupTimers.add(cleanupTimer);
|
||||
|
||||
return (req, res, next) => {
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
@@ -255,6 +258,7 @@ function strictRateLimit(options = {}) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dispose() { cleanupTimers.forEach(clearInterval); cleanupTimers.clear(); },
|
||||
feedbackRateLimit,
|
||||
strictRateLimit,
|
||||
generateGuestIdentifier,
|
||||
|
||||
@@ -1,267 +1,100 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { db } = require('../database/db');
|
||||
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 });
|
||||
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();
|
||||
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 db('events').where(slug ? { slug } : { id: decoded.eventId }).select('*').first();
|
||||
if (!event) return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
const grant = access.grant(event, decoded ? 'gallery' : 'public', decoded);
|
||||
await access.authorize(event, grant);
|
||||
attachAccess(req, event, grant);
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
if (!error.isOperational) logger.error('Error verifying gallery access', { error: error.message });
|
||||
return res.status(error.statusCode || 401).json({
|
||||
error: error.isOperational ? error.message : 'Invalid token',
|
||||
...(error.code && error.isOperational && { code: error.code }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
function canAccessEvent(admin, event) {
|
||||
return Boolean(admin && event && (admin.roleName === 'super_admin'
|
||||
|| event.created_by == null || Number(event.created_by) === Number(admin.id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to enforce event ownership for non-super_admin users.
|
||||
* Super admins bypass the check. Other admins can only access events they created.
|
||||
@@ -23,7 +28,7 @@ function requireEventOwnership(req, res, next) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
// Allow access if: event has no owner (legacy/system), or admin owns it
|
||||
if (event.created_by && event.created_by !== req.admin.id) {
|
||||
if (!canAccessEvent(req.admin, event)) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
next();
|
||||
@@ -165,6 +170,7 @@ function requireProjectOwnership(req, res, next) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canAccessEvent,
|
||||
requireEventOwnership,
|
||||
filterOwnedEventIds,
|
||||
scopeEventsQuery,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { requestLogPath } = require('../utils/requestLogPath');
|
||||
/**
|
||||
* Permission Checking Middleware for RBAC
|
||||
* Provides role-based access control with caching for performance
|
||||
@@ -143,7 +144,7 @@ function requirePermission(permissions, options = { requireAll: false }) {
|
||||
userId: req.admin.id,
|
||||
username: req.admin.username,
|
||||
requiredPermissions: permArray,
|
||||
path: req.path,
|
||||
path: requestLogPath(req.originalUrl || req.path),
|
||||
method: req.method
|
||||
});
|
||||
throw new ForbiddenError('Insufficient permissions');
|
||||
@@ -180,7 +181,7 @@ function requireSuperAdmin() {
|
||||
logger.warn('Super admin access denied', {
|
||||
userId: req.admin.id,
|
||||
username: req.admin.username,
|
||||
path: req.path,
|
||||
path: requestLogPath(req.originalUrl || req.path),
|
||||
method: req.method
|
||||
});
|
||||
throw new ForbiddenError('Super Admin access required');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { requestLogPath } = require('../utils/requestLogPath');
|
||||
const { db } = require('../database/db');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -10,12 +11,24 @@ class SecureImageMiddleware {
|
||||
this.suspiciousIPs = new Set();
|
||||
this.blockedFingerprints = new Set();
|
||||
this.rateLimitViolations = new Map();
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.cleanupTimer) return;
|
||||
this.cleanupTimer = setInterval(() => this.cleanup(), 300000);
|
||||
this.cleanupTimer.unref();
|
||||
}
|
||||
dispose() {
|
||||
clearInterval(this.cleanupTimer); this.cleanupTimer = null;
|
||||
this.suspiciousIPs.clear(); this.blockedFingerprints.clear(); this.rateLimitViolations.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main security middleware for image access
|
||||
*/
|
||||
secureImageAccess = async (req, res, next) => {
|
||||
this.start();
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const clientIP = this.getClientIP(req);
|
||||
@@ -56,7 +69,7 @@ class SecureImageMiddleware {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
path: requestLogPath(req.originalUrl || req.path)
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
@@ -328,7 +341,7 @@ class SecureImageMiddleware {
|
||||
client_ip: req.clientInfo?.ip || req.ip,
|
||||
client_fingerprint: req.clientInfo?.fingerprint,
|
||||
user_agent: req.get('User-Agent')?.substring(0, 255),
|
||||
request_path: req.path,
|
||||
request_path: requestLogPath(req.originalUrl || req.path),
|
||||
request_method: req.method,
|
||||
details: JSON.stringify(details),
|
||||
timestamp: new Date().toISOString()
|
||||
@@ -409,9 +422,4 @@ class SecureImageMiddleware {
|
||||
// Create singleton instance
|
||||
const secureImageMiddleware = new SecureImageMiddleware();
|
||||
|
||||
// Setup cleanup interval
|
||||
setInterval(() => {
|
||||
secureImageMiddleware.cleanup();
|
||||
}, 300000); // Every 5 minutes
|
||||
|
||||
module.exports = secureImageMiddleware;
|
||||
@@ -26,7 +26,7 @@ const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries
|
||||
// behaviour is unchanged: the timer fires every 5 min as long as
|
||||
// the server has anything else keeping the loop alive (HTTP server,
|
||||
// other intervals), which is always.
|
||||
setInterval(() => {
|
||||
const cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [token, lastActivity] of sessions.entries()) {
|
||||
if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) {
|
||||
@@ -208,6 +208,7 @@ function getActiveSessions() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dispose: () => { clearInterval(cleanupTimer); sessions.clear(); cachedTimeout = null; cacheExpiry = 0; },
|
||||
sessionTimeoutMiddleware,
|
||||
isSessionExpired,
|
||||
endSession,
|
||||
|
||||
@@ -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) => {
|
||||
@@ -298,571 +296,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);
|
||||
@@ -2029,8 +1469,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 +1670,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: db.fn.now()
|
||||
});
|
||||
|
||||
// Log activity
|
||||
|
||||
@@ -1,392 +1,9 @@
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Shared helpers + module-level caches used across the adminEvents sub-routers.
|
||||
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const logger = require('../../utils/logger');
|
||||
const { parseStringInput } = require('../../utils/parsers');
|
||||
const settings = require('../../services/eventSettings');
|
||||
|
||||
// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point
|
||||
const validateHeroImageAnchor = (value) => {
|
||||
if (['top', 'center', 'bottom'].includes(value)) return true;
|
||||
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
|
||||
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
|
||||
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
|
||||
}
|
||||
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
|
||||
};
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
|
||||
|
||||
// Helper to get event field requirements from settings
|
||||
const getEventFieldRequirements = async () => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'event_require_customer_name',
|
||||
'event_require_customer_email',
|
||||
'event_require_admin_email',
|
||||
'event_require_event_date',
|
||||
'event_require_expiration'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const requirements = {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
value = value === 'true';
|
||||
}
|
||||
}
|
||||
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
|
||||
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
|
||||
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
|
||||
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
|
||||
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
|
||||
});
|
||||
|
||||
return requirements;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get event field requirements', { error: error.message });
|
||||
return {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to read app_settings booleans by key, used to inherit per-setting
|
||||
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
|
||||
// so callers can fall back to a legacy default.
|
||||
/**
|
||||
* Decode an app_settings value into the JS value it represents.
|
||||
*
|
||||
* setting_value is JSON text on SQLite and may already be decoded by the
|
||||
* driver on a PG json column, so one parse does not normalise both. On top
|
||||
* of that, the Image Security tab used to PUT back values it had read
|
||||
* undecoded, wrapping another layer of quoting around each one on every
|
||||
* save — the GET handler decodes now, but installs carry however many
|
||||
* layers they accumulated before that.
|
||||
*
|
||||
* Every reader of app_settings has to agree about this, or the admin UI
|
||||
* shows one thing while event creation does another.
|
||||
*
|
||||
* Terminates: each parse of a string is strictly shorter than its input.
|
||||
*/
|
||||
const decodeSettingValue = (raw) => {
|
||||
let value = raw;
|
||||
while (typeof value === 'string') {
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(value); } catch { break; }
|
||||
if (parsed === value) break;
|
||||
value = parsed;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const readBooleanSetting = async (key) => {
|
||||
try {
|
||||
const setting = await db('app_settings').where('setting_key', key).first();
|
||||
if (!setting) return undefined;
|
||||
const value = decodeSettingValue(setting.setting_value);
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
} catch (error) {
|
||||
logger.error('Failed to read app setting', { key, error: error.message });
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to read the global "enable_devtools_protection" admin setting so
|
||||
// new events inherit it instead of always falling back to the DB column default
|
||||
// (#317 — admin disabled it globally but new events still got it ON).
|
||||
const getDownloadProtectionDefaults = async () => {
|
||||
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
|
||||
};
|
||||
|
||||
/**
|
||||
* The rest of Settings → Image security, as creation defaults (#1296).
|
||||
*
|
||||
* Four settings in that panel were written, reloaded and rendered as
|
||||
* controls, and read by nothing:
|
||||
*
|
||||
* default_protection_level → events.protection_level
|
||||
* default_image_quality → events.image_quality
|
||||
* enable_canvas_rendering → events.use_canvas_rendering
|
||||
*
|
||||
* Each maps onto a column migration 038 already created, and each is
|
||||
* labelled "… by default", so applying them at creation is what the panel
|
||||
* has always claimed to do. `enable_devtools_protection` above is the only
|
||||
* one of the five that was ever wired.
|
||||
*
|
||||
* Creation-time only, deliberately. Applying them to EXISTING events would
|
||||
* silently change live galleries on upgrade — an install with
|
||||
* enable_canvas_rendering already on would switch every grid to canvas
|
||||
* rendering, which is memory-expensive at scale and is the profile under
|
||||
* investigation in #1287. New events only; existing rows untouched.
|
||||
*
|
||||
* Any value that is missing or malformed comes back undefined so the caller
|
||||
* falls through to the column default, exactly as before this existed.
|
||||
*/
|
||||
const PROTECTION_LEVELS = ['basic', 'standard', 'enhanced', 'maximum'];
|
||||
|
||||
// parseInt would rescue malformed settings instead of rejecting them:
|
||||
// parseInt('72oops') is 72, parseInt(72.5) is 72, parseInt([72]) is 72.
|
||||
// That matters because the settings PUT stores whatever JSON it is handed
|
||||
// without validating the value (adminImageSecurity.js writes
|
||||
// JSON.stringify(value) for any allow-listed key), so those shapes really
|
||||
// can be sitting in app_settings. Accept only a genuine integer, or a
|
||||
// string that is exactly one.
|
||||
const toInteger = (value) => {
|
||||
if (typeof value === 'number') return Number.isInteger(value) ? value : undefined;
|
||||
if (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim())) return Number(value.trim());
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getImageSecurityDefaults = async (trx = null) => {
|
||||
const defaults = {};
|
||||
try {
|
||||
// Accepts a transaction the way getAppSetting does. It matters on
|
||||
// sqlite3, whose pool holds a single connection: a caller already inside
|
||||
// db.transaction() that read through the global `db` would block on the
|
||||
// connection its own transaction holds until the acquire timeout, and
|
||||
// the catch below would then quietly swallow it and drop the defaults.
|
||||
const query = trx || db;
|
||||
const rows = await query('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'default_protection_level',
|
||||
'default_image_quality',
|
||||
'enable_canvas_rendering',
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
// app_settings holds JSON text on SQLite, while a PG json column comes
|
||||
// back already decoded — so one parse is not enough to normalise both.
|
||||
// Worse, GET /api/admin/image-security/settings returns setting_value
|
||||
// without decoding it and the settings tab PUTs the whole fetched object
|
||||
// straight back through JSON.stringify, so opening the tab and saving
|
||||
// re-encodes every value it read as text. After one such round trip
|
||||
// `true` is stored as "\"true\"" and a single parse yields the string
|
||||
// 'true', which the type checks below reject — the settings would go
|
||||
// quietly dead again, which is the bug this whole change exists to fix.
|
||||
// The GET handler now decodes, so this stops accumulating — but installs
|
||||
// that already stacked N layers have to keep working, and N is however
|
||||
// many times someone opened that tab. So unwrap until it stops being a
|
||||
// JSON string rather than to a fixed depth; this terminates because each
|
||||
// parse of a string is strictly shorter than its input.
|
||||
const read = (key) => {
|
||||
const row = rows.find((r) => r.setting_key === key);
|
||||
if (!row) return undefined;
|
||||
return decodeSettingValue(row.setting_value);
|
||||
};
|
||||
|
||||
const level = read('default_protection_level');
|
||||
if (typeof level === 'string' && PROTECTION_LEVELS.includes(level)) {
|
||||
defaults.protection_level = level;
|
||||
}
|
||||
|
||||
// The column is an integer percentage; anything outside 1..100 is a
|
||||
// misconfiguration and falls through rather than being clamped into
|
||||
// something the operator did not choose.
|
||||
const quality = toInteger(read('default_image_quality'));
|
||||
if (quality !== undefined && quality >= 1 && quality <= 100) {
|
||||
defaults.image_quality = quality;
|
||||
}
|
||||
|
||||
const canvas = read('enable_canvas_rendering');
|
||||
if (typeof canvas === 'boolean') {
|
||||
defaults.use_canvas_rendering = canvas;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// A settings read must never block event creation; the column defaults
|
||||
// are a correct fallback.
|
||||
logger.error('Failed to read image-security defaults', { error: error.message });
|
||||
}
|
||||
return defaults;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the image-security columns for a NEW event: an explicit request
|
||||
* value wins, then the global default, then the column default (the key is
|
||||
* omitted entirely so the database supplies it).
|
||||
*
|
||||
* Shared by the admin create route and POST /api/v1/events so the configured
|
||||
* security level cannot depend on which entry point created the gallery —
|
||||
* the same split that made #592 (devtools) a separate bug from #317.
|
||||
*
|
||||
* `body` values are already validated by the route's express-validator
|
||||
* chain; `defaults` come from getImageSecurityDefaults(), which validates
|
||||
* them itself.
|
||||
*/
|
||||
const resolveImageSecurityColumns = (body = {}, defaults = {}) => {
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const columns = {};
|
||||
// express-validator runs isInt/isIn/isBoolean element-wise on arrays, so a
|
||||
// single-element array like `image_quality: [72]` passes the route's chain
|
||||
// and arrives here still an array. The routes reject those with
|
||||
// .not().isArray(); this guard means any future caller cannot write one
|
||||
// into a scalar column (a PG insert error, or `[false]` coerced to true).
|
||||
const scalar = (v) => (v !== null && typeof v === 'object' ? undefined : v);
|
||||
const pick = (key) => {
|
||||
const fromBody = scalar(body[key]);
|
||||
return fromBody !== undefined ? fromBody : defaults[key];
|
||||
};
|
||||
|
||||
const level = pick('protection_level');
|
||||
if (level !== undefined) columns.protection_level = level;
|
||||
|
||||
const quality = pick('image_quality');
|
||||
if (quality !== undefined) columns.image_quality = quality;
|
||||
|
||||
const canvas = pick('use_canvas_rendering');
|
||||
if (canvas !== undefined) columns.use_canvas_rendering = formatBoolean(canvas);
|
||||
|
||||
|
||||
return columns;
|
||||
};
|
||||
|
||||
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
|
||||
//
|
||||
// Note: `branding_logo_position` (header bar — left/center/right) is a
|
||||
// different concept from `hero_logo_position` (hero block — top/center/
|
||||
// bottom) and must NOT be mapped here. A previous version copied the
|
||||
// branding value over, which wrote 'left'/'right' into per-event
|
||||
// hero_logo_position columns and broke any subsequent PUT validation
|
||||
// (#357). Migration 084 heals existing rows.
|
||||
const getBrandingDefaults = async () => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_logo_display_hero',
|
||||
'branding_logo_size'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const defaults = {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top'
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_display_hero') {
|
||||
defaults.hero_logo_visible = value !== false;
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_size' && value) {
|
||||
defaults.hero_logo_size = value;
|
||||
}
|
||||
});
|
||||
|
||||
return defaults;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get branding defaults', { error: error.message });
|
||||
return {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
|
||||
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
|
||||
|
||||
// Whether the global "phone field" toggle (#322) is enabled. Cached for
|
||||
// the request via a module-level read; drift is acceptable since this
|
||||
// only governs whether to persist the field, not security boundaries.
|
||||
const isPhoneFieldEnabled = async () => {
|
||||
try {
|
||||
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
|
||||
if (!row) return false;
|
||||
let value = row.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch { /* keep raw */ }
|
||||
}
|
||||
return value === true;
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read event_phone_field_enabled', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const RECOVERABLE_PASSWORD_COLUMNS = ['password_recoverable', 'client_password_recoverable'];
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const {
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
customer_phone,
|
||||
// Bound only to exclude the secrets from `...rest` — never read.
|
||||
password_hash: _ph, client_password_hash: _cph,
|
||||
...rest
|
||||
} = event;
|
||||
// #1271 — the encrypted copies never leave the server except via
|
||||
// /:id/password. Removed by name (not destructured) so a secret scanner
|
||||
// does not read the binding as a hard-coded password.
|
||||
for (const column of RECOVERABLE_PASSWORD_COLUMNS) delete rest[column];
|
||||
|
||||
return {
|
||||
...rest,
|
||||
customer_name: customer_name ?? host_name ?? null,
|
||||
customer_email: customer_email ?? host_email ?? null,
|
||||
customer_phone: customer_phone ?? null
|
||||
};
|
||||
};
|
||||
|
||||
let customerColumnCache = null;
|
||||
const hasCustomerContactColumns = async () => {
|
||||
if (customerColumnCache === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
logger.debug('Failed to detect customer_email column', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Cascade-delete a single event: photos, audit/access logs, queued emails,
|
||||
// the event row itself (in one transaction), then the on-disk folder /
|
||||
// archive zip / hero logo (best-effort — file failures don't unwind the DB
|
||||
// changes since the source of truth is the database). Used by both the
|
||||
// per-event DELETE /:id route and the bulk-delete route to avoid drift.
|
||||
//
|
||||
// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the
|
||||
// bulk-delete loop can report it as a per-id failure without aborting the
|
||||
// whole batch. Any other error propagates and is the caller's problem.
|
||||
async function deleteEventCascade(eventId, adminContext) {
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (!event) {
|
||||
@@ -678,40 +295,4 @@ async function deleteEventCascade(eventId, adminContext) {
|
||||
return { id: event.id, name: event.event_name };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live
|
||||
// events that auto-picks-up new uploads (migration 138). Mirrors the
|
||||
// client-access second-token pattern: the link is minted on demand, rotatable
|
||||
// and disable-able, independent of the gallery password / share link.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Allowed slide transition styles (kept in sync with the SlideshowPage).
|
||||
// dipwhite/dipblack = fade through highlights / lowlights between images.
|
||||
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||||
// Allowed per-slide color filters.
|
||||
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
||||
// Allowed slideshow play orders (#202). 'chronological' = upload order,
|
||||
// 'random' = client-side shuffle.
|
||||
const SLIDESHOW_ORDERS = ['chronological', 'random'];
|
||||
module.exports = {
|
||||
RECOVERABLE_PASSWORD_COLUMNS,
|
||||
validateHeroImageAnchor,
|
||||
getStoragePath,
|
||||
getEventFieldRequirements,
|
||||
readBooleanSetting,
|
||||
decodeSettingValue,
|
||||
getDownloadProtectionDefaults,
|
||||
getImageSecurityDefaults,
|
||||
resolveImageSecurityColumns,
|
||||
getBrandingDefaults,
|
||||
getCustomerNameFromPayload,
|
||||
getCustomerEmailFromPayload,
|
||||
getCustomerPhoneFromPayload,
|
||||
isPhoneFieldEnabled,
|
||||
mapEventForApi,
|
||||
hasCustomerContactColumns,
|
||||
deleteEventCascade,
|
||||
SLIDESHOW_ORDERS,
|
||||
SLIDESHOW_TRANSITIONS,
|
||||
SLIDESHOW_COLORFILTERS,
|
||||
};
|
||||
module.exports = { ...settings, deleteEventCascade };
|
||||
|
||||
+21
-100
@@ -1,3 +1,4 @@
|
||||
const { isGalleryAvailable } = require('../utils/galleryLifecycle');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
@@ -420,7 +421,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);
|
||||
@@ -545,7 +546,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' });
|
||||
}
|
||||
@@ -631,14 +632,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' });
|
||||
}
|
||||
@@ -743,106 +744,26 @@ router.get('/session', async (req, res) => {
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
// Check if token has been revoked (e.g. after logout)
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
return res.status(401).json({ valid: false, error: 'Session has been invalidated' });
|
||||
}
|
||||
|
||||
// The redirect loop reported on the v3.32.4-beta.0 release came
|
||||
// from /auth/session reporting valid: true while the protected
|
||||
// adminAuth / galleryAuth middleware rejected the same token for
|
||||
// reasons /auth/session never checked: the admin user was
|
||||
// deactivated, the admin's password had been changed since iat,
|
||||
// or the gallery event was archived/deleted. Mirror those checks
|
||||
// here so the session endpoint is always at least as strict as
|
||||
// what the protected endpoints will enforce next.
|
||||
// Full user payload for admin sessions — the SSO callback establishes
|
||||
// the session via redirect (no JSON response the SPA could store), so
|
||||
// session restoration must be able to hydrate the user object (#798).
|
||||
const sessions = require('../services/sessionAccessService');
|
||||
let adminUser = null;
|
||||
|
||||
if (decoded.type === 'admin') {
|
||||
let admin = null;
|
||||
try {
|
||||
admin = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
|
||||
.select(
|
||||
'admin_users.id', 'admin_users.username', 'admin_users.email',
|
||||
'admin_users.password_changed_at', 'admin_users.must_change_password',
|
||||
'roles.name as role_name', 'roles.display_name as role_display_name'
|
||||
)
|
||||
.first();
|
||||
} catch (lookupErr) {
|
||||
// admin_users table not present (test fixture, fresh DB) — fall
|
||||
// through and trust the token. Real deployments always have it.
|
||||
admin = null;
|
||||
// intentional swallow; if the table is missing we do not want
|
||||
// to fail-closed during e.g. early bootstrap.
|
||||
}
|
||||
|
||||
if (admin === null) {
|
||||
// Lookup didn't run because the table is missing; skip the
|
||||
// existence/password checks and treat the token as valid.
|
||||
} else if (!admin) {
|
||||
return res.json({ valid: false, error: 'Admin account no longer active' });
|
||||
} else if (admin.password_changed_at) {
|
||||
const passwordChangedSeconds = Math.floor(
|
||||
new Date(admin.password_changed_at).getTime() / 1000
|
||||
);
|
||||
if (decoded.iat < passwordChangedSeconds) {
|
||||
return res.json({ valid: false, error: 'Token invalid due to password change' });
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the session-timeout check that sessionTimeoutMiddleware
|
||||
// enforces on every /api/admin endpoint. Without this, /auth/session
|
||||
// returns valid:true for an idle/old-iat token that protected
|
||||
// endpoints reject with 401 SESSION_TIMEOUT — the same redirect-loop
|
||||
// shape as the issuer-claim and password-change asymmetries (issue
|
||||
// #350 recurrence on v3.39.1-beta.0).
|
||||
try {
|
||||
const { isSessionExpired } = require('../middleware/sessionTimeout');
|
||||
if (await isSessionExpired(token, decoded)) {
|
||||
return res.json({ valid: false, error: 'Session expired' });
|
||||
}
|
||||
} catch (timeoutErr) {
|
||||
// Helper lookup failed (test stub may not export it) — fall through
|
||||
// and trust the token. Real deployments always have the middleware.
|
||||
}
|
||||
|
||||
if (admin) {
|
||||
adminUser = {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : null
|
||||
};
|
||||
const admin = await sessions.admin(decoded, { includeProfile: true });
|
||||
const { isSessionExpired } = require('../middleware/sessionTimeout');
|
||||
if (await isSessionExpired(token, decoded)) {
|
||||
return res.json({ valid: false, error: 'Session expired' });
|
||||
}
|
||||
adminUser = {
|
||||
id: admin.id, username: admin.username, email: admin.email,
|
||||
mustChangePassword: !!admin.must_change_password,
|
||||
role: admin.role_name ? { name: admin.role_name, displayName: admin.role_display_name } : null,
|
||||
};
|
||||
} else if (decoded.type === 'gallery') {
|
||||
try {
|
||||
const event = await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false),
|
||||
})
|
||||
.first();
|
||||
if (!event) {
|
||||
return res.json({ valid: false, error: 'Gallery no longer available' });
|
||||
}
|
||||
if (event.expires_at && new Date(event.expires_at) < new Date()) {
|
||||
return res.json({ valid: false, error: 'Gallery has expired' });
|
||||
}
|
||||
} catch (galleryLookupErr) {
|
||||
// events table missing in this context — same fallback as
|
||||
// admin path; trust the token rather than fail-closed.
|
||||
}
|
||||
const access = require('../services/galleryAccessService');
|
||||
const event = await db('events').where({ id: decoded.eventId }).first();
|
||||
if (!event) return res.json({ valid: false, error: 'Gallery no longer available' });
|
||||
await access.authorize(event, access.grant(event, 'gallery', decoded));
|
||||
} else {
|
||||
return res.status(403).json({ valid: false, error: 'Invalid token type' });
|
||||
}
|
||||
|
||||
// Calculate remaining time
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { isGalleryAvailable, isGalleryExpired } = require('../utils/galleryLifecycle');
|
||||
/**
|
||||
* Customer dashboard routes
|
||||
*
|
||||
@@ -165,10 +166,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
|
||||
@@ -191,8 +194,7 @@ router.get('/events/:slug/access-token', [
|
||||
type: 'gallery',
|
||||
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, {
|
||||
|
||||
+10
-3359
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,945 @@
|
||||
const express = require('express');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
const archiver = require('archiver');
|
||||
const path = require('path');
|
||||
const { resolvePhotoContentType } = require('../../utils/photoContentType');
|
||||
const router = express.Router();
|
||||
const watermarkService = require('../../services/watermarkService');
|
||||
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
|
||||
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||
const logger = require('../../utils/logger');
|
||||
const { pipeStreamToResponse } = require('../../utils/streamResponse');
|
||||
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../../services/photoResolver');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { blockHiddenGallery } = require('../../utils/revealMode');
|
||||
const downloadZipService = require('../../services/downloadZipService');
|
||||
const { renderPhotoForDownload, resolveWatermarkSettings } = require('../../services/downloadRendition');
|
||||
const downloadJobService = require('../../services/downloadJobService');
|
||||
const {
|
||||
resolveEventDownloadPolicy,
|
||||
pickRequestedResolution,
|
||||
parseResolution,
|
||||
} = require('../../utils/downloadResolutions');
|
||||
const { applyPhotoVisibilityFilter, canSeeHiddenPhotos } = require('../../utils/photoVisibility');
|
||||
const {
|
||||
getUseOriginalFilenames,
|
||||
pickRawDownloadName,
|
||||
getZipEntryNames,
|
||||
} = require('../../services/downloadFilenameService');
|
||||
const { buildContentDisposition } = require('../../utils/filenameSanitizer');
|
||||
const { getStorage } = require('../../services/storage');
|
||||
const fs = require('fs');
|
||||
function parseByteRange(header, size) {
|
||||
if (!header || typeof header !== 'string' || !size) return null;
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const [, rawStart, rawEnd] = match;
|
||||
if (rawStart === '' && rawEnd === '') return null;
|
||||
|
||||
let start;
|
||||
let end;
|
||||
if (rawStart === '') {
|
||||
// Suffix form: the last N bytes.
|
||||
const suffix = parseInt(rawEnd, 10);
|
||||
if (!suffix) return null;
|
||||
start = Math.max(0, size - suffix);
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = parseInt(rawStart, 10);
|
||||
end = rawEnd === '' ? size - 1 : parseInt(rawEnd, 10);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
if (start > end || start >= size) return null;
|
||||
return { start, end: Math.min(end, size - 1) };
|
||||
}
|
||||
function galleryActor(req) {
|
||||
// Portal tokens run as accessLevel 'guest' but carry via:'customer'
|
||||
// (req.viaCustomer); PIN-client logins carry accessLevel 'client'.
|
||||
// Both are customers, not guests (codex review of #849, final round).
|
||||
const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client'));
|
||||
return { type: isCustomer ? 'customer' : 'guest' };
|
||||
}
|
||||
const SINGLE_DOWNLOAD_DEBOUNCE_MS = 60 * 60 * 1000;
|
||||
const singleDownloadNotifiedAt = new Map();
|
||||
function notifySinglePhotoDownload(event, req) {
|
||||
const now = Date.now();
|
||||
const last = singleDownloadNotifiedAt.get(event.id) || 0;
|
||||
if (now - last < SINGLE_DOWNLOAD_DEBOUNCE_MS) return;
|
||||
singleDownloadNotifiedAt.set(event.id, now);
|
||||
logActivity('gallery_downloaded', { scope: 'single' }, event.id, galleryActor(req));
|
||||
}
|
||||
|
||||
router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
// Check if downloads are allowed for this event
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Per-category download permission (#640). Photos without a category are
|
||||
// always downloadable when the event allows downloads — only categorised
|
||||
// photos can opt out per-category.
|
||||
if (photo.category_id) {
|
||||
const cat = await db('photo_categories')
|
||||
.where('id', photo.category_id)
|
||||
.first('allow_downloads');
|
||||
if (cat && !parseBooleanInput(cat.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this category' });
|
||||
}
|
||||
}
|
||||
|
||||
// Download resolution (#858). Resolved BEFORE the counters below: a
|
||||
// rejected resolution must not inflate download stats, which a guest
|
||||
// could otherwise do by replaying ?resolution=bogus.
|
||||
const isVideo = photo.media_type === 'video'
|
||||
|| (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
const policy = await resolveEventDownloadPolicy(req.event);
|
||||
const requested = pickRequestedResolution(policy, req.query.resolution);
|
||||
if (requested === null) {
|
||||
return res.status(400).json({ error: 'Resolution not available for this gallery' });
|
||||
}
|
||||
const box = isVideo ? null : parseResolution(requested);
|
||||
|
||||
// A HEAD is a metadata probe, not a download. Answering it below the
|
||||
// counters recorded every probe as a real download, and answering it below
|
||||
// renderPhotoForDownload fetched and watermarked an image whose body Node
|
||||
// then discards. Both happen before this point in a GET, so HEAD leaves
|
||||
// here — with no side effects and no bytes read.
|
||||
if (req.method === 'HEAD') {
|
||||
const headUseOriginal = await getUseOriginalFilenames();
|
||||
const headHeaders = {
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': buildContentDisposition(pickRawDownloadName(photo, headUseOriginal)),
|
||||
'Accept-Ranges': 'bytes',
|
||||
};
|
||||
|
||||
// Content-Length only when the bytes ship untransformed AND the size can
|
||||
// be read without fetching them. A watermark or resize changes the
|
||||
// length, and the only way to learn the new one is to do the work this
|
||||
// branch exists to avoid — HEAD is allowed to omit it.
|
||||
const headWatermark = await resolveWatermarkSettings(req.event);
|
||||
if (!box && !headWatermark) {
|
||||
try {
|
||||
const headKey = resolvePhotoStorageKey(req.event, photo);
|
||||
const headStorage = getStorage();
|
||||
if (headKey && headStorage.kind() !== 'local') {
|
||||
const headStat = await headStorage.stat(headKey);
|
||||
if (!headStat) return res.status(404).json({ error: 'Photo file not found' });
|
||||
headHeaders['Content-Length'] = headStat.size;
|
||||
if (headStat.mtime) headHeaders['Last-Modified'] = new Date(headStat.mtime).toUTCString();
|
||||
}
|
||||
} catch (headErr) {
|
||||
// No length is a valid HEAD; not worth failing the probe over.
|
||||
logger.debug('HEAD probe could not stat the object', { photoId, error: headErr.message });
|
||||
}
|
||||
}
|
||||
|
||||
res.set(headHeaders);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
// Admin preview (#868) downloads are excluded from the download count +
|
||||
// guest analytics — kept out of client-facing stats.
|
||||
if (!req.isAdminPreview) {
|
||||
// Update download count
|
||||
await db('photos').where('id', photoId).increment('download_count', 1);
|
||||
|
||||
// Log download
|
||||
await db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download',
|
||||
photo_id: photoId
|
||||
});
|
||||
}
|
||||
// Surface in the admin notification bell (#746) — debounced, and only
|
||||
// once the response actually finished: notifying up-front would log a
|
||||
// download that then 404s/fails and the debounce would suppress the
|
||||
// next real one for an hour (codex review of #849).
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode < 400 && !req.isAdminPreview) notifySinglePhotoDownload(req.event, req);
|
||||
});
|
||||
|
||||
// #493: if the admin enabled "use original filenames", surface the
|
||||
// pre-rename camera filename in Content-Disposition. Storage path is
|
||||
// unchanged — only the user-visible download name is swapped.
|
||||
const useOriginal = await getUseOriginalFilenames();
|
||||
const downloadName = pickRawDownloadName(photo, useOriginal);
|
||||
const contentDisposition = buildContentDisposition(downloadName);
|
||||
|
||||
// The gallery's standard applies to EVERY ordinary download, single photos
|
||||
// included — otherwise a lowered standard is trivially bypassed by
|
||||
// downloading photos one at a time. `box` was resolved above, before the
|
||||
// counters. Videos have no resize path and always ship as-is.
|
||||
//
|
||||
// renderPhotoForDownload (#858) owns the resize-then-watermark ordering
|
||||
// and the storage fetch, and is what the zip builders below already use.
|
||||
// It returns null when the photo needs no transformation at all, which is
|
||||
// the default gallery's common case and lets us ship the stored bytes
|
||||
// without buffering a full-size original into memory.
|
||||
const effectiveSettings = await resolveWatermarkSettings(req.event);
|
||||
|
||||
let rendered;
|
||||
try {
|
||||
rendered = await renderPhotoForDownload(req.event, photo, box, effectiveSettings);
|
||||
} catch (renderError) {
|
||||
// Classify, the same way the pass-through branch below does. This can
|
||||
// reject because the source object is gone, but equally because
|
||||
// getToFile timed out, the tmp filesystem filled up, or sharp failed —
|
||||
// and reporting an operational failure as 404 tells the guest their
|
||||
// photo does not exist and tells us nothing.
|
||||
const gone = renderError.code === 'ENOENT'
|
||||
|| renderError.name === 'NoSuchKey'
|
||||
|| renderError.name === 'NotFound'
|
||||
|| renderError.$metadata?.httpStatusCode === 404;
|
||||
logger.error('Failed to render photo for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: renderError.message,
|
||||
});
|
||||
return gone
|
||||
? res.status(404).json({ error: 'Photo file not found' })
|
||||
: res.status(500).json({ error: 'Failed to download photo' });
|
||||
}
|
||||
|
||||
if (rendered) {
|
||||
res.set({
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': contentDisposition,
|
||||
'Content-Length': rendered.length
|
||||
});
|
||||
|
||||
return res.send(rendered);
|
||||
}
|
||||
|
||||
// Untransformed: ship the stored bytes.
|
||||
//
|
||||
// Managed photos live behind the storage abstraction and on an S3/R2
|
||||
// deployment are not on local disk at all — resolving a filesystem path
|
||||
// unconditionally here is what made every single-photo download 404 with
|
||||
// ENOENT in S3 mode (#1048), while download-all and secure-images worked
|
||||
// because they already went through getStorage().
|
||||
//
|
||||
// resolvePhotoStorageKey returns null for external/reference photos: those
|
||||
// live on a local mount and keep the sendFile path.
|
||||
let storageKey = null;
|
||||
try {
|
||||
storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo storage key for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
if (storageKey && storage.kind() !== 'local') {
|
||||
// Deliberately NOT the local path: res.sendFile emits Content-Length,
|
||||
// Accept-Ranges, ETag and Last-Modified and answers Range requests with
|
||||
// a 206, and a bare stream.pipe(res) has none of that. On local disk
|
||||
// sendFile stays the better implementation, so it stays the branch.
|
||||
//
|
||||
// On S3 we reproduce the parts that matter for a download: the length
|
||||
// (browsers need it for the progress indicator, which matters most on
|
||||
// exactly the large files this route serves) and Range, so an
|
||||
// interrupted download resumes instead of appending a second full body
|
||||
// onto the partial file. Conditional requests are not reproduced —
|
||||
// there is no ETag here, so a client revalidating gets the whole body,
|
||||
// same as it does today.
|
||||
const stat = await storage.stat(storageKey);
|
||||
if (!stat) {
|
||||
logger.error('Photo not found in storage backend for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
storageKey,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
const lastModified = stat.mtime ? new Date(stat.mtime).toUTCString() : null;
|
||||
const headers = {
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': contentDisposition,
|
||||
'Accept-Ranges': 'bytes',
|
||||
};
|
||||
if (lastModified) headers['Last-Modified'] = lastModified;
|
||||
|
||||
// If-Range: a client resuming an interrupted download sends back the
|
||||
// validator it was given last time. If the object has been replaced
|
||||
// since — the watcher re-importing a swapped file, an admin re-upload —
|
||||
// answering 206 from the NEW bytes lets the client splice two different
|
||||
// versions into one corrupt file. A validator that doesn't match means
|
||||
// a full 200, which is the whole point of the header.
|
||||
const ifRange = req.headers['if-range'];
|
||||
const staleValidator = !!ifRange && (!lastModified || ifRange.trim() !== lastModified);
|
||||
const range = staleValidator ? null : parseByteRange(req.headers.range, stat.size);
|
||||
|
||||
// Open the stream BEFORE any header is staged or sent. stat() succeeding
|
||||
// does not mean get() will: a concurrent delete or replace, or a
|
||||
// transient backend error, lands here. Once writeHead(206) has gone out
|
||||
// the outer catch can do nothing but throw ERR_HTTP_HEADERS_SENT, and in
|
||||
// the non-range case it would send its 500 JSON underneath the staged
|
||||
// image/jpeg attachment headers — a .jpg file full of JSON.
|
||||
let stream;
|
||||
try {
|
||||
stream = range
|
||||
? await storage.getRange(storageKey, range.start, range.end)
|
||||
: await storage.get(storageKey);
|
||||
} catch (fetchError) {
|
||||
const gone = fetchError.code === 'ENOENT'
|
||||
|| fetchError.name === 'NoSuchKey'
|
||||
|| fetchError.name === 'NotFound'
|
||||
|| fetchError.$metadata?.httpStatusCode === 404;
|
||||
logger.error('Failed to open photo stream for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
storageKey,
|
||||
error: fetchError.message,
|
||||
});
|
||||
return gone
|
||||
? res.status(404).json({ error: 'Photo file not found' })
|
||||
: res.status(500).json({ error: 'Failed to download photo' });
|
||||
}
|
||||
|
||||
if (range) {
|
||||
// status()+set() rather than writeHead(): writeHead commits the
|
||||
// response immediately, so a stream that resolves and THEN errors
|
||||
// before its first chunk would leave pipeStreamToResponse able only to
|
||||
// destroy the connection. Staged headers are flushed by the first body
|
||||
// write, which means an error at byte zero can still clear them and
|
||||
// return a clean, retryable status instead of a transport reset.
|
||||
res.status(206).set({
|
||||
...headers,
|
||||
'Content-Range': `bytes ${range.start}-${range.end}/${stat.size}`,
|
||||
'Content-Length': (range.end - range.start) + 1,
|
||||
});
|
||||
} else {
|
||||
res.set({ ...headers, 'Content-Length': stat.size });
|
||||
}
|
||||
pipeStreamToResponse(stream, res, {
|
||||
context: range ? `download range for photo ${photo.id}` : `download for photo ${photo.id}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath;
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// res.download() builds Content-Disposition itself but doesn't emit the
|
||||
// RFC 5987 filename* parameter, so unicode camera filenames would lose
|
||||
// their bytes on download. Set the header explicitly and stream the
|
||||
// file with res.sendFile-equivalent semantics.
|
||||
res.set({
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': contentDisposition,
|
||||
});
|
||||
res.sendFile(filePath, (downloadError) => {
|
||||
if (downloadError) {
|
||||
logger.error('Error streaming gallery download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: downloadError.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to download photo');
|
||||
}
|
||||
});
|
||||
|
||||
// Download all photos as ZIP
|
||||
// Zip downloads count toward each contained photo's download_count (#895)
|
||||
// — previously only single-photo downloads did, so galleries whose guests
|
||||
// grab the zip showed 0 per-photo downloads forever. Used by the
|
||||
// pre-generated-zip branches only: it mirrors downloadZipService._build,
|
||||
// which zips EVERY event photo with no per-category allow_downloads
|
||||
// filter — the counter has to reflect what actually shipped. (That the
|
||||
// prebuilt zip ignores per-category download opt-outs is a separate,
|
||||
// pre-existing issue.) Known approximation: _build skips entries whose
|
||||
// WATERMARK step fails and still publishes the zip; counting those
|
||||
// would need a persisted archive manifest, which isn't worth it for
|
||||
// that tail case. Fire-and-forget at the call sites: counters must
|
||||
// never fail a download.
|
||||
async function bumpEventDownloadCounts(eventId) {
|
||||
await db('photos').where('event_id', eventId).increment('download_count', 1);
|
||||
}
|
||||
|
||||
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||
try {
|
||||
// Check if downloads are allowed for this event
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
// Try to serve pre-generated zip (instant download with Content-Length).
|
||||
// Guests may use the prebuilt cache ONLY when the event has no hidden
|
||||
// photos: a cache built before a photo was hidden — or before this
|
||||
// visibility-aware builder shipped — could otherwise still leak it, and
|
||||
// getZipInfo only checks the DB pointer + file stat, not freshness. When
|
||||
// hidden photos exist, guests fall through to the visibility-filtered
|
||||
// stream below. PIN-clients always stream a full archive.
|
||||
const isClient = canSeeHiddenPhotos(req.accessLevel);
|
||||
const eventHasHidden = await db('photos')
|
||||
.where({ event_id: req.event.id, visibility: 'hidden' })
|
||||
.first()
|
||||
.then(Boolean);
|
||||
const zipInfo = (isClient || eventHasHidden)
|
||||
? null
|
||||
: await downloadZipService.getZipInfo(req.event.id);
|
||||
if (zipInfo) {
|
||||
const storage = getStorage();
|
||||
|
||||
// Stream via the authenticated route so logout, restore and account
|
||||
// changes are checked on every download, including S3-backed archives.
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Length', zipInfo.size);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
|
||||
const stream = await storage.get(zipInfo.key);
|
||||
pipeStreamToResponse(stream, res, { context: `prepared zip for event ${req.event.id}`, missingStatus: 410 });
|
||||
|
||||
// Log bulk download (admin preview #868 excluded — stats stay client-only).
|
||||
if (!req.isAdminPreview) {
|
||||
db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_all'
|
||||
}).catch(() => {});
|
||||
bumpEventDownloadCounts(req.event.id).catch(() => {});
|
||||
// Surface in the admin notification bell (#746) — only once the
|
||||
// stream actually finished; logging at pipe-time would report
|
||||
// downloads that then broke mid-transfer (codex review of #849).
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: on-the-fly streaming (existing behavior). Only pre-build the
|
||||
// guest cache when it will actually be served next time — a guest
|
||||
// download of an event with no hidden photos. Client bypasses and
|
||||
// hidden-photo events always stream, so rebuilding the guest archive on
|
||||
// those requests is wasted I/O (codex review).
|
||||
if (!isClient && !eventHasHidden) {
|
||||
downloadZipService.generateZip(req.event.id).catch(err =>
|
||||
logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message })
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch photos — exclude photos in categories that disabled downloads (#640).
|
||||
// Uncategorised photos are always included; categories without the column
|
||||
// (pre-migration-135) fall through the LEFT JOIN's null and are included.
|
||||
const photos = await applyPhotoVisibilityFilter(
|
||||
db('photos')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.where('photos.event_id', req.event.id)
|
||||
.where(function () {
|
||||
this.whereNull('photos.category_id')
|
||||
.orWhere('photo_categories.allow_downloads', true)
|
||||
.orWhereNull('photo_categories.allow_downloads');
|
||||
}),
|
||||
req.accessLevel
|
||||
)
|
||||
.select('photos.*')
|
||||
.orderBy('photos.type', 'asc')
|
||||
.orderBy('photos.uploaded_at', 'desc');
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.status(404).json({ error: 'No photos found' });
|
||||
}
|
||||
|
||||
// Count unique types
|
||||
const uniqueTypes = new Set(photos.map(p => p.type)).size;
|
||||
const hasMultipleTypes = uniqueTypes > 1;
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||
archive.on('error', (err) => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
archive.pipe(res);
|
||||
|
||||
// Get watermark settings - apply if global setting OR event-level setting is enabled
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
|
||||
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
|
||||
const effectiveSettings = shouldApplyWatermark ? {
|
||||
...watermarkSettings,
|
||||
enabled: true,
|
||||
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
||||
} : null;
|
||||
|
||||
// The gallery's standard resolution applies to the streamed archive too,
|
||||
// not only the cached one (#858).
|
||||
const { standardBox: bulkBox } = await resolveEventDownloadPolicy(req.event);
|
||||
|
||||
// Add photos to archive — managed photos via storage backend, external via local path.
|
||||
const { resolvePhotoStorageKey } = require('../../services/photoResolver');
|
||||
const storage = getStorage();
|
||||
// #493: resolve a unique display filename per photo up-front so collisions
|
||||
// get a deterministic `_1` suffix before the entries hit the archive.
|
||||
const useOriginalBulk = await getUseOriginalFilenames();
|
||||
const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk);
|
||||
// Only photos whose append succeeded count as downloaded (#895) — the
|
||||
// catch below deliberately skips missing/corrupt sources, and those
|
||||
// never make it into the archive.
|
||||
const appendedIds = [];
|
||||
for (let i = 0; i < photos.length; i += 1) {
|
||||
const photo = photos[i];
|
||||
const storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||
const entryName = bulkEntryNames[i];
|
||||
let archiveName;
|
||||
if (hasMultipleTypes) {
|
||||
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
||||
archiveName = path.join(folderName, entryName);
|
||||
} else {
|
||||
archiveName = entryName;
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the source exists BEFORE appending — but only for local
|
||||
// sources: fs.createReadStream is lazy, so its error fires outside
|
||||
// this try/catch and the archive 'error' handler then kills the
|
||||
// whole response instead of skipping one photo (#895 review). S3's
|
||||
// get() awaits GetObject and rejects right here on a missing key,
|
||||
// so a preflight HEAD per entry would just be a redundant serial
|
||||
// round trip (500-photo zip = 500 extra HEADs).
|
||||
if (storageKey && storage.kind() === 'local') {
|
||||
const srcStat = await storage.stat(storageKey);
|
||||
if (!srcStat) {
|
||||
throw new Error(`Photo missing in storage: ${storageKey}`);
|
||||
}
|
||||
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
|
||||
throw new Error('Photo file missing on disk');
|
||||
}
|
||||
|
||||
// Resize to the gallery's standard resolution (#858) and/or watermark.
|
||||
// This branch runs whenever the cached zip isn't usable — the first
|
||||
// download after an invalidation, PIN clients, and galleries with
|
||||
// hidden photos all land here, so skipping the cap would leak
|
||||
// full-resolution files for exactly those cases.
|
||||
const rendered = await renderPhotoForDownload(req.event, photo, bulkBox, effectiveSettings);
|
||||
if (rendered) {
|
||||
archive.append(rendered, { name: archiveName });
|
||||
} else if (storageKey) {
|
||||
const stream = await storage.get(storageKey);
|
||||
archive.append(stream, { name: archiveName });
|
||||
} else {
|
||||
archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName });
|
||||
}
|
||||
appendedIds.push(photo.id);
|
||||
} catch (err) {
|
||||
logger.warn('Skipping photo in bulk download due to error', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Notification only after the response actually finished — finalize()
|
||||
// ends Archiver's input, not the HTTP transfer (codex review of #849,
|
||||
// confirmation round). Registered before finalize so it can't be missed.
|
||||
// Admin preview (#868) streams the archive but is excluded from stats.
|
||||
if (!req.isAdminPreview) {
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
|
||||
});
|
||||
}
|
||||
await archive.finalize();
|
||||
|
||||
if (!req.isAdminPreview) {
|
||||
// Log bulk download
|
||||
await db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_all'
|
||||
});
|
||||
// Exactly the photos that made it into this archive (#895) — skipped
|
||||
// (missing/corrupt) sources don't count.
|
||||
if (appendedIds.length > 0) {
|
||||
db('photos').whereIn('id', appendedIds)
|
||||
.increment('download_count', 1).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to create download archive');
|
||||
}
|
||||
});
|
||||
|
||||
// Download selected photos as ZIP
|
||||
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||
try {
|
||||
// Check if downloads are allowed for this event
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
const ids = Array.isArray(req.body?.photo_ids) ? req.body.photo_ids : [];
|
||||
if (!ids.length) {
|
||||
return res.status(400).json({ error: 'photo_ids is required (non-empty array)' });
|
||||
}
|
||||
|
||||
// Clean IDs
|
||||
const photoIds = ids
|
||||
.map((v) => parseInt(v, 10))
|
||||
.filter((v) => Number.isInteger(v))
|
||||
.slice(0, 500);
|
||||
|
||||
if (photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'No valid photo IDs provided' });
|
||||
}
|
||||
|
||||
// Fetch photos — exclude photos in categories that disabled downloads (#640).
|
||||
// Same LEFT JOIN pattern as the download-all endpoint.
|
||||
const photos = await applyPhotoVisibilityFilter(
|
||||
db('photos')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.where('photos.event_id', req.event.id)
|
||||
.whereIn('photos.id', photoIds)
|
||||
.where(function () {
|
||||
this.whereNull('photos.category_id')
|
||||
.orWhere('photo_categories.allow_downloads', true)
|
||||
.orWhereNull('photo_categories.allow_downloads');
|
||||
}),
|
||||
req.accessLevel
|
||||
)
|
||||
.select('photos.*')
|
||||
.orderBy('photos.uploaded_at', 'desc');
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.status(404).json({ error: 'No photos found for selected IDs' });
|
||||
}
|
||||
|
||||
// Download resolution (#858). Resolve BEFORE any header goes out — once
|
||||
// the archive starts streaming we can no longer return a JSON error.
|
||||
const selectedPolicy = await resolveEventDownloadPolicy(req.event);
|
||||
const selectedResolution = pickRequestedResolution(selectedPolicy, req.body?.resolution);
|
||||
if (selectedResolution === null) {
|
||||
return res.status(400).json({ error: 'Resolution not available for this gallery' });
|
||||
}
|
||||
const selectedBox = parseResolution(selectedResolution);
|
||||
|
||||
const archiveName = `${req.event.slug}-selected.zip`;
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||
archive.on('error', (err) => {
|
||||
logger.error('Zip error generating selected download', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: err.message,
|
||||
});
|
||||
try {
|
||||
res.status(500).end();
|
||||
} catch (_) {
|
||||
// ignore double-send errors
|
||||
}
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
// Check watermark settings - apply if global setting OR event-level setting is enabled
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
|
||||
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
|
||||
const effectiveSettings = shouldApplyWatermark ? {
|
||||
...watermarkSettings,
|
||||
enabled: true,
|
||||
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
||||
} : null;
|
||||
|
||||
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../../services/photoResolver');
|
||||
const selectedStorage = getStorage();
|
||||
// #493: same display-name resolution as bulk download, with dedup.
|
||||
const useOriginalSelected = await getUseOriginalFilenames();
|
||||
const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected);
|
||||
// Only photos whose append succeeded count as downloaded (#895).
|
||||
const appendedIds = [];
|
||||
for (let i = 0; i < photos.length; i += 1) {
|
||||
const photo = photos[i];
|
||||
const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`;
|
||||
const storageKey = resolveSelectedKey(req.event, photo);
|
||||
try {
|
||||
// Same pre-append source check as download-all (#895 review),
|
||||
// local backend only: a lazy fs stream's async error would kill
|
||||
// the response instead of skipping the photo; S3's get() rejects
|
||||
// at the await below, so no redundant per-entry HEAD there.
|
||||
if (storageKey && selectedStorage.kind() === 'local') {
|
||||
const srcStat = await selectedStorage.stat(storageKey);
|
||||
if (!srcStat) {
|
||||
throw new Error(`Photo missing in storage: ${storageKey}`);
|
||||
}
|
||||
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
|
||||
throw new Error('Photo file missing on disk');
|
||||
}
|
||||
|
||||
// Resize (#858) and/or watermark. renderPhotoForDownload returns null
|
||||
// when neither applies, so the untransformed case still streams from
|
||||
// storage rather than buffering the whole photo.
|
||||
const rendered = await renderPhotoForDownload(req.event, photo, selectedBox, effectiveSettings);
|
||||
if (rendered) {
|
||||
archive.append(rendered, { name });
|
||||
} else if (storageKey) {
|
||||
const stream = await selectedStorage.get(storageKey);
|
||||
archive.append(stream, { name });
|
||||
} else {
|
||||
archive.file(resolvePhotoFilePath(req.event, photo), { name });
|
||||
}
|
||||
appendedIds.push(photo.id);
|
||||
} catch (err) {
|
||||
logger.warn('Skipping selected photo due to error', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// See download-all: notify only on response 'finish'.
|
||||
// Admin preview (#868) streams the archive but is excluded from stats.
|
||||
if (!req.isAdminPreview) {
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'selected', photo_count: photoIds.length }, req.event.id, galleryActor(req));
|
||||
});
|
||||
}
|
||||
await archive.finalize();
|
||||
|
||||
if (!req.isAdminPreview) {
|
||||
await db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_selected'
|
||||
});
|
||||
// Exactly the photos that made it into this archive (#895) — skipped
|
||||
// (missing/corrupt) sources don't count.
|
||||
if (appendedIds.length > 0) {
|
||||
db('photos').whereIn('id', appendedIds)
|
||||
.increment('download_count', 1).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to download selected photos');
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Custom-resolution download jobs (#858).
|
||||
//
|
||||
// The plain download-all is served from the pre-built cache at the gallery's
|
||||
// STANDARD resolution. Picking a different size has nothing to cache against,
|
||||
// and resizing a whole gallery inside one request would sit far past any
|
||||
// reverse-proxy timeout — so those archives are built as a job the client
|
||||
// polls. Same access rules as the download routes above.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Kick off (or join) a build. Returns the polling token.
|
||||
router.post('/:slug/download-jobs', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||
try {
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
const policy = await resolveEventDownloadPolicy(req.event);
|
||||
if (!policy.pickerEnabled) {
|
||||
return res.status(403).json({ error: 'Resolution choice is not enabled for this gallery' });
|
||||
}
|
||||
const resolution = pickRequestedResolution(policy, req.body?.resolution);
|
||||
if (resolution === null) {
|
||||
return res.status(400).json({ error: 'Resolution not available for this gallery' });
|
||||
}
|
||||
|
||||
// Optional subset. Absent = the whole visible gallery.
|
||||
let photoIds = null;
|
||||
if (Array.isArray(req.body?.photo_ids) && req.body.photo_ids.length) {
|
||||
photoIds = req.body.photo_ids
|
||||
.map((v) => parseInt(v, 10))
|
||||
.filter((v) => Number.isInteger(v))
|
||||
.slice(0, 500);
|
||||
if (photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'No valid photo IDs provided' });
|
||||
}
|
||||
}
|
||||
|
||||
let job;
|
||||
try {
|
||||
job = await downloadJobService.createJob({
|
||||
event: req.event,
|
||||
resolution,
|
||||
photoIds,
|
||||
accessLevel: req.accessLevel,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === 'NO_PHOTOS') {
|
||||
return res.status(404).json({ error: 'No photos available for this selection' });
|
||||
}
|
||||
if (err.code === 'BUSY') {
|
||||
return res.status(429).json({ error: 'Too many downloads are being prepared right now — please try again shortly' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
res.status(202).json({
|
||||
token: job.token,
|
||||
status: job.status,
|
||||
resolution: job.resolution,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to start download preparation');
|
||||
}
|
||||
});
|
||||
|
||||
// Poll. The token is unguessable, but it is never sufficient on its own —
|
||||
// verifyGalleryAccess still runs and the job must belong to THIS event.
|
||||
// no-store: a cached 'preparing' would strand the caller in a poll that can
|
||||
// never observe the job finishing.
|
||||
router.get('/:slug/download-jobs/:token', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, noStoreCache, async (req, res) => {
|
||||
try {
|
||||
const job = await downloadJobService.getStatus(req.params.token);
|
||||
if (!job || job.event_id !== req.event.id) {
|
||||
return res.status(404).json({ error: 'Download job not found' });
|
||||
}
|
||||
res.json({
|
||||
status: job.status,
|
||||
resolution: job.resolution,
|
||||
photo_count: job.photo_count || 0,
|
||||
size_bytes: job.size_bytes || null,
|
||||
error: job.status === 'failed' ? (job.error || 'Preparation failed') : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to read download job');
|
||||
}
|
||||
});
|
||||
|
||||
// Deliver the finished archive.
|
||||
router.get('/:slug/download-jobs/:token/file', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||
try {
|
||||
// Downloads can be switched off after a job was created — every other
|
||||
// download route re-checks this per request, so this one must too.
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
const job = await downloadJobService.getStatus(req.params.token);
|
||||
if (!job || job.event_id !== req.event.id) {
|
||||
return res.status(404).json({ error: 'Download job not found' });
|
||||
}
|
||||
// The token alone never grants access: the archive was built under one
|
||||
// visibility scope, and only a requester still in that scope may take it.
|
||||
// Without this, a leaked client token would hand hidden photos to a guest.
|
||||
if (job.visibility_scope !== downloadJobService.visibilityScopeFor(req.accessLevel)) {
|
||||
return res.status(404).json({ error: 'Download job not found' });
|
||||
}
|
||||
if (job.status !== 'ready' || !job.zip_path) {
|
||||
return res.status(409).json({ error: 'Download is not ready yet', status: job.status });
|
||||
}
|
||||
if (new Date(job.expires_at).getTime() <= Date.now()) {
|
||||
return res.status(410).json({ error: 'This download has expired — please request it again' });
|
||||
}
|
||||
// A photo hidden AFTER this archive was built is still inside it, and the
|
||||
// scope check above can't see that — both sides remain 'public'. Re-run
|
||||
// the visibility query over the packaged set before handing it over.
|
||||
if (!(await downloadJobService.isStillDeliverable(job, req.event, req.accessLevel))) {
|
||||
return res.status(409).json({
|
||||
error: 'This gallery changed since the download was prepared — please request it again',
|
||||
status: 'stale',
|
||||
});
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const stat = await storage.stat(job.zip_path);
|
||||
if (!stat) {
|
||||
return res.status(410).json({ error: 'This download is no longer available' });
|
||||
}
|
||||
|
||||
// Stats parity with the other bulk paths (#895): only count once the
|
||||
// response actually completed, and keep admin previews out of guest stats.
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode >= 400 || req.isAdminPreview) return;
|
||||
// The DELIVERED set, not the requested one: a photo whose source was
|
||||
// missing at build time isn't in the zip and must not be counted.
|
||||
let ids = [];
|
||||
try {
|
||||
ids = JSON.parse(job.delivered_photo_ids || job.photo_ids || '[]');
|
||||
} catch (_) { /* malformed row — skip counting rather than fail */ }
|
||||
if (ids.length > 0) {
|
||||
db('photos').whereIn('id', ids).increment('download_count', 1).catch(() => {});
|
||||
}
|
||||
db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download',
|
||||
photo_id: null,
|
||||
}).catch(() => {});
|
||||
logActivity('gallery_downloaded', { scope: 'all', resolution: job.resolution },
|
||||
req.event.id, galleryActor(req));
|
||||
});
|
||||
|
||||
const suffix = job.resolution === 'original' ? 'original' : job.resolution;
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}-${suffix}.zip"`);
|
||||
const stream = await storage.get(job.zip_path);
|
||||
pipeStreamToResponse(stream, res, { context: `download job ${job.id}`, missingStatus: 410 });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to serve prepared download');
|
||||
}
|
||||
});
|
||||
|
||||
// Explicit per-photo view beacon (#895). Counting views on the image-
|
||||
// serving routes is wrong in both directions: the lightbox preloads the
|
||||
// prev/next neighbours (three fetches per open), while a preloaded
|
||||
// neighbour that becomes the current slide is never re-fetched (#505
|
||||
// keeps the DOM node alive across the swipe) — so request-level counters
|
||||
// overcount preloads AND undercount swipe-throughs. Instead the lightbox
|
||||
// pings this endpoint exactly when a photo becomes the visible slide.
|
||||
// This also covers enhanced/maximum-protection galleries, whose bytes
|
||||
// are served by /api/secure-images and never pass the routes below.
|
||||
// The slideshow kiosk is excluded (denySlideshowToken; migration 138).
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,639 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../../database/db');
|
||||
const path = require('path');
|
||||
const { resolvePhotoContentType } = require('../../utils/photoContentType');
|
||||
const router = express.Router();
|
||||
const watermarkService = require('../../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../../services/watermarkGeneratorService');
|
||||
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
|
||||
const withPreview = (req, url) => (req.isAdminPreview ? `${url}${url.includes('?') ? '&' : '?'}admin_preview=1` : url);
|
||||
const secureImageService = require('../../services/secureImageService');
|
||||
const logger = require('../../utils/logger');
|
||||
const { pipeStreamToResponse } = require('../../utils/streamResponse');
|
||||
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { blockHiddenGallery } = require('../../utils/revealMode');
|
||||
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../../services/imageProcessor');
|
||||
const { getStorage } = require('../../services/storage');
|
||||
const fs = require('fs');
|
||||
const { getStoragePath } = require('../../config/storage');
|
||||
|
||||
router.post('/:slug/photo/:photoId/view',
|
||||
verifyGalleryAccess,
|
||||
denySlideshowToken,
|
||||
blockHiddenGallery,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const photo = await db('photos')
|
||||
.where({ id: req.params.photoId, event_id: req.event.id })
|
||||
.first('id', 'visibility');
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
// Admin preview (#981 review) is excluded from per-photo view analytics.
|
||||
if (!req.isAdminPreview) {
|
||||
await db('photos').where('id', photo.id).increment('view_count', 1);
|
||||
}
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to record view');
|
||||
}
|
||||
});
|
||||
|
||||
// View single photo (with watermark if enabled)
|
||||
router.get('/:slug/photo/:photoId',
|
||||
verifyGalleryAccess,
|
||||
blockHiddenGallery,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Check if this is a video
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
|
||||
// Check protection level - basic and standard protection allow direct JWT access
|
||||
const protectionLevel = req.event.protection_level || 'standard';
|
||||
|
||||
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
|
||||
// For enhanced/maximum protection, redirect to secure endpoint
|
||||
return res.status(302).json({
|
||||
error: 'Secure access required',
|
||||
secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`,
|
||||
photoId: photoId
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve where to read the photo bytes from. For external/reference
|
||||
// photos the source is always a local mount path. For managed photos
|
||||
// we go through the storage abstraction so S3 deployments work too
|
||||
// (#432 — previously this route did fs.* directly and 500'd in S3
|
||||
// mode because the file wasn't on the container's local fs).
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../../services/photoResolver');
|
||||
const storage = getStorage();
|
||||
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
|
||||
const useStorageBackend = !isExternal;
|
||||
|
||||
let filePath = null; // Local fs path (external photos OR LocalFs storage)
|
||||
let storageKey = null; // Relative storage key (managed photos via storage abstraction)
|
||||
let stat;
|
||||
let fileSize;
|
||||
|
||||
if (useStorageBackend) {
|
||||
try {
|
||||
storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo storage key', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
photoPath: photo.path,
|
||||
photoFilename: photo.filename
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
stat = await storage.stat(storageKey);
|
||||
if (!stat) {
|
||||
logger.error('Photo not found in storage backend', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
storageKey
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
fileSize = stat.size;
|
||||
} else {
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
photoPath: photo.path,
|
||||
photoFilename: photo.filename
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.error('Photo file does not exist at resolved path', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
resolvedPath: filePath,
|
||||
photoPath: photo.path
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
stat = fs.statSync(filePath);
|
||||
fileSize = stat.size;
|
||||
}
|
||||
|
||||
// Handle video streaming with range requests
|
||||
if (isVideo) {
|
||||
const range = req.headers.range;
|
||||
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
// Validate before writing the 206: a NaN, inverted or out-of-file
|
||||
// range used to be committed to the headers and then throw while
|
||||
// streaming (or read past the end).
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end)
|
||||
|| start < 0 || end < start || start >= fileSize) {
|
||||
res.set('Content-Range', `bytes */${fileSize}`);
|
||||
return res.status(416).end();
|
||||
}
|
||||
const boundedEnd = Math.min(end, fileSize - 1);
|
||||
const chunksize = (boundedEnd - start) + 1;
|
||||
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${boundedEnd}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize,
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
const file = useStorageBackend
|
||||
? await storage.getRange(storageKey, start, boundedEnd)
|
||||
: fs.createReadStream(filePath, { start, end: boundedEnd });
|
||||
pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` });
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': fileSize,
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
const file = useStorageBackend
|
||||
? await storage.get(storageKey)
|
||||
: fs.createReadStream(filePath);
|
||||
pipeStreamToResponse(file, res, { context: `video for photo ${photo.id}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Image path
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
// orientation_checked_at participates because the backfill (#1198) can
|
||||
// change these bytes without touching either of the other two inputs:
|
||||
// it rewrites the derived renditions while the ORIGINAL's mtime and the
|
||||
// watermark settings both stay exactly as they were. Without it a guest
|
||||
// holding a pre-fix ETag keeps getting 304 and keeps their cached
|
||||
// sideways image, however many times the backfill succeeds.
|
||||
const orientationVersion = photo.orientation_checked_at
|
||||
? `-o${new Date(photo.orientation_checked_at).getTime()}`
|
||||
: '';
|
||||
const etag = `"${photoId}-${mtimeMs}${watermarkHash}${orientationVersion}"`;
|
||||
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Pre-generated watermarked file: served via the storage backend
|
||||
// (managed) or directly from local fs (external).
|
||||
if (photo.watermark_path) {
|
||||
try {
|
||||
if (useStorageBackend) {
|
||||
const wmStat = await storage.stat(photo.watermark_path);
|
||||
if (wmStat) {
|
||||
res.set({
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Length': wmStat.size,
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
const wmStream = await storage.get(photo.watermark_path);
|
||||
return pipeStreamToResponse(wmStream, res, { context: `watermarked photo ${photo.id}` });
|
||||
}
|
||||
} else {
|
||||
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
|
||||
if (fs.existsSync(watermarkFilePath)) {
|
||||
res.set({
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
return res.sendFile(watermarkFilePath);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: apply watermark on-the-fly. applyWatermark needs a
|
||||
// local file path (sharp + fs.readFile) — for managed photos in
|
||||
// S3 mode, withLocalCopy materializes to a tmp file and cleans up.
|
||||
const watermarkedBuffer = useStorageBackend
|
||||
? await withLocalCopy(storageKey, (localPath) =>
|
||||
watermarkService.applyWatermark(localPath, watermarkSettings))
|
||||
: await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
// Queue watermark generation in background for next request
|
||||
watermarkGeneratorService.generateForPhoto(photo.id)
|
||||
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
|
||||
|
||||
res.set({
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
res.set({
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
if (useStorageBackend) {
|
||||
res.set('Content-Length', stat.size);
|
||||
res.set('Content-Type', resolvePhotoContentType(photo));
|
||||
const stream = await storage.get(storageKey);
|
||||
pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` });
|
||||
} else {
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
||||
res.sendFile(absolutePath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to serve photo');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Serve thumbnail
|
||||
router.get('/:slug/thumbnail/:photoId',
|
||||
verifyGalleryAccess,
|
||||
blockHiddenGallery,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||
// Responsive tier (#1095), whitelisted the same way the preview route's
|
||||
// is. Unrecognised or absent falls through to the canonical 300px
|
||||
// thumbnail, so existing clients are untouched.
|
||||
const { THUMBNAIL_WIDTHS, normalizeTierWidth, ensureThumbnailAtWidth } =
|
||||
require('../../services/imageProcessor');
|
||||
const thumbTier = normalizeTierWidth(req.query.w, THUMBNAIL_WIDTHS);
|
||||
|
||||
const thumbnailPath = thumbTier
|
||||
? (await ensureThumbnailAtWidth(photo, thumbTier)) || (await ensureThumbnail(photo))
|
||||
: await ensureThumbnail(photo);
|
||||
|
||||
// What was actually resolved, not what was asked for. A tier request can
|
||||
// land on the canonical thumbnail — generation failed, or the row is a
|
||||
// video — and stamping the requested tier into the ETag below would then
|
||||
// have the client cache a 300px image under its 900px key for the full
|
||||
// max-age, with no way to notice.
|
||||
const servedTier = thumbTier && thumbnailPath
|
||||
&& path.basename(thumbnailPath).startsWith(`thumb_w${thumbTier}_`)
|
||||
? thumbTier
|
||||
: null;
|
||||
|
||||
if (!thumbnailPath) {
|
||||
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||
return res.status(404).json({ error: 'Thumbnail generation failed' });
|
||||
}
|
||||
|
||||
// Read thumbnail metadata via the storage abstraction so we work in
|
||||
// both LocalFs and S3 modes (#432). The previous fs.statSync on the
|
||||
// resolved local path 500'd in S3 deployments because the thumbnail
|
||||
// only exists in the bucket, not on the container's local fs.
|
||||
const storage = getStorage();
|
||||
const stat = await storage.stat(thumbnailPath);
|
||||
if (!stat) {
|
||||
logger.error(`Thumbnail not found in storage backend for photo ${photoId}`, { thumbnailPath });
|
||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||
}
|
||||
|
||||
// Log thumbnail access
|
||||
await secureImageService.logImageAccess(
|
||||
photoId,
|
||||
req.event.id,
|
||||
req.clientInfo,
|
||||
'thumbnail'
|
||||
);
|
||||
|
||||
// Check if watermarks are enabled and apply to thumbnail
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
// ETag uses storage stat mtime + photo id + watermark hash.
|
||||
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
// Tier in the ETag, same reason as the preview route: without it a
|
||||
// client holding the 300px thumbnail gets a 304 for its 600px request
|
||||
// and renders the small one, which is this feature inverted.
|
||||
const etag = `"thumb-${photoId}-${servedTier || 'def'}-${mtimeMs}${watermarkHash}"`;
|
||||
|
||||
// Check if client has valid cached version
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
// Set appropriate headers with enhanced security
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Reduced cache time
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Protected-Thumbnail': 'true',
|
||||
'ETag': etag
|
||||
});
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Watermarking needs a local file path (sharp + fs.readFile).
|
||||
// Materialize via withLocalCopy — no-op in local mode, downloads
|
||||
// to a tmp file then cleans up in S3 mode.
|
||||
const watermarkedBuffer = await withLocalCopy(thumbnailPath, (localPath) =>
|
||||
watermarkService.applyWatermark(localPath, watermarkSettings)
|
||||
);
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
const stream = await storage.get(thumbnailPath);
|
||||
pipeStreamToResponse(stream, res, { context: `thumbnail for photo ${photoId}` });
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to serve thumbnail');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Serve hero-optimized image (1920x1080 for full-width hero sections)
|
||||
router.get('/:slug/hero/:photoId',
|
||||
verifyGalleryAccess,
|
||||
// Reveal-gated too: this route serves a 1920px derivative of ANY photo id,
|
||||
// not just the chosen hero — an open bypass while hidden (review round 1).
|
||||
blockHiddenGallery,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Check if this is a video - videos don't get hero images
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
if (isVideo) {
|
||||
// For videos, redirect to the regular photo endpoint
|
||||
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||
}
|
||||
|
||||
// Ensure hero image exists and is valid, regenerate if needed
|
||||
const heroPath = await ensureHeroImage(photo);
|
||||
|
||||
if (!heroPath) {
|
||||
// If hero generation fails, fall back to original photo
|
||||
logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`);
|
||||
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||
}
|
||||
|
||||
// Hero images are always written via the storage abstraction (see
|
||||
// imageProcessor.generateHeroImage), so they're a managed-storage
|
||||
// key in both LocalFs and S3 modes (#432). Read via storage.
|
||||
const storage = getStorage();
|
||||
const stat = await storage.stat(heroPath);
|
||||
if (!stat) {
|
||||
logger.error('Hero image file does not exist in storage backend', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
heroPath
|
||||
});
|
||||
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||
}
|
||||
|
||||
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||
const etag = `"hero-${photoId}-${mtimeMs}"`;
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Hero-Image': 'true',
|
||||
'ETag': etag
|
||||
});
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// applyWatermark needs a local file path; materialize via
|
||||
// withLocalCopy so this works in S3 mode too.
|
||||
const watermarkedBuffer = await withLocalCopy(heroPath, (localPath) =>
|
||||
watermarkService.applyWatermark(localPath, watermarkSettings)
|
||||
);
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
const stream = await storage.get(heroPath);
|
||||
pipeStreamToResponse(stream, res, { context: `hero for photo ${photoId}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error serving hero image:', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id
|
||||
});
|
||||
// Fall back to original photo on any error
|
||||
res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Lightbox preview tier (#492). Aspect-preserved JPEG capped at 1920px
|
||||
// long edge — admin-controlled opt-in via app_settings.lightbox_preview_enabled.
|
||||
// Mirrors the hero route shape: same auth, ETag from preview mtime,
|
||||
// fall back to original on any failure so the lightbox never shows a
|
||||
// broken image. The watermark application path is preserved so a
|
||||
// preview surfaced in the lightbox carries the same protection a
|
||||
// guest would see on the full original.
|
||||
router.get('/:slug/preview/:photoId',
|
||||
verifyGalleryAccess,
|
||||
blockHiddenGallery,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Videos don't get a preview tier — fall through to the regular
|
||||
// photo endpoint (which serves the source). The frontend should
|
||||
// already be checking media_type before requesting /preview but
|
||||
// belt-and-braces in case a stale tab does.
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
if (isVideo) {
|
||||
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||
}
|
||||
|
||||
// Responsive tier (#1095). Whitelisted only — an open ?w= would let
|
||||
// anyone fill the disk with renditions nobody asked for. An unrecognised
|
||||
// or absent value falls through to the canonical 1920 preview, so old
|
||||
// clients and hand-typed URLs behave exactly as before.
|
||||
const { PREVIEW_WIDTHS, normalizeTierWidth, ensurePreviewImageAtWidth } =
|
||||
require('../../services/imageProcessor');
|
||||
const tierWidth = normalizeTierWidth(req.query.w, PREVIEW_WIDTHS);
|
||||
|
||||
// Lazy generation: ensurePreviewImage returns null on any
|
||||
// failure (corrupt source, sharp OOM, storage unavailable, …).
|
||||
// Fall back to the original so the lightbox always renders.
|
||||
const previewPath = tierWidth
|
||||
? (await ensurePreviewImageAtWidth(photo, tierWidth)) || (await ensurePreviewImage(photo))
|
||||
: await ensurePreviewImage(photo);
|
||||
if (!previewPath) {
|
||||
logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`);
|
||||
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const stat = await storage.stat(previewPath);
|
||||
if (!stat) {
|
||||
logger.error('Preview file does not exist in storage backend', {
|
||||
slug: req.params.slug, photoId, eventId: req.event.id, previewPath,
|
||||
});
|
||||
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||
}
|
||||
|
||||
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
// Tier is part of the etag: without it a client that already holds the
|
||||
// 1920 rendition would get a 304 for its 640 request and render the
|
||||
// wrong size, which is the whole point of the feature inverted.
|
||||
const etag = `"preview-${photoId}-${tierWidth || 'def'}-${mtimeMs}${watermarkHash}"`;
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
res.set({
|
||||
// From the key, not hard-coded: a preview of a transparent or animated
|
||||
// source is WebP, because JPEG carries neither. `nosniff` below means
|
||||
// getting this wrong shows a broken image rather than being silently
|
||||
// corrected by the browser. Pre-existing keys have no .webp suffix and
|
||||
// are JPEG, so they keep their old header.
|
||||
'Content-Type': previewPath.endsWith('.webp') ? 'image/webp' : 'image/jpeg',
|
||||
// Cache aggressively — preview only changes on photo
|
||||
// re-upload (which generates a new preview key) or settings
|
||||
// regenerate (which writes a new mtime + ETag).
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Preview-Image': 'true',
|
||||
'ETag': etag,
|
||||
});
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// No Content-Type override here. applyWatermark PRESERVES the source
|
||||
// format (watermarkService.js: png -> png, webp -> webp, else jpeg),
|
||||
// and its input is this preview — so the output format matches the key
|
||||
// the header was already derived from. Forcing image/jpeg would
|
||||
// mislabel a watermarked WebP preview, and `nosniff` means the browser
|
||||
// will not correct it.
|
||||
//
|
||||
// What is still lost is the animation: the compositor flattens a
|
||||
// multi-frame source to one frame while keeping the WebP container.
|
||||
// That is a separate problem and a much larger one.
|
||||
const watermarkedBuffer = await withLocalCopy(previewPath, (localPath) =>
|
||||
watermarkService.applyWatermark(localPath, watermarkSettings)
|
||||
);
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
const stream = await storage.get(previewPath);
|
||||
pipeStreamToResponse(stream, res, { context: `preview for photo ${photoId}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error serving preview image:', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id,
|
||||
});
|
||||
res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /:slug/feedback-settings lives in galleryFeedback.js. A duplicate of it
|
||||
// used to sit here, and since server.js mounts galleryRoutes before
|
||||
// galleryFeedback it shadowed the real handler — dropping the per-guest caps
|
||||
// (#655) from the guest payload, so the gallery could never render the
|
||||
// favorite/like limits or their counters (#1030).
|
||||
|
||||
// Get photo stats. no-store: view/download/visitor counters are private
|
||||
// gallery analytics and change on every request.
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,249 @@
|
||||
const { isGalleryExpired } = require('../../utils/galleryLifecycle');
|
||||
const express = require('express');
|
||||
const { db } = require('../../database/db');
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { timingSafeEqualStr } = require('../../utils/timingSafe');
|
||||
const router = express.Router();
|
||||
const { resolveHeroLogoVisible } = require('../../services/galleryModel');
|
||||
const { verifyAdminPreview } = require('../../middleware/gallery');
|
||||
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||
const logger = require('../../utils/logger');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { handleAsync, errorResponse } = require('../../utils/routeHelpers');
|
||||
const { isGalleryHidden } = require('../../utils/revealMode');
|
||||
const { NotFoundError } = require('../../utils/errors');
|
||||
async function checkSlugRedirect(slug) {
|
||||
try {
|
||||
const hasTable = await db.schema.hasTable('slug_redirects');
|
||||
if (!hasTable) return null;
|
||||
|
||||
const redirect = await db('slug_redirects')
|
||||
.where({ old_slug: slug })
|
||||
.first();
|
||||
|
||||
return redirect ? redirect.new_slug : null;
|
||||
} catch (error) {
|
||||
logger.warn('Error checking slug redirect:', { slug, error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/resolve/:identifier', handleAsync(async (req, res) => {
|
||||
const { identifier } = req.params;
|
||||
let result = await resolveShareIdentifier(identifier);
|
||||
|
||||
// If not found, check for redirect
|
||||
if (!result) {
|
||||
const newSlug = await checkSlugRedirect(identifier);
|
||||
if (newSlug) {
|
||||
return res.status(301).json({
|
||||
redirect: true,
|
||||
newSlug,
|
||||
message: 'Gallery has been renamed'
|
||||
});
|
||||
}
|
||||
throw new NotFoundError('Gallery');
|
||||
}
|
||||
|
||||
const { event, matchType, shareToken } = result;
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
// The share_token is a bearer secret. Only return it (and the share
|
||||
// links/URLs that embed it) when the caller already proved they hold it —
|
||||
// i.e. they resolved via the token or the full share link. A bare *slug*
|
||||
// lookup (slugs appear in gallery URLs and are guessable) must NOT hand
|
||||
// back the secret, or an anonymous caller could turn a known slug into
|
||||
// share-link access to a no-password gallery (GHSA-rh8r).
|
||||
const callerHasToken = matchType !== 'slug';
|
||||
if (!callerHasToken) {
|
||||
return res.json({ slug: event.slug, matchType, requires_password: requiresPassword });
|
||||
}
|
||||
|
||||
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
|
||||
res.json({
|
||||
slug: event.slug,
|
||||
token: shareToken,
|
||||
matchType,
|
||||
share_link: event.share_link,
|
||||
share_path: linkVariants.sharePath,
|
||||
share_url: linkVariants.shareUrl,
|
||||
short_enabled: linkVariants.shortEnabled,
|
||||
requires_password: requiresPassword
|
||||
});
|
||||
}));
|
||||
|
||||
// Verify share token. no-store: this is an authorization decision — a cached
|
||||
// `{ valid: true }` would keep answering for a token the admin has rotated.
|
||||
router.get('/:slug/verify-token/:token', noStoreCache, handleAsync(async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) })
|
||||
.select('id', 'share_link', 'share_token')
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
throw new NotFoundError('Gallery');
|
||||
}
|
||||
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
|
||||
throw new NotFoundError('Gallery', 'Invalid gallery link');
|
||||
}
|
||||
|
||||
res.json({ valid: true });
|
||||
}));
|
||||
|
||||
// Get gallery info (with optional token verification)
|
||||
router.get('/:slug/info', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const { token } = req.query;
|
||||
|
||||
let event = await db('events')
|
||||
.where({ slug })
|
||||
.select(
|
||||
'id',
|
||||
'created_by',
|
||||
'event_name',
|
||||
'event_type',
|
||||
'event_date',
|
||||
'expires_at',
|
||||
'is_active',
|
||||
'is_archived',
|
||||
'share_link',
|
||||
'share_token',
|
||||
'allow_downloads',
|
||||
'allow_user_uploads',
|
||||
'reveal_mode',
|
||||
'reveal_at',
|
||||
'revealed_at',
|
||||
'disable_right_click',
|
||||
'watermark_downloads',
|
||||
'watermark_text',
|
||||
'require_password',
|
||||
'color_theme',
|
||||
'enable_devtools_protection',
|
||||
'use_canvas_rendering',
|
||||
'hero_logo_visible',
|
||||
'hero_logo_size',
|
||||
'hero_logo_position',
|
||||
'hero_logo_url',
|
||||
'login_logo_visible',
|
||||
'header_style',
|
||||
'hero_divider_style',
|
||||
'hero_image_anchor',
|
||||
'is_draft',
|
||||
'default_photo_sort',
|
||||
// Per-event promotional override (#440). Resolution into a
|
||||
// ready-to-render markdown string happens below so the
|
||||
// frontend doesn't have to know about modes.
|
||||
'promo_mode',
|
||||
'promo_markdown',
|
||||
'info_mode',
|
||||
'info_markdown'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
// Check for redirect
|
||||
const newSlug = await checkSlugRedirect(slug);
|
||||
if (newSlug) {
|
||||
return res.status(301).json({
|
||||
redirect: true,
|
||||
newSlug,
|
||||
message: 'Gallery has been renamed'
|
||||
});
|
||||
}
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
// Check if event is archived
|
||||
if (event.is_archived) {
|
||||
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
|
||||
}
|
||||
|
||||
// Admin preview (#868) bypasses both the draft gate and — below — the
|
||||
// password gate. Computed once and reused.
|
||||
const adminPreview = await verifyAdminPreview(req, event);
|
||||
// Check if event is a draft (allow admin preview)
|
||||
if (event.is_draft && !adminPreview) {
|
||||
return res.status(404).json({ error: 'Gallery is not yet published' });
|
||||
}
|
||||
|
||||
// If token provided, verify it matches the share link
|
||||
if (token) {
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
|
||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||
}
|
||||
}
|
||||
|
||||
// Admin preview skips the guest password on published, protected galleries
|
||||
// (#868) — the admin already sees every photo through the admin routes.
|
||||
const requiresPassword = adminPreview
|
||||
? false
|
||||
: !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||
|
||||
res.json({
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
expires_at: event.expires_at,
|
||||
is_active: event.is_active,
|
||||
is_expired: !event.is_active || isGalleryExpired(event),
|
||||
requires_password: requiresPassword,
|
||||
color_theme: event.color_theme,
|
||||
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
|
||||
allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1',
|
||||
// Reveal mode (#838): effective hidden state (computed, time-exact) so
|
||||
// the landing page can hint at the reveal before login too.
|
||||
hidden_until_reveal: isGalleryHidden(event),
|
||||
reveal_at: isGalleryHidden(event) ? (event.reveal_at || null) : null,
|
||||
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
|
||||
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||
watermark_text: event.watermark_text,
|
||||
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
|
||||
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
|
||||
// #894: only an explicit false hides the logo on the password page;
|
||||
// NULL keeps the default (show).
|
||||
login_logo_visible: !(event.login_logo_visible === false || event.login_logo_visible === 0 || event.login_logo_visible === '0'),
|
||||
// #756: NULL per-event size inherits the global branding_logo_size.
|
||||
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
hero_logo_url: event.hero_logo_url || null,
|
||||
header_style: event.header_style || 'standard',
|
||||
hero_divider_style: event.hero_divider_style || 'wave',
|
||||
hero_image_anchor: event.hero_image_anchor || 'center',
|
||||
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
|
||||
// Per-event promotional override (#440). Frontend resolves
|
||||
// 'inherit' against branding_promo_markdown from public settings.
|
||||
promo_mode: event.promo_mode || 'inherit',
|
||||
promo_markdown: event.promo_markdown || null,
|
||||
// Info banner (#932). Same inherit/custom/off semantics as promo,
|
||||
// resolved against branding_info_markdown from public settings.
|
||||
info_mode: event.info_mode || 'inherit',
|
||||
info_markdown: event.info_markdown || null
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch gallery info');
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live Slideshow ("Diashow") — token-only fullscreen kiosk surface
|
||||
// (migration 138). The token in the URL IS the secret (no gallery password),
|
||||
// so these routes are unauthenticated except for the token match itself. The
|
||||
// slideshow shows ALL public/visible, finished photos — exactly the guest
|
||||
// set — so once /session mints a short-lived `accessLevel:'slideshow'` JWT,
|
||||
// the page reuses the normal /photos + image endpoints unchanged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Photos a slideshow may display: published, finished, non-hidden. Mirrors the
|
||||
// guest filter in GET /:slug/photos so the live count matches the rendered set.
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,192 @@
|
||||
const express = require('express');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const router = express.Router();
|
||||
const { verifyGalleryAccess } = require('../../middleware/gallery');
|
||||
const { resolveGuest } = require('../../middleware/guestAuth');
|
||||
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||
const { generateGuestIdentifier } = require('../../middleware/feedbackRateLimit');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { guestBlockedByReveal } = require('../../utils/revealMode');
|
||||
const downloadZipService = require('../../services/downloadZipService');
|
||||
const GALLERY_OPENED_DEBOUNCE_MS = 6 * 60 * 60 * 1000;
|
||||
const galleryOpenedNotifiedAt = new Map();
|
||||
function galleryActor(req) {
|
||||
// Portal tokens run as accessLevel 'guest' but carry via:'customer'
|
||||
// (req.viaCustomer); PIN-client logins carry accessLevel 'client'.
|
||||
// Both are customers, not guests (codex review of #849, final round).
|
||||
const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client'));
|
||||
return { type: isCustomer ? 'customer' : 'guest' };
|
||||
}
|
||||
function notifyGalleryOpened(event, req) {
|
||||
// Customer-PORTAL opens already log `customer_event_access` on the
|
||||
// access-token mint — a second `gallery_opened` per portal click would
|
||||
// double-notify. Keyed on the portal provenance (req.viaCustomer), NOT
|
||||
// on accessLevel: PIN-client logins are 'client' without any other
|
||||
// open signal and must keep notifying (codex review of #849, final
|
||||
// round — the previous check had this inverted).
|
||||
if (req && req.viaCustomer) return;
|
||||
const now = Date.now();
|
||||
const last = galleryOpenedNotifiedAt.get(event.id) || 0;
|
||||
if (now - last < GALLERY_OPENED_DEBOUNCE_MS) return;
|
||||
galleryOpenedNotifiedAt.set(event.id, now);
|
||||
// Fire-and-forget — logActivity swallows its own errors.
|
||||
logActivity('gallery_opened', {}, event.id, galleryActor(req));
|
||||
}
|
||||
|
||||
router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => {
|
||||
try {
|
||||
const payload = await require('../../services/galleryQueryService').getGalleryPhotos({
|
||||
event: req.event, slug: req.params.slug, query: req.query,
|
||||
identity: { guestId: req.guest?.id, guestIdentifier: generateGuestIdentifier(req) },
|
||||
accessLevel: req.accessLevel, adminPreview: req.isAdminPreview,
|
||||
hiddenForGuest: guestBlockedByReveal(req),
|
||||
});
|
||||
// Log view — but NOT for the Live Slideshow kiosk. A running projector
|
||||
// refetches this list on every new-upload poll, which would massively
|
||||
// inflate total_views / unique_visitors. The slideshow is explicitly
|
||||
// excluded from real visitor analytics (migration 138 design).
|
||||
// Admin preview (#868) is excluded from guest analytics + the "gallery
|
||||
// opened" bell — it's the photographer looking at their own gallery.
|
||||
if (req.accessLevel !== 'slideshow' && !req.isAdminPreview && !(Number(req.query.page) > 1)) {
|
||||
await db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'view'
|
||||
});
|
||||
notifyGalleryOpened(req.event, req);
|
||||
}
|
||||
|
||||
res.json(payload);
|
||||
} catch (error) { errorResponse(res, error, 500, 'Failed to fetch photos'); }
|
||||
});
|
||||
|
||||
/**
|
||||
* People in this gallery (#1074).
|
||||
*
|
||||
* Returns [] rather than 403 whenever the feature is unavailable — a guest
|
||||
* must not be able to tell "this gallery has no people" from "this gallery
|
||||
* has the feature switched off". Same reasoning as reveal mode returning an
|
||||
* empty photo set rather than an error.
|
||||
*
|
||||
* Counts and cover faces are computed against the caller's own visibility
|
||||
* scope inside facePeopleService; nothing here reads face_count_total.
|
||||
*/
|
||||
// no-store for the same reason as /photos: the people list and its scan
|
||||
// progress are scoped to what THIS viewer may see.
|
||||
router.get('/:slug/people', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => {
|
||||
try {
|
||||
const isClient = req.accessLevel === 'client';
|
||||
const { isEnabledForEvent, areFacesVisibleToGuests, getThresholds } =
|
||||
require('../../services/faceSettings');
|
||||
|
||||
if (!(await isEnabledForEvent(req.event))) {
|
||||
return res.json({ people: [] });
|
||||
}
|
||||
if (!isClient && !areFacesVisibleToGuests(req.event)) {
|
||||
return res.json({ people: [] });
|
||||
}
|
||||
// While a gallery is hidden behind reveal mode (#838), a plain guest sees
|
||||
// no photos — so they see no people either.
|
||||
if (guestBlockedByReveal(req)) {
|
||||
return res.json({ people: [] });
|
||||
}
|
||||
|
||||
const { listPeople, getScanStatus } = require('../../services/facePeopleService');
|
||||
const thresholds = await getThresholds();
|
||||
|
||||
const people = await listPeople(req.event.id, {
|
||||
isClient,
|
||||
forAdmin: false,
|
||||
minClusterSize: thresholds.face_min_cluster_size,
|
||||
});
|
||||
|
||||
// Drives the "Finding people… 240/1200" progress line during a backfill.
|
||||
// Scoped to what this viewer may see — an unscoped total would leak the
|
||||
// number of hidden photos through the progress bar.
|
||||
const status = await getScanStatus(req.event.id, { isClient });
|
||||
|
||||
res.json({
|
||||
people,
|
||||
scan: {
|
||||
in_progress: status.in_progress,
|
||||
scanned: status.scanned,
|
||||
total: status.total,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch people');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle photo visibility (client-only)
|
||||
router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
if (req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Client access required' });
|
||||
}
|
||||
|
||||
const { photoId } = req.params;
|
||||
const { visibility } = req.body;
|
||||
|
||||
if (!['visible', 'hidden'].includes(visibility)) {
|
||||
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.update({ visibility });
|
||||
|
||||
// A client hiding/showing a photo changes the guest download bundle —
|
||||
// drop the cached ZIP so it rebuilds fresh (codex review).
|
||||
downloadZipService.invalidate(req.event.id);
|
||||
|
||||
res.json({ message: 'Photo visibility updated', visibility });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update photo visibility');
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk toggle photo visibility (client-only)
|
||||
router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
if (req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Client access required' });
|
||||
}
|
||||
|
||||
const { photoIds, visibility } = req.body;
|
||||
|
||||
if (!Array.isArray(photoIds) || photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'Invalid photo IDs' });
|
||||
}
|
||||
|
||||
if (!['visible', 'hidden'].includes(visibility)) {
|
||||
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||
}
|
||||
|
||||
const count = await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
.where('event_id', req.event.id)
|
||||
.update({ visibility });
|
||||
|
||||
// Client bulk hide/show alters the guest download bundle — invalidate
|
||||
// the cached ZIP (codex review).
|
||||
downloadZipService.invalidate(req.event.id);
|
||||
|
||||
res.json({ message: `${count} photos updated`, visibility });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update photo visibility');
|
||||
}
|
||||
});
|
||||
|
||||
// Download single photo
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,289 @@
|
||||
const { isGalleryAvailable } = require('../../utils/galleryLifecycle');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
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',
|
||||
accessLevel: 'slideshow',
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '12h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
// <img> tags can't carry an Authorization header, so the photo/thumbnail/
|
||||
// preview endpoints authenticate via the per-slug gallery cookie. Set it
|
||||
// here so the kiosk's image requests are authorized with zero extra wiring.
|
||||
setGalleryAuthCookies(res, sessionToken, event.slug);
|
||||
|
||||
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
|
||||
|
||||
res.json({
|
||||
token: sessionToken,
|
||||
event: {
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
color_theme: event.color_theme
|
||||
},
|
||||
settings: await slideshowSettings(event, req),
|
||||
photo_count: parseInt(count, 10) || 0,
|
||||
expires_at: event.expires_at || null
|
||||
});
|
||||
}));
|
||||
|
||||
// Cheap live-poll endpoint (tiny payload, hit every ~3s by the running show):
|
||||
// current settings + the visible photo count. The page diffs photo_count to
|
||||
// decide when to refetch the full list, and re-reads settings so admin changes
|
||||
// take effect live. A dead/disabled link 404s here → the projector stops.
|
||||
router.get('/:slug/show/:token/state', noStoreCache, handleAsync(async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
const event = await resolveSlideshow(slug, token);
|
||||
if (!event) {
|
||||
throw new NotFoundError('Slideshow');
|
||||
}
|
||||
|
||||
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
|
||||
|
||||
res.json({
|
||||
...(await slideshowSettings(event, req)),
|
||||
photo_count: parseInt(count, 10) || 0,
|
||||
expires_at: event.expires_at || null
|
||||
});
|
||||
}));
|
||||
|
||||
// Get all photos.
|
||||
//
|
||||
// no-store (B6): the payload is private and per-guest — it carries the
|
||||
// viewer's own likes/favorites/ratings and, for a client token, photos hidden
|
||||
// from plain guests. With no Cache-Control at all a browser applies heuristic
|
||||
// freshness and may reuse a body it stored on disk, on a shared device, for a
|
||||
// gallery whose password has since been rotated. Express still computes its
|
||||
// weak ETag, so a caller that does revalidate (React Query's own in-memory
|
||||
// cache is unaffected either way) still gets a correct 304.
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,44 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../../database/db');
|
||||
const router = express.Router();
|
||||
const { verifyGalleryAccess } = require('../../middleware/gallery');
|
||||
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||
const { blockHiddenGallery } = require('../../utils/revealMode');
|
||||
|
||||
router.get('/:slug/stats', verifyGalleryAccess, blockHiddenGallery, noStoreCache, async (req, res) => {
|
||||
try {
|
||||
const totalPhotos = await db('photos')
|
||||
.where('event_id', req.event.id)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const totalViews = await db('access_logs')
|
||||
.where('event_id', req.event.id)
|
||||
.where('action', 'view')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const totalDownloads = await db('photos')
|
||||
.where('event_id', req.event.id)
|
||||
.sum('download_count as total')
|
||||
.first();
|
||||
|
||||
const uniqueVisitors = await db('access_logs')
|
||||
.where('event_id', req.event.id)
|
||||
.countDistinct('ip_address as count')
|
||||
.first();
|
||||
|
||||
res.json({
|
||||
total_photos: totalPhotos.count,
|
||||
total_views: totalViews.count,
|
||||
total_downloads: totalDownloads.total || 0,
|
||||
unique_visitors: uniqueVisitors.count
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch stats' });
|
||||
}
|
||||
});
|
||||
|
||||
// User photo upload endpoint
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,41 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../../database/db');
|
||||
const router = express.Router();
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
router.get('/:slug/css-template', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
|
||||
// Find the event by slug
|
||||
const event = await db('events')
|
||||
.where({ slug })
|
||||
.select('css_template_id')
|
||||
.first();
|
||||
|
||||
if (!event || !event.css_template_id) {
|
||||
// No custom CSS - return 204 No Content
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
// Get the template if it's enabled
|
||||
const template = await db('css_templates')
|
||||
.where({ id: event.css_template_id, is_enabled: true })
|
||||
.select('css_content')
|
||||
.first();
|
||||
|
||||
if (!template || !template.css_content) {
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
// Return CSS with caching headers
|
||||
res.setHeader('Content-Type', 'text/css');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
|
||||
res.send(template.css_content);
|
||||
} catch (error) {
|
||||
logger.error('Get CSS template error:', error);
|
||||
res.status(500).send('/* Error loading template */');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,211 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../../database/db');
|
||||
|
||||
const router = express.Router();
|
||||
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
|
||||
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||
const logger = require('../../utils/logger');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
|
||||
router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.eventId);
|
||||
|
||||
// Verify the event matches the token
|
||||
if (req.event.id !== eventId) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
// Check if user uploads are allowed
|
||||
if (!req.event.allow_user_uploads) {
|
||||
return res.status(403).json({ error: 'User uploads are not allowed for this event' });
|
||||
}
|
||||
|
||||
// Ensure temp upload directory exists
|
||||
const fs = require('fs');
|
||||
const tempUploadDir = '/tmp/uploads/';
|
||||
if (!fs.existsSync(tempUploadDir)) {
|
||||
try {
|
||||
fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 });
|
||||
logger.info('Created temp upload directory:', tempUploadDir);
|
||||
} catch (mkdirErr) {
|
||||
return errorResponse(res, mkdirErr, 500, 'Server configuration error: unable to create upload directory');
|
||||
}
|
||||
}
|
||||
|
||||
// Import multer and photo processing
|
||||
const multer = require('multer');
|
||||
const { getAllowedMimeTypes, getMaxFilesPerUpload, getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings');
|
||||
const { validateFileType } = require('../../utils/fileSecurityUtils');
|
||||
|
||||
// Resolve allowed MIME types from settings
|
||||
let allowedMimeTypes;
|
||||
try {
|
||||
allowedMimeTypes = await getAllowedMimeTypes();
|
||||
} catch {
|
||||
allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
}
|
||||
|
||||
// #613 — per-batch file count was hardcoded to 10 here, so the admin's
|
||||
// Settings → General → "Max Files per Upload" value silently didn't
|
||||
// apply to guest uploads (only admin uploads honoured it via
|
||||
// adminPhotos.js:131). Zszywany reported uploading 16 files succeeded
|
||||
// even with the limit set to 10. Mirror the admin path: resolve from
|
||||
// settings (cached for 60s in the service) and feed multer both
|
||||
// `limits.files` and the `.array(...)` cap. Fall back to the service's
|
||||
// default if the read fails.
|
||||
let maxFilesPerUpload;
|
||||
try {
|
||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||
} catch {
|
||||
maxFilesPerUpload = 500;
|
||||
}
|
||||
|
||||
// Per-file size cap was hardcoded to 50MB here, so the admin's Settings →
|
||||
// General → "Max File Size (MB)" value (general_max_file_size_mb) never
|
||||
// applied to guest uploads — a guest could not upload a large video even
|
||||
// when the admin allowed it (reported on #613 by mat1990dj). Resolve it from
|
||||
// settings like the count above; fall back to the 50MB default on read error.
|
||||
let maxFileSizeBytes;
|
||||
try {
|
||||
maxFileSizeBytes = await getMaxFileSizeBytes();
|
||||
} catch {
|
||||
maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
dest: tempUploadDir,
|
||||
limits: {
|
||||
fileSize: maxFileSizeBytes,
|
||||
files: maxFilesPerUpload
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type'));
|
||||
}
|
||||
}
|
||||
}).array('photos', maxFilesPerUpload);
|
||||
|
||||
// Handle upload
|
||||
upload(req, res, async (err) => {
|
||||
if (err) {
|
||||
logger.error('Upload error:', err);
|
||||
// Turn multer's generic "File too large" into an actionable message
|
||||
// that names the configured limit.
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
const limitMb = Math.floor(maxFileSizeBytes / (1024 * 1024));
|
||||
return res.status(400).json({ error: `File too large. Maximum size is ${limitMb} MB per file.` });
|
||||
}
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
const { queueFilesForProcessing } = require('../../services/photoProcessor');
|
||||
const rawCategory = req.body.category_id || req.event.upload_category_id || null;
|
||||
const numericCategoryId = (() => {
|
||||
if (rawCategory === null || rawCategory === undefined) return null;
|
||||
const n = parseInt(rawCategory, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
})();
|
||||
|
||||
try {
|
||||
// Queue files as 'pending' — the background worker will process
|
||||
// thumbnails / EXIF / dimensions off the request thread (#357).
|
||||
const result = await queueFilesForProcessing(req.files, {
|
||||
eventId,
|
||||
photoType: 'individual',
|
||||
categoryId: numericCategoryId,
|
||||
});
|
||||
|
||||
res.status(202).json({
|
||||
message: 'Photos queued for processing',
|
||||
upload_id: result.uploadId,
|
||||
count: result.photos.length,
|
||||
photo_ids: result.photos.map((p) => p.id),
|
||||
photos: result.photos,
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
});
|
||||
} catch (processError) {
|
||||
errorResponse(res, processError, 500, 'Failed to process photos');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to upload photos');
|
||||
}
|
||||
});
|
||||
|
||||
// A guest upload_id is `crypto.randomBytes(16).toString('hex')`
|
||||
// (photoProcessor.js). The pattern is deliberately a little wider than that so
|
||||
// an id-format change does not silently 400, but narrow enough that the value
|
||||
// can only ever be an opaque token.
|
||||
const UPLOAD_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
|
||||
// The guest UI uploads one file per request, so a batch of N files yields N
|
||||
// upload ids. Batching them into a single poll keeps the request rate flat
|
||||
// regardless of batch size; the cap bounds the IN-list.
|
||||
const MAX_UPLOAD_STATUS_IDS = 50;
|
||||
|
||||
/**
|
||||
* GET /:slug/uploads/status?ids=<upload_id>[,<upload_id>…]
|
||||
*
|
||||
* Guest-facing processing status for the guest's own uploads (B7).
|
||||
*
|
||||
* The upload route answers 202 and queues the files, and /photos only returns
|
||||
* rows that reached `processing_status: 'complete'`. Without this the gallery
|
||||
* had to poll /photos blind, could not say "processing…", and could not tell a
|
||||
* slow worker from a photo that failed outright — the guest just watched their
|
||||
* upload not appear.
|
||||
*
|
||||
* Authorization: `verifyGalleryAccess` already resolved `req.event` from the
|
||||
* caller's gallery token, and the query is filtered on `event_id = req.event.id`
|
||||
* as well as the ids. An id belonging to another gallery therefore matches no
|
||||
* row rather than being reported as forbidden — no cross-event read, and no
|
||||
* existence oracle either. Slideshow tokens are denied because a kiosk never
|
||||
* uploads.
|
||||
*
|
||||
* The response is counts only. The guest already knows which files they sent;
|
||||
* anything more (filenames, `processing_error` strings, which can carry
|
||||
* internal paths) would be leaking beyond "how far along is my upload".
|
||||
*/
|
||||
router.get('/:slug/uploads/status', verifyGalleryAccess, denySlideshowToken, noStoreCache, async (req, res) => {
|
||||
try {
|
||||
const ids = String(req.query.ids || '')
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (ids.length === 0 || ids.length > MAX_UPLOAD_STATUS_IDS || !ids.every((id) => UPLOAD_ID_PATTERN.test(id))) {
|
||||
return res.status(400).json({ error: 'Invalid upload ids' });
|
||||
}
|
||||
|
||||
const rows = await db('photos')
|
||||
.where('event_id', req.event.id)
|
||||
.whereIn('upload_id', ids)
|
||||
.select('processing_status');
|
||||
|
||||
const summary = { total: rows.length, pending: 0, processing: 0, complete: 0, failed: 0 };
|
||||
for (const row of rows) {
|
||||
// NULL is a pre-async-migration row, treated as complete exactly as the
|
||||
// /photos filter treats it.
|
||||
const status = row.processing_status || 'complete';
|
||||
if (Object.prototype.hasOwnProperty.call(summary, status) && status !== 'total') {
|
||||
summary[status] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
res.json(summary);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to read upload status');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:slug/css-template
|
||||
* Get custom CSS template for gallery (public endpoint)
|
||||
*/
|
||||
|
||||
module.exports = router;
|
||||
@@ -6,6 +6,7 @@ const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
|
||||
const 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;
|
||||
|
||||
@@ -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,61 @@
|
||||
/**
|
||||
* Regression tests for issue #550.
|
||||
*
|
||||
* Two related bugs in POST /v1/events:
|
||||
* 1. color_theme was not accepted on the request body and never written
|
||||
* to the events row. Editing such an event later in the admin UI
|
||||
* snapped the theme picker to GALLERY_THEME_PRESETS.default and
|
||||
* saving overwrote whatever theme was inherited visually.
|
||||
* 2. event_feedback_settings row was never created, so the gallery UI
|
||||
* read it as "feedback off" regardless of the global
|
||||
* event_default_feedback_enabled toggle (#520).
|
||||
*
|
||||
* Test pattern mirrors events.category.test.js — queue up db() chains
|
||||
* with db.__setImplementations() in the exact order the handler invokes
|
||||
* them, then assert against the captured payloads.
|
||||
*/
|
||||
|
||||
/** Persisted contracts, not a mock tied to the number/order of Knex calls. */
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../../../../__tests__/integration/helpers/crmDb');
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const buildChain = ({ firstResult, insertResult, returningResult, selectResult } = {}) => {
|
||||
const chain = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
whereIn: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orWhere: jest.fn().mockReturnThis(),
|
||||
// `select` resolves to an array so `await db(...).whereIn(...).select(...)`
|
||||
// gives an iterable result (used by the branding-defaults probe added in
|
||||
// #592 follow-up). Tests that don't need it leave selectResult undefined
|
||||
// and get `[]`, which is a safe no-op for any caller that iterates.
|
||||
select: jest.fn().mockResolvedValue(selectResult ?? []),
|
||||
first: jest.fn().mockResolvedValue(firstResult),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
jest.mock('../../../database/db', () => {
|
||||
const dbMock = jest.fn();
|
||||
dbMock.raw = jest.fn();
|
||||
dbMock.__setImplementations = (...chains) => {
|
||||
dbMock.mockReset();
|
||||
chains.forEach((chain) => {
|
||||
dbMock.mockImplementationOnce(() => chain);
|
||||
});
|
||||
};
|
||||
return {
|
||||
db: dbMock,
|
||||
logActivity: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
let db, cleanup, app, adminId, adminToken, apiToken;
|
||||
const base = { event_type: 'wedding', event_name: 'Creation parity', event_date: '2030-06-15',
|
||||
customer_name: 'Ada', customer_email: 'ada@example.test', admin_email: 'admin@example.test',
|
||||
require_password: false, is_draft: false, expires_at: '2030-07-15T00:00:00.000Z' };
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb()); ({ adminId } = await seedMinimal(db)); await assignAdminRole(db, adminId);
|
||||
adminToken = mintAdminToken(adminId);
|
||||
const generated = require('../../../middleware/apiTokenAuth').generateApiToken(); apiToken = generated.plaintext;
|
||||
await db('api_tokens').insert({ name: 'parity', hashed_token: generated.hashed, scopes: 'admin', created_by: adminId });
|
||||
app = express(); app.use(express.json());
|
||||
app.use('/admin', require('../../adminEvents'));
|
||||
app.use('/v1', require('../events'));
|
||||
}, 120000);
|
||||
afterAll(async () => { await require('../../../services/serviceShutdown').stopServices(); await cleanup(); });
|
||||
async function create(source, extra) {
|
||||
const input = { ...base, ...extra };
|
||||
if (source === 'legacy') return require('../../../services/eventService').createEvent(input, { actor: { id: adminId } });
|
||||
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
|
||||
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send(input);
|
||||
expect(response.status).toBe(source === 'admin' ? 200 : 201);
|
||||
return response.body;
|
||||
}
|
||||
it.each(['admin', 'v1', 'legacy'])('%s stores theme, owner, dates and feedback defaults through one use case', async source => {
|
||||
const theme = JSON.stringify({ primaryColor: '#ff0066' });
|
||||
const created = await create(source, { color_theme: theme, feedback_enabled: true });
|
||||
const row = await db('events').where({ id: created.id }).first();
|
||||
expect(row).toMatchObject({ color_theme: theme, created_by: adminId, event_name: base.event_name, customer_email: base.customer_email });
|
||||
expect(require('../../../utils/dateNormalize').toIso(row.expires_at)).toBe(base.expires_at);
|
||||
expect([false, 0]).toContain(row.require_password);
|
||||
expect(row.updated_at).toBeTruthy(); expect(row.share_token).toBeTruthy(); expect(row.password_hash).toBeTruthy();
|
||||
const feedback = await db('event_feedback_settings').where({ event_id: row.id }).first();
|
||||
for (const key of ['feedback_enabled','allow_ratings','allow_likes','allow_comments','allow_favorites','allow_reactions','moderate_comments','show_feedback_to_guests']) expect([true, 1]).toContain(feedback[key]);
|
||||
expect([false, 0]).toContain(feedback.allow_color_labels); expect(feedback.keybind_mode).toBe('colors');
|
||||
});
|
||||
|
||||
// RBAC is enforced on these routes since GHSA-9697 (requirePermission), but
|
||||
// this suite mocks the database, so a real permission lookup would 500. These
|
||||
// tests cover route logic, not authorization — the intersection of token
|
||||
// scopes and role permissions is pinned in __tests__/routes/v1EventOwnership.
|
||||
jest.mock('../../../middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
userHasAnyPermission: async () => true,
|
||||
userHasAllPermissions: async () => true,
|
||||
}));
|
||||
|
||||
jest.mock('../../../middleware/apiTokenAuth', () => ({
|
||||
apiTokenAuth: (req, _res, next) => {
|
||||
req.apiToken = { id: 1, admin_id: 1, scopes: ['admin'] };
|
||||
req.admin = { id: 1, username: 'token-admin' };
|
||||
next();
|
||||
},
|
||||
requireApiScope: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// bcrypt.hash is awaited twice per request (real path + dummy path).
|
||||
// Stub it to a constant so tests don't burn CPU on bcrypt rounds.
|
||||
jest.mock('bcrypt', () => ({
|
||||
hash: jest.fn().mockResolvedValue('$2b$10$mocked-hash'),
|
||||
}));
|
||||
|
||||
jest.mock('../../../services/shareLinkService', () => ({
|
||||
buildShareLinkVariants: jest.fn().mockResolvedValue({
|
||||
shareUrl: 'https://example.test/gallery/some-slug?t=abc',
|
||||
shareLinkToStore: '/gallery/some-slug?t=abc',
|
||||
}),
|
||||
}));
|
||||
|
||||
// Webhook fire is in a try/catch; stub to silence the predictable
|
||||
// failure log so test output stays clean.
|
||||
jest.mock('../../../services/webhookService', () => ({
|
||||
fire: jest.fn().mockResolvedValue(undefined),
|
||||
buildEventSubject: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// event_type is validated against the live event_types catalog (#800) —
|
||||
// that lookup would consume the first queued db() chain and shift the
|
||||
// call sequence these tests pin. Stub it valid; the invalid path has its
|
||||
// own test below.
|
||||
jest.mock('../../../services/eventTypeService', () => ({
|
||||
isValidEventType: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const { db } = require('../../../database/db');
|
||||
const { isValidEventType } = require('../../../services/eventTypeService');
|
||||
const eventsRouter = require('../events');
|
||||
|
||||
const buildApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/', eventsRouter);
|
||||
return app;
|
||||
};
|
||||
|
||||
const BASE_BODY = {
|
||||
event_name: 'Issue 550 Wedding',
|
||||
event_type: 'wedding',
|
||||
event_date: '2026-06-15',
|
||||
require_password: false,
|
||||
};
|
||||
|
||||
// db() call sequence for BASE_BODY (no feedback / devtools provided,
|
||||
// require_password supplied so its probe is skipped, no customer_phone,
|
||||
// no slug collision):
|
||||
// 1. app_settings.where('event_default_feedback_enabled').first() (#550)
|
||||
// 2. app_settings.where('enable_devtools_protection').first() (#592)
|
||||
// 3. app_settings.whereIn([branding_logo_display_hero,...]).select(...) (#592 follow-up)
|
||||
// Then slug probe, events insert, optional feedback insert.
|
||||
const baseSettingsChains = () => [
|
||||
buildChain({ firstResult: null }), // feedback default
|
||||
buildChain({ firstResult: null }), // devtools default
|
||||
buildChain({ selectResult: [] }), // image-security whereIn → empty rows (#1296)
|
||||
buildChain({ selectResult: [] }), // branding whereIn → empty rows
|
||||
];
|
||||
|
||||
describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('persists color_theme to the events row when provided', async () => {
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 42 }] });
|
||||
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, color_theme: 'default' })
|
||||
.expect(201);
|
||||
|
||||
const insertedRow = insertChain.insert.mock.calls[0][0];
|
||||
expect(insertedRow).toMatchObject({
|
||||
event_name: 'Issue 550 Wedding',
|
||||
color_theme: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a JSON-encoded theme string and persists it verbatim', async () => {
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 43 }] });
|
||||
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
|
||||
|
||||
const customTheme = JSON.stringify({ primaryColor: '#ff0066' });
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, color_theme: customTheme })
|
||||
.expect(201);
|
||||
|
||||
const insertedRow = insertChain.insert.mock.calls[0][0];
|
||||
expect(insertedRow.color_theme).toBe(customTheme);
|
||||
});
|
||||
|
||||
it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => {
|
||||
// feedback_enabled provided → feedback probe SKIPPED. Sequence:
|
||||
// 1. devtools probe
|
||||
// 2. image-security probe (whereIn → select, #1296)
|
||||
// 3. branding probe (whereIn → select)
|
||||
// 4. slug probe
|
||||
// 5. events insert
|
||||
// 6. feedback sub-toggle defaults probe (whereIn → select, #1044)
|
||||
// 7. event_feedback_settings insert
|
||||
const devtoolsChain = buildChain({ firstResult: null });
|
||||
const imageSecurityChain = buildChain({ selectResult: [] });
|
||||
const brandingChain = buildChain({ selectResult: [] });
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 50 }] });
|
||||
const feedbackDefaultsChain = buildChain({ selectResult: [] });
|
||||
const feedbackInsertChain = buildChain();
|
||||
db.__setImplementations(
|
||||
devtoolsChain, imageSecurityChain, brandingChain, slugChain, insertChain,
|
||||
feedbackDefaultsChain, feedbackInsertChain,
|
||||
);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, feedback_enabled: true })
|
||||
.expect(201);
|
||||
|
||||
expect(db).toHaveBeenNthCalledWith(7, 'event_feedback_settings');
|
||||
|
||||
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
|
||||
expect(feedbackRow).toMatchObject({ event_id: 50 });
|
||||
// formatBoolean() returns 1/0 on SQLite and true/false on PG. Either
|
||||
// way the value must be truthy/falsy in the right places — assert by
|
||||
// coercion so the test stays driver-agnostic.
|
||||
expect(Boolean(feedbackRow.feedback_enabled)).toBe(true);
|
||||
expect(Boolean(feedbackRow.allow_ratings)).toBe(true);
|
||||
expect(Boolean(feedbackRow.allow_likes)).toBe(true);
|
||||
expect(Boolean(feedbackRow.allow_comments)).toBe(true);
|
||||
expect(Boolean(feedbackRow.allow_favorites)).toBe(true);
|
||||
// #1044: this insert used to omit allow_reactions entirely, so v1-created
|
||||
// events only got reactions by accident of the column default.
|
||||
expect(Boolean(feedbackRow.allow_reactions)).toBe(true);
|
||||
// Colour labels are opt-in, so they stay off until the global is flipped.
|
||||
expect(Boolean(feedbackRow.allow_color_labels)).toBe(false);
|
||||
expect(feedbackRow.keybind_mode).toBe('colors');
|
||||
expect(Boolean(feedbackRow.require_name_email)).toBe(false);
|
||||
expect(Boolean(feedbackRow.moderate_comments)).toBe(true);
|
||||
expect(Boolean(feedbackRow.show_feedback_to_guests)).toBe(true);
|
||||
});
|
||||
|
||||
it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => {
|
||||
// Feedback probe returns serialized "true" → fallback kicks in and
|
||||
// the feedback insert runs. Sequence: feedback probe, devtools probe,
|
||||
// image-security probe (#1296), branding probe, slug, insert, sub-toggle
|
||||
// defaults probe (#1044), feedback insert (8 calls total).
|
||||
const feedbackProbe = buildChain({
|
||||
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' },
|
||||
});
|
||||
const devtoolsChain = buildChain({ firstResult: null });
|
||||
const imageSecurityChain = buildChain({ selectResult: [] });
|
||||
const brandingChain = buildChain({ selectResult: [] });
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 51 }] });
|
||||
const feedbackDefaultsChain = buildChain({ selectResult: [] });
|
||||
const feedbackInsertChain = buildChain();
|
||||
db.__setImplementations(
|
||||
feedbackProbe, devtoolsChain, imageSecurityChain, brandingChain, slugChain,
|
||||
insertChain, feedbackDefaultsChain, feedbackInsertChain,
|
||||
);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send(BASE_BODY)
|
||||
.expect(201);
|
||||
|
||||
expect(db).toHaveBeenNthCalledWith(8, 'event_feedback_settings');
|
||||
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT create a feedback row when global setting is unset and body omits feedback_enabled', async () => {
|
||||
const slugChain = buildChain({ firstResult: null });
|
||||
const insertChain = buildChain({ returningResult: [{ id: 52 }] });
|
||||
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
|
||||
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send(BASE_BODY)
|
||||
.expect(201);
|
||||
|
||||
// 6 db() calls: feedback + devtools + image-security + branding probes,
|
||||
// slug, insert. event_feedback_settings is never touched.
|
||||
expect(db).toHaveBeenCalledTimes(6);
|
||||
expect(db).not.toHaveBeenCalledWith('event_feedback_settings');
|
||||
});
|
||||
|
||||
it('rejects non-boolean feedback_enabled with 400', async () => {
|
||||
// Validators run before any db() call, so no chain queueing needed.
|
||||
await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejects an event_type unknown to the catalog with 400 (#800)', async () => {
|
||||
isValidEventType.mockResolvedValueOnce(false);
|
||||
const res = await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, event_type: 'nope' })
|
||||
.expect(400);
|
||||
|
||||
expect(isValidEventType).toHaveBeenCalledWith('nope');
|
||||
expect(JSON.stringify(res.body.errors)).toContain('event_type');
|
||||
expect(db).not.toHaveBeenCalled();
|
||||
});
|
||||
it('inherits global feedback and preserves explicit overrides for every entry point', async () => {
|
||||
await db('app_settings').insert({ setting_key: 'event_default_feedback_enabled', setting_value: 'true', setting_type: 'boolean' })
|
||||
.onConflict('setting_key').merge({ setting_value: 'true' });
|
||||
for (const source of ['admin', 'v1', 'legacy']) {
|
||||
const inherited = await create(source, {});
|
||||
expect(await db('event_feedback_settings').where({ event_id: inherited.id }).first()).toBeTruthy();
|
||||
const override = await create(source, { feedback_enabled: false });
|
||||
expect(await db('event_feedback_settings').where({ event_id: override.id }).first()).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it('queues a publication email only for published galleries', async () => {
|
||||
const draft = await create('admin', { is_draft: true });
|
||||
expect(await db('email_queue').where({ event_id: draft.id })).toHaveLength(0);
|
||||
const published = await create('v1', {});
|
||||
expect(await db('email_queue').where({ event_id: published.id, email_type: 'gallery_created' })).toHaveLength(1);
|
||||
});
|
||||
it.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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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,606 @@
|
||||
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,
|
||||
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 = 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) {
|
||||
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,
|
||||
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(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;
|
||||
}
|
||||
|
||||
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('./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).falsy(0)])),
|
||||
hero_logo_visible: Joi.boolean().truthy(1).falsy(0).allow(null),
|
||||
}).unknown(true);
|
||||
|
||||
async function validateCreationInput(data) {
|
||||
const { value, error } = schema.validate(data, { abortEarly: false });
|
||||
if (error) {
|
||||
const err = new AppError('Invalid event', 400, 'EVENT_INVALID');
|
||||
// Never return Joi's submitted value/context: it can contain passwords.
|
||||
err.responseBody = { errors: error.details.map(item => ({ path: item.path.join('.'), msg: item.message })) };
|
||||
throw err;
|
||||
}
|
||||
if (!await eventTypes.isValidEventType(value.event_type)) throw new AppError('Invalid event type', 400, 'EVENT_TYPE_INVALID');
|
||||
normaliseEventTimeTriple(value); // Reject before password hashing or filesystem writes.
|
||||
return value;
|
||||
}
|
||||
module.exports = { validateCreationInput };
|
||||
@@ -10,11 +10,11 @@ const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const 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
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
// Allowed slide transition styles (kept in sync with the SlideshowPage).
|
||||
// dipwhite/dipblack = fade through highlights / lowlights between images.
|
||||
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||||
// Allowed per-slide color filters.
|
||||
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
||||
// Allowed slideshow play orders (#202). 'chronological' = upload order,
|
||||
// 'random' = client-side shuffle.
|
||||
const SLIDESHOW_ORDERS = ['chronological', 'random'];
|
||||
module.exports = {
|
||||
RECOVERABLE_PASSWORD_COLUMNS,
|
||||
validateHeroImageAnchor,
|
||||
getStoragePath,
|
||||
getEventFieldRequirements,
|
||||
readBooleanSetting,
|
||||
decodeSettingValue,
|
||||
getDownloadProtectionDefaults,
|
||||
getImageSecurityDefaults,
|
||||
resolveImageSecurityColumns,
|
||||
getBrandingDefaults,
|
||||
getCustomerNameFromPayload,
|
||||
getCustomerEmailFromPayload,
|
||||
getCustomerPhoneFromPayload,
|
||||
isPhoneFieldEnabled,
|
||||
mapEventForApi,
|
||||
hasCustomerContactColumns,
|
||||
SLIDESHOW_ORDERS,
|
||||
SLIDESHOW_TRANSITIONS,
|
||||
SLIDESHOW_COLORFILTERS,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
const cron = require('node-cron');
|
||||
const { scheduledTask } = require('./scheduledTask');
|
||||
const { db } = require('../database/db');
|
||||
const { 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.
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,30 @@
|
||||
// #756: a NULL per-event hero_logo_visible means "inherit the global
|
||||
// branding_logo_display_hero toggle". Only an explicit true/false is a
|
||||
// per-gallery override. `globalDefault` is branding_logo_display_hero
|
||||
// (defaults true when unset).
|
||||
function resolveHeroLogoVisible(perEvent, globalDefault) {
|
||||
if (perEvent === null || perEvent === undefined) {
|
||||
return globalDefault !== false;
|
||||
}
|
||||
return perEvent !== false && perEvent !== 0 && perEvent !== '0';
|
||||
}
|
||||
|
||||
// Formats whose ORIGINAL bytes a browser can't render in an <img> (HEIC/HEIF,
|
||||
// camera RAW/DNG). For these the lightbox must be served the generated JPEG
|
||||
// preview instead of `url` (the original) — otherwise it shows a broken image.
|
||||
// So we force `preview_url` for them regardless of the lightbox_preview_enabled
|
||||
// toggle. Detection is by MIME first, extension as a fallback (browsers report
|
||||
// these MIMEs inconsistently). EXPERIMENTAL: whether a preview actually renders
|
||||
// still depends on the backend being able to decode the source (HEVC-in-HEIC on
|
||||
// the prod image; exiftool for DNG) — see #821.
|
||||
const NON_DISPLAYABLE_ORIGINAL_EXT = new Set(['heic', 'heif', 'dng']);
|
||||
const NON_DISPLAYABLE_ORIGINAL_MIME = new Set(['image/heic', 'image/heif', 'image/x-adobe-dng']);
|
||||
function originalNeedsPreview(photo) {
|
||||
const mime = (photo.mime_type || '').toLowerCase();
|
||||
if (NON_DISPLAYABLE_ORIGINAL_MIME.has(mime)) return true;
|
||||
const name = photo.original_filename || photo.filename || '';
|
||||
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
||||
return NON_DISPLAYABLE_ORIGINAL_EXT.has(ext);
|
||||
}
|
||||
|
||||
module.exports = { resolveHeroLogoVisible, originalNeedsPreview };
|
||||
@@ -0,0 +1,37 @@
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { COLOR_LABELS, SHARED_COLOR_LABEL_IDENTITY } = require('../constants/colorLabels');
|
||||
/** Apply the same own/shared feedback visibility before pagination and counts. */
|
||||
function applyFeedbackFilter(query, { filter, event, identity, sharedColorMode, showFeedbackToGuests }) {
|
||||
if (!filter) return query;
|
||||
const tokens = new Set(String(filter).toLowerCase().split(',').map(x => x.trim()).filter(Boolean));
|
||||
if (tokens.size === 0 || tokens.has('all')) return query;
|
||||
if (tokens.has('saved') || tokens.has('favorite')) tokens.add('favorited');
|
||||
const feedback = type => db('photo_feedback').where({ event_id: event.id, feedback_type: type,
|
||||
is_hidden: formatBoolean(false) }).select('photo_id');
|
||||
const own = q => identity.guestId ? q.where('guest_id', identity.guestId) : q.where('guest_identifier', identity.guestIdentifier);
|
||||
return query.where(function () {
|
||||
this.whereRaw('1 = 0');
|
||||
for (const [token, type, column] of [['liked', 'like', 'like_count'], ['favorited', 'favorite', 'favorite_count'], ['rated', 'rating', 'average_rating'], ['commented', 'comment', null]]) {
|
||||
if (!tokens.has(token)) continue;
|
||||
this.orWhereIn('photos.id', own(feedback(type)));
|
||||
if (showFeedbackToGuests) {
|
||||
if (column) this.orWhere(`photos.${column}`, '>', 0);
|
||||
else this.orWhereIn('photos.id', feedback(type).where('is_approved', formatBoolean(true)));
|
||||
}
|
||||
}
|
||||
const colors = COLOR_LABELS.filter(color => tokens.has(`color:${color}`));
|
||||
if (colors.length) {
|
||||
const colorQuery = feedback('color_label').whereIn('color_label', colors);
|
||||
if (sharedColorMode) {
|
||||
this.orWhereIn('photos.id', colorQuery.where('guest_identifier', SHARED_COLOR_LABEL_IDENTITY));
|
||||
} else {
|
||||
this.orWhereIn('photos.id', own(colorQuery.clone()));
|
||||
if (showFeedbackToGuests) this.orWhereIn('photos.id', colorQuery.where(function () {
|
||||
this.whereNot('guest_identifier', SHARED_COLOR_LABEL_IDENTITY).orWhereNull('guest_identifier');
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
module.exports = { applyFeedbackFilter };
|
||||
@@ -0,0 +1,567 @@
|
||||
const { toIso } = require('../utils/dateNormalize');
|
||||
const { db } = require('../database/db');
|
||||
const { parseBooleanInput } = require('../utils/parsers');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { SHARED_COLOR_LABEL_IDENTITY } = require('../constants/colorLabels');
|
||||
const watermarkService = require('./watermarkService');
|
||||
const logger = require('../utils/logger');
|
||||
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
|
||||
const { getUseOriginalFilenames } = require('./downloadFilenameService');
|
||||
const { resolveEventDownloadPolicy } = require('../utils/downloadResolutions');
|
||||
const { resolveHeroLogoVisible, originalNeedsPreview } = require('./galleryModel');
|
||||
const { applyFeedbackFilter } = require('./galleryPhotoQuery');
|
||||
async function getGalleryPhotos({ event, query = {}, identity, accessLevel, adminPreview, hiddenForGuest, slug }) {
|
||||
// Get filter and sort parameters from query
|
||||
// `guest_id` is deliberately NOT read from the query string: the viewer's
|
||||
// own feedback is resolved from the request identity instead (see the
|
||||
// filter block). The frontend still sends it; it is ignored.
|
||||
const { filter, sort = 'upload_date', order = 'desc' } = query;
|
||||
|
||||
// Get watermark settings to generate cache-busting version for URLs
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const wmVersion = watermarkSettings?.enabled
|
||||
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '';
|
||||
|
||||
// Build the query with sorting
|
||||
const sortOrder = order === 'asc' ? 'asc' : 'desc';
|
||||
const isClient = accessLevel === 'client';
|
||||
let photosQuery = db('photos')
|
||||
.where('photos.event_id', event.id)
|
||||
// Guests/clients never see photos still being processed by the
|
||||
// background worker — the original is on disk but the thumbnail
|
||||
// / dimensions / EXIF haven't landed yet. Photos with a NULL
|
||||
// processing_status are pre-async-migration rows and are treated
|
||||
// as complete (the migration's column default is 'complete' so
|
||||
// this is just defensive against partial migration states).
|
||||
.where(function() {
|
||||
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
|
||||
})
|
||||
.select('photos.*');
|
||||
|
||||
// Guests only see visible photos; clients see all
|
||||
if (!isClient) {
|
||||
photosQuery = photosQuery.where(function() {
|
||||
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
|
||||
});
|
||||
}
|
||||
|
||||
// Live Slideshow category filter (#202). Enforced server-side so the kiosk
|
||||
// viewer can't widen the set: when the event pins show_category_id, the
|
||||
// slideshow only sees that category. NULL = all photos (unchanged).
|
||||
if (accessLevel === 'slideshow' && event.show_category_id) {
|
||||
photosQuery = photosQuery.where('photos.category_id', event.show_category_id);
|
||||
}
|
||||
|
||||
// Apply sort option.
|
||||
//
|
||||
// Every branch carries photos.id as a tiebreaker (#1172). Without one the
|
||||
// order within a tie is whatever the engine happens to return, and ties are
|
||||
// the normal case rather than the exception: a bulk import writes hundreds
|
||||
// of rows inside the same second, so uploaded_at collapses — and with
|
||||
// captured_at NULL the COALESCE below collapses onto it too. The visible
|
||||
// symptom is a grid that reshuffles between page loads. id is insertion
|
||||
// order, so it also makes the fallback ordering meaningful rather than
|
||||
// arbitrary.
|
||||
if (sort === 'capture_date') {
|
||||
// Sort by capture date, falling back to uploaded_at if capture date is null.
|
||||
//
|
||||
// On SQLite that fallback cannot be a plain COALESCE, because the two
|
||||
// columns do not hold one type. photos.captured_at ends up carrying three
|
||||
// different storage classes:
|
||||
//
|
||||
// integer managed uploads — photoProcessor.js:488 writes a Date, which
|
||||
// the sqlite3 binding stores as epoch milliseconds
|
||||
// text external imports and the backfill, which write ISO-8601
|
||||
// ('2026-06-03T01:15:00.000Z') per the CLAUDE.md rule that
|
||||
// Dates must not be handed to the binding in tests
|
||||
// null no capture date, so the sort falls through to uploaded_at —
|
||||
// usually text in knex's 'YYYY-MM-DD HH:MM:SS' default shape,
|
||||
// but epoch milliseconds on rows written by a legacy archive
|
||||
// restore (see __tests__/integration/sqliteEpochTimestamps.js),
|
||||
// so that column needs the same two branches
|
||||
//
|
||||
// SQLite orders INTEGER before TEXT unconditionally, so every managed
|
||||
// photo carrying EXIF sorted ahead of every photo that did not, whatever
|
||||
// the actual dates — a 2027 capture landing before a 2020 one. Among the
|
||||
// text values the 'T' separator (0x54) also outranks the space (0x20), so
|
||||
// a same-day ISO 01:15 sorted after a fallback 23:00.
|
||||
//
|
||||
// Normalising in the ORDER BY rather than rewriting the column: the data
|
||||
// fix would have to touch every existing row and every writer, which is a
|
||||
// much heavier change than the sort it is meant to correct. The cost here
|
||||
// is that this sort stops using idx_photos_captured_at on SQLite — an
|
||||
// acceptable trade on the fallback engine, where the alternative is an
|
||||
// index-assisted wrong answer.
|
||||
//
|
||||
// Postgres is untouched: captured_at is a real timestamp there, so
|
||||
// COALESCE already compares correctly.
|
||||
if (db.client.config.client === 'pg') {
|
||||
photosQuery = photosQuery
|
||||
.orderByRaw('COALESCE(photos.captured_at, photos.uploaded_at) ' + sortOrder);
|
||||
} else {
|
||||
photosQuery = photosQuery.orderByRaw(`CASE
|
||||
WHEN typeof(photos.captured_at) IN ('integer', 'real') THEN datetime(photos.captured_at / 1000, 'unixepoch')
|
||||
WHEN photos.captured_at IS NOT NULL THEN replace(replace(substr(photos.captured_at, 1, 19), 'T', ' '), 'Z', '')
|
||||
WHEN typeof(photos.uploaded_at) IN ('integer', 'real') THEN datetime(photos.uploaded_at / 1000, 'unixepoch')
|
||||
ELSE substr(photos.uploaded_at, 1, 19)
|
||||
END ${sortOrder}`);
|
||||
}
|
||||
photosQuery = photosQuery.orderBy('photos.id', sortOrder);
|
||||
} else if (sort === 'filename') {
|
||||
photosQuery = photosQuery.orderBy('photos.filename', sortOrder).orderBy('photos.id', sortOrder);
|
||||
} else {
|
||||
// Default: sort by upload date
|
||||
photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder).orderBy('photos.id', sortOrder);
|
||||
}
|
||||
|
||||
// Reveal mode (#838): while the gallery is hidden, plain guests get
|
||||
// the event shell with an empty photo/category set plus the
|
||||
// hidden_until_reveal flag — the frontend renders the upload-only view
|
||||
// from it. Slideshow, client access and the admin preview bypass
|
||||
// (guestBlockedByReveal). Enforced here, not just in the UI.
|
||||
|
||||
|
||||
// Check if feedback should be visible to guests. Read BEFORE the filter
|
||||
// block, not after: the filters below consult it, because a filter that
|
||||
// selects on other people's feedback is a way of reading that feedback.
|
||||
const feedbackService = require('./feedbackService');
|
||||
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
const showFeedbackToGuests = isClient || parseBooleanInput(feedbackSettings.show_feedback_to_guests, true);
|
||||
// One identity-less colour tag per photo, any guest may overwrite it
|
||||
// (#1197). Read in three places below: the colour filters, the per-viewer
|
||||
// badge, and the "other viewers" dots that must not double-render it.
|
||||
const sharedColorMode = feedbackSettings?.identity_mode === 'shared';
|
||||
|
||||
applyFeedbackFilter(photosQuery, { filter, event, identity, sharedColorMode, showFeedbackToGuests });
|
||||
const limit = query.limit === undefined ? null : Math.min(250, Math.max(1, parseInt(query.limit, 10) || 100));
|
||||
const page = Math.max(1, parseInt(query.page, 10) || 1);
|
||||
const countRow = hiddenForGuest ? { total: 0 } : await photosQuery.clone().clearSelect().clearOrder().count('photos.id as total').first();
|
||||
const total = Number(countRow.total);
|
||||
if (limit) photosQuery.limit(limit).offset((page - 1) * limit);
|
||||
const photos = hiddenForGuest ? [] : await photosQuery;
|
||||
|
||||
// Then get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
.where('feedback_type', 'comment')
|
||||
.where('is_approved', formatBoolean(true))
|
||||
.where('is_hidden', formatBoolean(false))
|
||||
.groupBy('photo_id')
|
||||
.select('photo_id', db.raw('COUNT(*) as comment_count'));
|
||||
|
||||
// Create a map for quick lookup
|
||||
const commentMap = {};
|
||||
commentCounts.forEach(c => {
|
||||
commentMap[c.photo_id] = parseInt(c.comment_count);
|
||||
});
|
||||
|
||||
// Per-viewer "is_liked" set (#590 follow-up). Hard refresh on the
|
||||
// gallery grid used to reset every heart to empty because the lifted
|
||||
// likedPhotoIds state started as a fresh Set on mount — even photos
|
||||
// the viewer had actually liked. Surface a per-viewer flag so the
|
||||
// frontend can seed correctly. Prefers identity.guestId when a verified
|
||||
// guest token is present (per-person identity), falls back to the
|
||||
// IP+UA hash that the original like was recorded under — same model
|
||||
// the /my-feedback endpoint uses.
|
||||
//
|
||||
// NOT gated on showFeedbackToGuests (#1286). This query is filtered to
|
||||
// the VIEWER — by guest_id or by their own identifier — so what it
|
||||
// returns is their own selection, not shared aggregate data. Gating it
|
||||
// emptied every heart the guest had set themselves on a gallery with
|
||||
// sharing off, which reads as the gallery silently discarding their
|
||||
// choices. Same reasoning the colour-label block below already applies;
|
||||
// this was the one per-viewer field that disagreed with it.
|
||||
const likedPhotoIds = new Set();
|
||||
if (photos.length > 0) {
|
||||
const likeQuery = db('photo_feedback')
|
||||
// Hidden rows are not there, for the viewer's OWN feedback as much as
|
||||
// anyone's (#1150). getPhotoFeedback drops them, the filter drops them
|
||||
// and updatePhotoFeedbackStats does not count them — leaving the heart
|
||||
// filled was the one place that disagreed, so a like the photographer
|
||||
// had hidden still showed as liked on a photo whose like_count was 0.
|
||||
.where({ event_id: event.id, feedback_type: 'like', is_hidden: formatBoolean(false) })
|
||||
.whereIn('photo_id', photos.map(p => p.id));
|
||||
if (identity.guestId) {
|
||||
likeQuery.where('guest_id', identity.guestId);
|
||||
} else {
|
||||
likeQuery.where('guest_identifier', identity.guestIdentifier);
|
||||
}
|
||||
const likedRows = await likeQuery.select('photo_id');
|
||||
likedRows.forEach(row => likedPhotoIds.add(row.photo_id));
|
||||
}
|
||||
|
||||
// Per-viewer colour label (#1044), same identity resolution as the likes
|
||||
// above. NOT gated on showFeedbackToGuests: a guest's own label is their
|
||||
// own selection, not shared aggregate data, and hiding it would blank the
|
||||
// grid badges on every refresh in a gallery with sharing switched off.
|
||||
//
|
||||
// In shared identity mode (#1197) there is no per-viewer label to read:
|
||||
// the photo carries one tag and it belongs to everyone, so it arrives on
|
||||
// this same field. The badge, the lightbox swatch and the keyboard
|
||||
// shortcuts then work unchanged — they were already reading "the colour on
|
||||
// this photo, from my point of view", which is precisely what the shared
|
||||
// tag is.
|
||||
const myColorLabelByPhoto = {};
|
||||
if (photos.length > 0 && sharedColorMode) {
|
||||
Object.assign(
|
||||
myColorLabelByPhoto,
|
||||
await feedbackService.getSharedColorLabels(event.id, photos.map(p => p.id)),
|
||||
);
|
||||
} else if (photos.length > 0) {
|
||||
const colorQuery = db('photo_feedback')
|
||||
// Same rule as the heart above (#1150).
|
||||
.where({ event_id: event.id, feedback_type: 'color_label', is_hidden: formatBoolean(false) })
|
||||
.whereIn('photo_id', photos.map(p => p.id));
|
||||
if (identity.guestId) {
|
||||
colorQuery.where('guest_id', identity.guestId);
|
||||
} else {
|
||||
colorQuery.where('guest_identifier', identity.guestIdentifier);
|
||||
}
|
||||
const colorRows = await colorQuery.select('photo_id', 'color_label');
|
||||
colorRows.forEach(row => {
|
||||
if (row.color_label) myColorLabelByPhoto[row.photo_id] = row.color_label;
|
||||
});
|
||||
}
|
||||
|
||||
// OTHER viewers' colour labels, per photo (#1178).
|
||||
//
|
||||
// The lightbox has always shown these — /photos/:id/feedback returns
|
||||
// per-colour tallies across everyone — but the grid had no field carrying
|
||||
// them, so a label set by one guest was visible in fullscreen and invisible
|
||||
// on the tile. With sharing on, that is just a hole.
|
||||
//
|
||||
// DISTINCT colours, not counts: a tile has room for a couple of dots, and
|
||||
// "who else marked this, and how" is a lightbox question. The viewer's own
|
||||
// colour is excluded here so the badge and the dots never say the same
|
||||
// thing twice — the frontend renders `my_color_label` as the badge and
|
||||
// these beside it.
|
||||
//
|
||||
// Gated on showFeedbackToGuests, like every other aggregate: this is other
|
||||
// people's feedback, unlike my_color_label above.
|
||||
//
|
||||
// Skipped entirely in shared mode (#1197). There are no other viewers'
|
||||
// labels there — there is one tag, already delivered as my_color_label
|
||||
// above. Without this the shared row would come back here too (its
|
||||
// reserved identity is not the viewer's), and every tile would render the
|
||||
// same colour twice: once as the badge, once as a dot beside it.
|
||||
const otherColorLabelsByPhoto = {};
|
||||
if (photos.length > 0 && showFeedbackToGuests && !sharedColorMode) {
|
||||
const othersQuery = db('photo_feedback')
|
||||
.where({ event_id: event.id, feedback_type: 'color_label', is_hidden: formatBoolean(false) })
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
.whereNotNull('color_label')
|
||||
// The other direction of the same rule (#1197): an event switched back
|
||||
// out of shared mode keeps its shared tag, and it is nobody's — so
|
||||
// without this it would show up as an anonymous other viewer's dot on
|
||||
// every tile that still carries one.
|
||||
.where(function () {
|
||||
this.whereNot('guest_identifier', SHARED_COLOR_LABEL_IDENTITY).orWhereNull('guest_identifier');
|
||||
});
|
||||
if (identity.guestId) {
|
||||
othersQuery.where(function () {
|
||||
this.whereNot('guest_id', identity.guestId).orWhereNull('guest_id');
|
||||
});
|
||||
} else {
|
||||
const mine = identity.guestIdentifier;
|
||||
othersQuery.where(function () {
|
||||
this.whereNot('guest_identifier', mine).orWhereNull('guest_identifier');
|
||||
});
|
||||
}
|
||||
const otherRows = await othersQuery.distinct('photo_id', 'color_label');
|
||||
otherRows.forEach(row => {
|
||||
if (!otherColorLabelsByPhoto[row.photo_id]) otherColorLabelsByPhoto[row.photo_id] = [];
|
||||
if (!otherColorLabelsByPhoto[row.photo_id].includes(row.color_label)) {
|
||||
otherColorLabelsByPhoto[row.photo_id].push(row.color_label);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// People in each photo (#1074). Two independent gates: the feature must
|
||||
// be on for this event AND, for a plain guest, the photographer must have
|
||||
// left the strip visible. A client (PIN access) is the photographer's own
|
||||
// view, so faces_visible_to_guests doesn't restrict them.
|
||||
//
|
||||
// `photos` is already visibility-filtered above, and this only ever asks
|
||||
// about ids in that set, so it cannot widen what the caller sees.
|
||||
let peopleEnabled = false;
|
||||
let personIdsByPhoto = new Map();
|
||||
try {
|
||||
const { isEnabledForEvent, areFacesVisibleToGuests } = require('./faceSettings');
|
||||
if (photos.length > 0 && await isEnabledForEvent(event)) {
|
||||
peopleEnabled = isClient || areFacesVisibleToGuests(event);
|
||||
if (peopleEnabled) {
|
||||
const { getPersonIdsByPhoto } = require('./facePeopleService');
|
||||
personIdsByPhoto = await getPersonIdsByPhoto(
|
||||
event.id,
|
||||
photos.map(p => p.id),
|
||||
{ forAdmin: isClient }
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// A face-feature failure must never take down the gallery payload.
|
||||
logger.warn(`gallery: person_ids lookup failed for event ${event.id}`, { error: err.message });
|
||||
peopleEnabled = false;
|
||||
personIdsByPhoto = new Map();
|
||||
}
|
||||
|
||||
// Get actual categories used by photos in this event
|
||||
// This includes both global categories and event-specific ones
|
||||
const usedCategoryIds = hiddenForGuest ? [] : await db('photos')
|
||||
.where('event_id', event.id)
|
||||
.whereNotNull('category_id')
|
||||
.distinct('category_id')
|
||||
.pluck('category_id');
|
||||
|
||||
// Fetch category details from photo_categories table
|
||||
let categories = [];
|
||||
if (usedCategoryIds.length > 0) {
|
||||
// Resolved category order (#782): per-event override, else global
|
||||
// default, else name — restricted to categories that have photos.
|
||||
const categoryDetails = await getEventCategoriesOrdered(event.id, {
|
||||
onlyIds: usedCategoryIds,
|
||||
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads', 'c.is_folder'],
|
||||
});
|
||||
|
||||
categories = categoryDetails.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
is_global: cat.is_global,
|
||||
hero_photo_id: cat.hero_photo_id || null,
|
||||
// Per-category download flag (#640). false explicitly disables; the
|
||||
// gallery hides the download button. Defaults true so categories
|
||||
// created before migration 135 keep working.
|
||||
allow_downloads: parseBooleanInput(cat.allow_downloads, true),
|
||||
// Folder vs filter (#1160). true = the category CONTAINS its photos:
|
||||
// they leave the root grid and only render inside the folder. Defaults
|
||||
// false so categories predating migration 185 keep filtering.
|
||||
is_folder: parseBooleanInput(cat.is_folder, false)
|
||||
}));
|
||||
}
|
||||
|
||||
// Build a map for quick category lookup
|
||||
const categoryMap = {};
|
||||
categories.forEach(cat => {
|
||||
categoryMap[cat.id] = cat;
|
||||
});
|
||||
|
||||
// Include protection settings in response
|
||||
const protectionSettings = {
|
||||
protection_level: event.protection_level || 'standard',
|
||||
image_quality: event.image_quality || 85,
|
||||
use_canvas_rendering: parseBooleanInput(event.use_canvas_rendering, false),
|
||||
overlay_protection: parseBooleanInput(event.overlay_protection, true)
|
||||
};
|
||||
|
||||
// Lightbox preview tier (#492). When the admin opts in, the
|
||||
// photos response carries a preview_url alongside url/thumbnail_url
|
||||
// — the lightbox uses preview_url when present and falls back to
|
||||
// url when not, so existing galleries continue working before
|
||||
// any preview has actually been generated.
|
||||
let lightboxPreviewEnabled = false;
|
||||
try {
|
||||
const setting = await db('app_settings')
|
||||
.where('setting_key', 'lightbox_preview_enabled')
|
||||
.first();
|
||||
if (setting) {
|
||||
const raw = setting.setting_value;
|
||||
// setting_value is JSON-stringified per migration 104; tolerate
|
||||
// raw boolean/string for forward-compat.
|
||||
const parsed = typeof raw === 'string' ? (() => {
|
||||
try { return JSON.parse(raw); } catch { return raw; }
|
||||
})() : raw;
|
||||
lightboxPreviewEnabled = parsed === true || parsed === 'true' || parsed === 1;
|
||||
}
|
||||
} catch (e) {
|
||||
// Setting missing / DB blip → fall back to off so the lightbox
|
||||
// keeps working with the original. logger.debug to avoid noise.
|
||||
logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message });
|
||||
}
|
||||
|
||||
// #508: when the admin has flipped the "use original camera filenames"
|
||||
// toggle (#493), the lightbox surfaces each photo's original_filename
|
||||
// alongside the position counter so the photographer can map a guest's
|
||||
// selection back to source files. Tied to the same toggle as downloads —
|
||||
// one switch controls both surfaces.
|
||||
const useOriginalFilenames = await getUseOriginalFilenames();
|
||||
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||
const downloadPolicy = await resolveEventDownloadPolicy(event);
|
||||
|
||||
return {
|
||||
pagination: { page, limit: limit || total, total, has_more: !!limit && page * limit < total },
|
||||
event: {
|
||||
id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
welcome_message: event.welcome_message,
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
hero_photo_id: event.hero_photo_id,
|
||||
// Defaults match /info: downloads on unless explicitly disabled,
|
||||
// uploads off unless explicitly enabled (#1028).
|
||||
allow_downloads: parseBooleanInput(event.allow_downloads, true),
|
||||
allow_user_uploads: parseBooleanInput(event.allow_user_uploads, false),
|
||||
// Download resolutions (#858). `choices` drives the picker modal and is
|
||||
// empty when the picker is off, so the UI can never offer a size the
|
||||
// server would reject.
|
||||
download_resolution: {
|
||||
standard: downloadPolicy.standard,
|
||||
picker_enabled: downloadPolicy.pickerEnabled,
|
||||
choices: downloadPolicy.pickerEnabled ? downloadPolicy.choices : [],
|
||||
},
|
||||
// Reveal mode (#838): armed flag lets an open VISIBLE gallery keep
|
||||
// polling so a re-hide propagates without a manual reload.
|
||||
reveal_armed: parseBooleanInput(event.reveal_mode, false),
|
||||
disable_right_click: parseBooleanInput(event.disable_right_click, false),
|
||||
watermark_downloads: parseBooleanInput(event.watermark_downloads, false),
|
||||
watermark_text: event.watermark_text,
|
||||
enable_devtools_protection: parseBooleanInput(event.enable_devtools_protection, false),
|
||||
use_canvas_rendering: parseBooleanInput(event.use_canvas_rendering, false),
|
||||
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
|
||||
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
hero_logo_url: event.hero_logo_url || null,
|
||||
header_style: event.header_style || 'standard',
|
||||
hero_divider_style: event.hero_divider_style || 'wave',
|
||||
hero_image_anchor: event.hero_image_anchor || 'center',
|
||||
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
|
||||
// Promo banner override (#440). GalleryView has always read
|
||||
// promo_mode from THIS payload, but it was never sent — so every
|
||||
// per-event promo override silently resolved to 'inherit' and a
|
||||
// gallery set to 'off' still showed the global banner.
|
||||
promo_mode: event.promo_mode || 'inherit',
|
||||
promo_markdown: event.promo_markdown || null,
|
||||
// Info banner override (#932). GalleryAuthContext refreshes its cached
|
||||
// event from THIS payload, so the fields have to travel here — /info
|
||||
// alone isn't enough, the context stops reading it once the guest is
|
||||
// authenticated.
|
||||
info_mode: event.info_mode || 'inherit',
|
||||
info_markdown: event.info_markdown || null,
|
||||
download_zip_ready: !!(event.download_zip_path && event.download_zip_generated_at),
|
||||
// Mirror of the admin-side toggle so the lightbox can decide
|
||||
// whether to surface original camera filenames (#508).
|
||||
use_original_filenames: useOriginalFilenames,
|
||||
// "People in this gallery" (#1074). False whenever the global flag
|
||||
// is off, detection is off for this event, or the photographer chose
|
||||
// to keep the strip to themselves — the frontend renders no face UI
|
||||
// at all in that case.
|
||||
people_enabled: peopleEnabled,
|
||||
...protectionSettings
|
||||
},
|
||||
// Reveal mode (#838): the guest UI switches to the upload-only view
|
||||
// on this flag; reveal_at lets it show the scheduled time.
|
||||
hidden_until_reveal: hiddenForGuest,
|
||||
reveal_at: hiddenForGuest ? (event.reveal_at || null) : undefined,
|
||||
categories: categories,
|
||||
photos: photos.map(photo => {
|
||||
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||
// Watermark version (cache-busting) + admin-preview flag (#868). In
|
||||
// preview mode no gallery cookie is minted, so each <img> request must
|
||||
// re-assert the admin session — thread the flag onto every /api/gallery
|
||||
// image URL so the browser sends it (the admin_token cookie rides along
|
||||
// same-origin).
|
||||
const imgQuery = [wmVersion, adminPreview ? 'admin_preview=1' : ''].filter(Boolean).join('&');
|
||||
const wmQuery = imgQuery ? `?${imgQuery}` : '';
|
||||
const photoUrl = useJwtUrl ?
|
||||
`/api/gallery/${slug}/photo/${photo.id}${wmQuery}` :
|
||||
`/api/secure-images/${slug}/secure/${photo.id}/{{token}}`;
|
||||
|
||||
return {
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
// Raw camera filename (or null for pre-migration-062 uploads).
|
||||
// The lightbox renders it when `use_original_filenames` is on.
|
||||
original_filename: photo.original_filename || null,
|
||||
url: photoUrl,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${slug}/thumbnail/${photo.id}${wmQuery}` : null,
|
||||
// Hero-optimized image URL (1920x1080) for full-width hero sections
|
||||
hero_url: `/api/gallery/${slug}/hero/${photo.id}${wmQuery}`,
|
||||
// Lightbox preview URL (#492). Only emitted when the admin
|
||||
// has flipped lightbox_preview_enabled — the frontend
|
||||
// lightbox reads preview_url with a fallback to url so
|
||||
// installs that haven't opted in keep loading the original
|
||||
// (current behaviour). Skipped for videos since they don't
|
||||
// get a preview tier; lightbox will use the original .url.
|
||||
preview_url: (lightboxPreviewEnabled || originalNeedsPreview(photo))
|
||||
&& photo.media_type !== 'video'
|
||||
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
|
||||
? `/api/gallery/${slug}/preview/${photo.id}${wmQuery}`
|
||||
: null,
|
||||
// Slideshow source (#1015). Same preview tier, but emitted
|
||||
// unconditionally: the slideshow has no `url` fallback worth
|
||||
// taking (originals are projector-sized) and must never land on
|
||||
// `hero_url`, which is cover-cropped to 16:9 — that made the
|
||||
// "no crop" fit letterbox an already-cropped frame. The preview
|
||||
// route generates lazily and redirects to the original on any
|
||||
// failure, so this is safe even where no preview exists yet.
|
||||
slideshow_url: photo.media_type !== 'video'
|
||||
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
|
||||
? `/api/gallery/${slug}/preview/${photo.id}${wmQuery}`
|
||||
: null,
|
||||
secure_url_template: `/api/secure-images/${slug}/secure/${photo.id}/{{token}}`,
|
||||
download_url_template: `/api/secure-images/${slug}/secure-download/${photo.id}/{{token}}`,
|
||||
type: photo.type,
|
||||
category_id: photo.category_id || null,
|
||||
category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null,
|
||||
// Per-category download permission (#640). Defaults true for photos
|
||||
// without a category or for categories that pre-date migration 135.
|
||||
category_allow_downloads: photo.category_id && categoryMap[photo.category_id]
|
||||
? parseBooleanInput(categoryMap[photo.category_id].allow_downloads, true)
|
||||
: true,
|
||||
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
|
||||
size: photo.size_bytes,
|
||||
// toIso: on SQLite installs rows written with a raw Date (e.g.
|
||||
// the pre-fix archive-restore path) hold epoch numbers — the
|
||||
// Timeline layout's parseISO() crashes on those (#485 class).
|
||||
uploaded_at: toIso(photo.uploaded_at),
|
||||
// Image dimensions for layout calculations
|
||||
width: photo.width || null,
|
||||
height: photo.height || null,
|
||||
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||
requires_token: !useJwtUrl,
|
||||
// EXIF capture date
|
||||
captured_at: toIso(photo.captured_at) || null,
|
||||
// Media type
|
||||
media_type: photo.media_type || null,
|
||||
mime_type: photo.mime_type || null,
|
||||
duration: photo.duration || null,
|
||||
// Feedback data (hidden when show_feedback_to_guests is disabled)
|
||||
has_feedback: showFeedbackToGuests ? (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0) : false,
|
||||
average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0,
|
||||
comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0,
|
||||
like_count: showFeedbackToGuests ? (photo.like_count || 0) : 0,
|
||||
// Per-viewer flag (#590 follow-up) — true when this viewer has
|
||||
// an active like row for this photo, false otherwise. Lets the
|
||||
// grid seed its lifted likedPhotoIds correctly on hard refresh.
|
||||
// Survives show_feedback_to_guests being off (#1286): the viewer's
|
||||
// own heart is theirs, and the like_count beside it stays hidden.
|
||||
is_liked: likedPhotoIds.has(photo.id),
|
||||
favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0,
|
||||
// Colour labels (#1044). The COUNT is aggregate data and follows
|
||||
// show_feedback_to_guests like its siblings; the viewer's OWN label
|
||||
// is not aggregate and must survive with sharing off, otherwise the
|
||||
// grid badge disappears on refresh for the very guest who set it.
|
||||
color_label_count: showFeedbackToGuests ? (photo.color_label_count || 0) : 0,
|
||||
my_color_label: myColorLabelByPhoto[photo.id] || null,
|
||||
// Distinct colours other viewers put on this photo (#1178), so the
|
||||
// grid can show them beside the viewer's own badge. Empty with
|
||||
// sharing off — it is other people's feedback.
|
||||
other_color_labels: otherColorLabelsByPhoto[photo.id] || [],
|
||||
// People in this photo (#1074). Empty array when the feature is
|
||||
// off for this event or hidden from guests, so the frontend has
|
||||
// one shape to handle. Riding along on this payload is what keeps
|
||||
// face filtering client-side and instant, like the category and
|
||||
// liked/rated filters.
|
||||
person_ids: personIdsByPhoto.get(photo.id) || [],
|
||||
// Visibility (only included for clients)
|
||||
...(isClient ? { visibility: photo.visibility || 'visible' } : {})
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
module.exports = { getGalleryPhotos };
|
||||
@@ -24,13 +24,13 @@
|
||||
* `crmSchedulerService` is a future cleanup.
|
||||
*/
|
||||
|
||||
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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,16 @@ async function processEvent(event, storage) {
|
||||
previousSnapshot.set(event.id, currentKeys);
|
||||
}
|
||||
|
||||
const pollingTask = require('./scheduledTask').scheduledTask(runTick, { interval: POLL_INTERVAL_MS });
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
const cron = require('node-cron');
|
||||
const logger = require('../utils/logger');
|
||||
/** One owner for a periodic job, with no overlapping runs and a draining stop. */
|
||||
function scheduledTask(run, { schedule, interval, initialDelay } = {}) {
|
||||
let started = false, timer = null, first = null, running = null;
|
||||
const tick = () => {
|
||||
if (!started || running) return running;
|
||||
running = Promise.resolve().then(run).catch(error => {
|
||||
logger.error('Scheduled task failed', { error: error.message });
|
||||
}).finally(() => { running = null; });
|
||||
return running;
|
||||
};
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
timer = schedule ? cron.schedule(schedule, tick) : setInterval(tick, interval);
|
||||
if (!schedule) timer.unref?.();
|
||||
if (initialDelay !== undefined) { first = setTimeout(tick, initialDelay); first.unref?.(); }
|
||||
},
|
||||
async stop() {
|
||||
started = false;
|
||||
if (schedule) timer?.stop(); else clearInterval(timer);
|
||||
clearTimeout(first); timer = null; first = null;
|
||||
await running;
|
||||
},
|
||||
};
|
||||
}
|
||||
module.exports = { scheduledTask };
|
||||
@@ -9,6 +9,25 @@ class SecureImageService {
|
||||
this.tokenCache = new Map();
|
||||
this.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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const { toTimestamp } = require('./dateNormalize');
|
||||
const { AppError } = require('./errors');
|
||||
|
||||
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);
|
||||
return !Number.isFinite(expiry) || expiry <= now;
|
||||
}
|
||||
|
||||
function requiresGalleryPassword(event) {
|
||||
return !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
}
|
||||
|
||||
function isGalleryAvailable(event, { adminPreview = false } = {}) {
|
||||
return !!event && isTrue(event.is_active) && !isTrue(event.is_archived)
|
||||
&& (adminPreview || (!isTrue(event.is_draft) && !isGalleryExpired(event)));
|
||||
}
|
||||
|
||||
function assertGalleryAvailable(event, { adminPreview = false } = {}) {
|
||||
if (!isGalleryAvailable(event, { adminPreview })) {
|
||||
throw new AppError('Gallery not found or expired', 404, 'GALLERY_UNAVAILABLE');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isGalleryAvailable, assertGalleryAvailable, isGalleryExpired, requiresGalleryPassword };
|
||||
@@ -190,30 +190,27 @@ 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 must use the returned addresses from validateExternalUrlAsync
|
||||
* with pinnedRequestOptions; a separate preflight alone cannot stop rebinding.
|
||||
*
|
||||
* @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 +234,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 };
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Axios/Node lookup: connect only to the addresses vetted for this delivery.
|
||||
* Keep the original URL for Host, TLS SNI and certificate verification.
|
||||
* Disable environment proxies (which would resolve the destination themselves)
|
||||
* and redirects. No reusable agent/socket can carry an old DNS decision.
|
||||
*/
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
function pinnedRequestOptions(check) {
|
||||
if (!check?.valid || !check.hostname || !check.addresses?.length) {
|
||||
throw new Error('A validated destination is required');
|
||||
}
|
||||
const addresses = check.addresses.map(({ address, family }) => ({ address, family }));
|
||||
const lookup = (hostname, options, callback) => {
|
||||
if (typeof options === 'function') { callback = options; options = {}; }
|
||||
if (hostname !== check.hostname) return callback(new Error('Destination hostname changed'));
|
||||
const family = typeof options === 'number' ? options : options?.family;
|
||||
const matches = family ? addresses.filter(a => a.family === family) : addresses;
|
||||
if (!matches.length) return callback(new Error('No validated address for requested family'));
|
||||
if (options?.all) return callback(null, matches);
|
||||
callback(null, matches[0].address, matches[0].family);
|
||||
};
|
||||
return {
|
||||
proxy: false, maxRedirects: 0,
|
||||
httpAgent: new http.Agent({ lookup, keepAlive: false }),
|
||||
httpsAgent: new https.Agent({ lookup, keepAlive: false }),
|
||||
};
|
||||
}
|
||||
module.exports = { pinnedRequestOptions };
|
||||
@@ -1,3 +1,4 @@
|
||||
const { requestLogPath } = require('./requestLogPath');
|
||||
/**
|
||||
* Rate Limiting Security Utilities
|
||||
* 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')
|
||||
|
||||
@@ -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(/(\/api\/public\/[^/]+\/)[^/]+/gi, '$1[redacted]')
|
||||
.replace(/(\/(?:signed|verify-token|show|download-jobs|invite|accept-invite|password-reset)\/)[^/]+/gi, '$1[redacted]')
|
||||
.replace(/(\/(?:secure|secure-download)\/[^/]+\/)[^/]+/gi, '$1[redacted]')
|
||||
.replace(/\b(?:[a-f0-9]{32,}|eyJ[A-Za-z0-9_.-]+)\b/gi, '[redacted]')
|
||||
// eslint-disable-next-line no-control-regex -- strip log injection control bytes
|
||||
.replace(/[\r\n\x00-\x1f]/g, '');
|
||||
}
|
||||
module.exports = { requestLogPath };
|
||||
@@ -21,22 +21,23 @@ 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) {
|
||||
const site = req.headers['sec-fetch-site'];
|
||||
if (site) return site !== 'cross-site';
|
||||
// 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) {
|
||||
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; }
|
||||
}
|
||||
const site = req.headers['sec-fetch-site'];
|
||||
return !site || site === 'same-origin' || site === 'none';
|
||||
}
|
||||
|
||||
|
||||
module.exports = { isAllowedOrigin, multipartOriginAllowed };
|
||||
// Compatibility export for existing callers.
|
||||
const multipartOriginAllowed = mutationOriginAllowed;
|
||||
module.exports = { isAllowedOrigin, mutationOriginAllowed, multipartOriginAllowed };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
stopRevocationCleanup,
|
||||
revokeToken,
|
||||
isTokenRevoked,
|
||||
revokeAllUserTokens,
|
||||
|
||||
+81
-26
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 [$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,9 @@ map $http_x_forwarded_proto $real_proto {
|
||||
}
|
||||
|
||||
server {
|
||||
access_log /var/log/nginx/access.log picpeak_safe;
|
||||
# Request error logs expose raw token URLs; use safe access statuses.
|
||||
error_log /dev/null;
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
server_tokens off;
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# URLs and Referer can contain gallery, image and customer bearer tokens.
|
||||
map $uri $request_surface {
|
||||
~*^/api/(?<picpeak_surface>[a-z-]+)(?:/|$) /api/$picpeak_surface;
|
||||
default /;
|
||||
}
|
||||
log_format picpeak_safe '$remote_addr [$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,9 @@ map $http_x_forwarded_proto $real_proto {
|
||||
}
|
||||
|
||||
server {
|
||||
access_log /var/log/nginx/access.log picpeak_safe;
|
||||
# Request error logs expose raw token URLs; use safe access statuses.
|
||||
error_log /dev/null;
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
Generated
+344
-472
File diff suppressed because it is too large
Load Diff
@@ -22,14 +22,14 @@
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/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">
|
||||
|
||||
@@ -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,10 @@ 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,
|
||||
hardBreak: false, // We'll use the separate HardBreak extension
|
||||
codeBlock: false, // We'll use CodeBlockLowlight instead
|
||||
}),
|
||||
@@ -148,7 +173,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 +189,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,10 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
||||
const [showVariables, setShowVariables] = useState(false);
|
||||
|
||||
const editor = useEditor({
|
||||
shouldRerenderOnTransaction: true,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
link: false,
|
||||
hardBreak: false,
|
||||
}),
|
||||
HardBreak.configure({
|
||||
@@ -75,7 +100,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 +159,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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import type { Photo } from '../../../types';
|
||||
vi.mock('../../../contexts/ThemeContext', () => ({ useTheme: () => ({ theme: { gallerySettings: { carouselShowThumbnails: false } } }) }));
|
||||
vi.mock('../../common', () => ({ AuthenticatedImage: ({ alt }: { alt: string }) => <img alt={alt} />, Button: ({ children, ...props }: any) => <button {...props}>{children}</button> }));
|
||||
vi.mock('../../../contexts/GuestIdentityContext', () => ({ useGuestIdentityOptional: () => null }));
|
||||
import { CarouselGalleryLayout } from '../layouts/CarouselGalleryLayout';
|
||||
const photo = (id: number) => ({ id, filename: `photo-${id}`, url: '/photo' } as Photo);
|
||||
it('keeps the carousel usable when a refetch empties, reorders or removes photos', () => {
|
||||
const props = { slug: 'g', onPhotoClick: vi.fn(), onDownload: vi.fn(), photos: [] as Photo[] };
|
||||
const { rerender } = render(<CarouselGalleryLayout {...props} />);
|
||||
rerender(<CarouselGalleryLayout {...props} photos={[photo(1), photo(2)]} />);
|
||||
fireEvent.click(screen.getByLabelText('Next photo')); expect(screen.getByAltText('photo-2')).toBeTruthy();
|
||||
rerender(<CarouselGalleryLayout {...props} photos={[photo(2), photo(1)]} />); expect(screen.getByAltText('photo-2')).toBeTruthy();
|
||||
rerender(<CarouselGalleryLayout {...props} photos={[photo(1)]} />); expect(screen.getByAltText('photo-1')).toBeTruthy();
|
||||
rerender(<CarouselGalleryLayout {...props} photos={[]} />);
|
||||
rerender(<CarouselGalleryLayout {...props} photos={[photo(3)]} />); expect(screen.getByAltText('photo-3')).toBeTruthy();
|
||||
});
|
||||
@@ -1,95 +1,92 @@
|
||||
/**
|
||||
* A guest upload must show up in the grid on its own — and say so while it is
|
||||
* still being worked on.
|
||||
*
|
||||
* Guest uploads are queued: `POST /gallery/:id/upload` answers 202 and the row
|
||||
* lands as `processing_status: 'pending'`, while `GET /gallery/:slug/photos`
|
||||
* only returns completed rows. The old handler refetched exactly once (via a
|
||||
* full `window.location.reload()`), which always raced the background worker —
|
||||
* the payload was still byte-identical, the browser was answered 304, and the
|
||||
* guest's photo silently vanished until they hard-reloaded (QA P4-E.01).
|
||||
*
|
||||
* The follow-up (B7) replaced the blind count-baseline poll with one driven by
|
||||
* the real processing status of the guest's own upload group, so the UI can
|
||||
* show "processing…" and report a failure instead of timing out in silence.
|
||||
*
|
||||
* GalleryView needs its providers, the router and a dozen child components to
|
||||
* render, so this pins the contract at source level (same approach as
|
||||
* facePreviewRendition.test.ts).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { galleryService } from '../../../services/gallery.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useGalleryUpload } from '../hooks/useGalleryUpload';
|
||||
|
||||
const read = (...parts: string[]) =>
|
||||
fs.readFileSync(path.join(__dirname, '..', ...parts), 'utf8');
|
||||
vi.mock('../../../services/gallery.service', () => ({ galleryService: { getUploadStatus: vi.fn() } }));
|
||||
vi.mock('react-toastify', () => ({ toast: { error: vi.fn(), info: vi.fn() } }));
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
const pending = { total: 2, pending: 1, processing: 1, complete: 0, failed: 0 };
|
||||
const status = vi.mocked(galleryService.getUploadStatus);
|
||||
beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); status.mockReset().mockResolvedValue(pending); });
|
||||
afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); });
|
||||
function setup(slug = 'wedding') {
|
||||
const refetch = vi.fn().mockResolvedValue(undefined);
|
||||
const close = vi.fn();
|
||||
return { ...renderHook(({ slug }) => useGalleryUpload(slug, refetch, close), { initialProps: { slug } }), refetch, close };
|
||||
}
|
||||
async function tick(ms = 2000) { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); }
|
||||
|
||||
const source = read('GalleryView.tsx');
|
||||
const uploadSource = read('UserPhotoUpload.tsx');
|
||||
const serviceSource = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', '..', 'services', 'gallery.service.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const handler = source.slice(
|
||||
source.indexOf('const handleUploadComplete'),
|
||||
source.indexOf('const uploadProcessingNotice')
|
||||
);
|
||||
|
||||
describe('post-upload photo refresh', () => {
|
||||
it('never reloads the page to pick up an upload', () => {
|
||||
expect(source).not.toContain('window.location.reload');
|
||||
describe('post-upload refresh', () => {
|
||||
it('tracks uploads, refreshes progressively and stops after completion', async () => {
|
||||
const { result, refetch, close, unmount } = setup();
|
||||
await act(async () => { result.current.handleUploadComplete(['one', 'two']); });
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(status).toHaveBeenLastCalledWith('wedding', ['one', 'two']);
|
||||
expect(result.current.uploadProcessing).toEqual({ complete: 0, total: 2 });
|
||||
status.mockResolvedValue({ ...pending, pending: 0, complete: 1 });
|
||||
await tick();
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
expect(result.current.uploadProcessing).toEqual({ complete: 1, total: 2 });
|
||||
status.mockResolvedValue({ ...pending, pending: 0, processing: 0, complete: 2 });
|
||||
await tick();
|
||||
expect(result.current.uploadProcessing).toBeNull();
|
||||
const calls = status.mock.calls.length;
|
||||
await tick(10_000);
|
||||
expect(status).toHaveBeenCalledTimes(calls);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('drives the refresh off the upload group\'s processing status', () => {
|
||||
expect(handler).toContain('galleryService.getUploadStatus(slug, uploadIds)');
|
||||
// Refetch as photos land, not only once the whole batch settles.
|
||||
expect(handler).toContain('status.complete > lastComplete');
|
||||
expect(handler).toMatch(/setInterval\(poll/);
|
||||
it('reports failed processing after the batch settles', async () => {
|
||||
status.mockResolvedValue({ ...pending, pending: 0, processing: 0, failed: 2 });
|
||||
const { result, refetch, unmount } = setup();
|
||||
await act(async () => { result.current.handleUploadComplete(['one', 'two']); });
|
||||
expect(toast.error).toHaveBeenCalledWith('upload.processingFailed');
|
||||
expect(result.current.uploadProcessing).toBeNull();
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('stops on the real terminal condition rather than a count baseline', () => {
|
||||
expect(handler).toContain('status.pending === 0 && status.processing === 0');
|
||||
// Still bounded, so a wedged worker can never leave a poll running forever.
|
||||
expect(handler).toContain('Date.now() > deadline');
|
||||
it('bounds a pending batch and announces ongoing processing', async () => {
|
||||
const { result, refetch, unmount } = setup();
|
||||
await act(async () => { result.current.handleUploadComplete(['one', 'two']); });
|
||||
await tick(122_000);
|
||||
expect(result.current.uploadProcessing).toBeNull();
|
||||
expect(toast.info).toHaveBeenCalledWith('upload.processingStillRunning');
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('tells the guest when a photo failed processing or is still queued', () => {
|
||||
expect(handler).toContain("toast.error(t('upload.processingFailed'");
|
||||
expect(handler).toContain("toast.info(t('upload.processingStillRunning')");
|
||||
// ...and renders a "processing…" notice while the poll runs.
|
||||
expect(source).toContain("t('upload.processing')");
|
||||
expect(source).toContain("t('upload.processingProgress'");
|
||||
it('falls back to one refresh if status cannot be read', async () => {
|
||||
status.mockRejectedValue(new Error('offline'));
|
||||
const { result, refetch, unmount } = setup();
|
||||
await act(async () => { result.current.handleUploadComplete(['one']); });
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
expect(result.current.uploadProcessing).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('degrades to a plain refetch when the status call itself fails', () => {
|
||||
expect(handler).toContain('} catch {');
|
||||
expect(handler).toContain('await finish();');
|
||||
it('refreshes once without polling when there are no accepted uploads', async () => {
|
||||
const { result, refetch, unmount } = setup();
|
||||
await act(async () => { result.current.handleUploadComplete([]); });
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('wires the polling handler into the upload modals that render the grid', () => {
|
||||
const wired = source.match(/onUploadComplete=\{handleUploadComplete\}/g) || [];
|
||||
expect(wired.length).toBeGreaterThanOrEqual(2);
|
||||
// The notice is rendered next to each of them; the two layout branches
|
||||
// have no shared wrapper to hang it on.
|
||||
const shown = source.match(/\{uploadProcessingNotice\}/g) || [];
|
||||
expect(shown.length).toBe(wired.length);
|
||||
});
|
||||
|
||||
it('clears the poll when the gallery unmounts', () => {
|
||||
expect(source).toContain('useEffect(() => stopUploadRefresh, [])');
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload id plumbing', () => {
|
||||
it('hands the 202 upload ids to the gallery', () => {
|
||||
expect(uploadSource).toContain('onUploadComplete: (uploadIds: string[]) => void');
|
||||
expect(uploadSource).toContain('uploadIds.push(response.data.upload_id)');
|
||||
expect(uploadSource).toContain('onUploadComplete(uploadIds)');
|
||||
});
|
||||
|
||||
it('asks the gallery-scoped status route, batching the ids into one request', () => {
|
||||
expect(serviceSource).toContain('`/gallery/${slug}/uploads/status`');
|
||||
expect(serviceSource).toContain("params: { ids: uploadIds.join(',') }");
|
||||
it.each(['unmount', 'navigate', 'new batch'] as const)('ignores an old request after %s', async (action) => {
|
||||
let resolve!: (value: typeof pending) => void;
|
||||
status.mockReturnValueOnce(new Promise(done => { resolve = done; }));
|
||||
const { result, refetch, rerender, unmount } = setup();
|
||||
await act(async () => { result.current.handleUploadComplete(['old']); });
|
||||
await tick(6000);
|
||||
expect(status).toHaveBeenCalledOnce();
|
||||
if (action === 'unmount') unmount();
|
||||
else if (action === 'navigate') rerender({ slug: 'another' });
|
||||
else await act(async () => { result.current.handleUploadComplete(['new']); });
|
||||
await act(async () => { resolve({ ...pending, pending: 0, processing: 0, complete: 1, failed: 1 }); });
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
if (action !== 'unmount') unmount();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user