fix: enforce gallery access and consolidate gallery workflows (#1357)

Harden gallery authentication and authorization, consolidate gallery workflows, and prevent token-bearing URLs from leaking through nginx request error logs.
This commit is contained in:
Paul Nothaft
2026-09-08 15:34:09 +02:00
committed by GitHub
parent 895e5ab3cc
commit f0e6d2dfb1
120 changed files with 7147 additions and 8525 deletions
@@ -26,6 +26,8 @@ jest.mock('../database/db', () => {
return { db: mockDb, withRetry };
});
jest.mock('../utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
jest.mock('../utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn().mockResolvedValue(false) }));
jest.mock('../utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
@@ -83,8 +85,12 @@ function mockEventAndAssignment({ event, assignment }) {
assignChain.where = jest.fn().mockReturnValue(assignChain);
assignChain.first = jest.fn().mockResolvedValue(assignment);
db.mockImplementationOnce(() => eventsChain)
.mockImplementationOnce(() => assignChain);
db.mockImplementation((table) => {
if (table === 'events') return eventsChain;
if (table === 'customer_accounts') return { ...eventsChain, first: jest.fn().mockResolvedValue({ id: 7 }) };
if (table === 'event_customer_assignments') return assignChain;
throw new Error('Unexpected table: ' + table);
});
return { eventsChain, assignChain };
}
@@ -101,7 +107,7 @@ describe('verifyGalleryAccess — customer-minted JWT with active assignment', (
it('allows access when the event_customer_assignments row exists', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
type: 'gallery', iat: Math.floor(Date.now() / 1000),
eventId: 42,
via: 'customer',
customerId: 7,
@@ -132,7 +138,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
type: 'gallery', iat: Math.floor(Date.now() / 1000),
eventId: 42,
via: 'customer',
customerId: 7,
@@ -162,7 +168,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
// and start 403'ing per-event-password sessions.
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
type: 'gallery', iat: Math.floor(Date.now() / 1000),
eventId: 42,
customerId: 7,
// intentionally no `via` claim
@@ -194,7 +200,7 @@ describe('verifyGalleryAccess — per-event-password JWT', () => {
it('does NOT touch event_customer_assignments and passes through', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
type: 'gallery', iat: Math.floor(Date.now() / 1000),
eventId: 42,
// No via, no customerId — this is the legacy per-event-password
// flow where every guest mints their own JWT after entering the
@@ -0,0 +1,11 @@
const logger = require('../utils/logger');
const { requestLogPath } = require('../utils/requestLogPath');
module.exports = function apiRequestLogger(req, res, next) {
const started = Date.now();
const path = requestLogPath(req.originalUrl);
logger.info(`${req.method} ${path}`);
res.once('finish', () => {
logger.info(`${req.method} ${path} -> ${res.statusCode} (${Date.now() - started}ms)`);
});
next();
};
+9 -100
View File
@@ -1,9 +1,5 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isMissingRolesSchema } = require('../utils/dbErrors');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const sessionAccess = require('../services/sessionAccessService');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
@@ -32,100 +28,10 @@ async function adminAuth(req, res, next) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
logger.warn('Revoked token used', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject any session issued before the global cutoff (set by a .picpeak
// restore, which can reassign admin ids). Forces every pre-restore admin
// session to re-authenticate against the restored data.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Verify token type
if (decoded.type !== 'admin') {
logger.warn('Non-admin token used for admin endpoint', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(403).json({ error: 'Insufficient permissions' });
}
// IP validation (optional - can be strict or just log)
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.warn('Token used from different IP', {
userId: decoded.id,
tokenIp: decoded.ip,
currentIp: currentIp
});
}
// Check if admin still exists and is active, including role info
// Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario)
let admin;
try {
admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
.select(
'admin_users.id',
'admin_users.username',
'admin_users.email',
'admin_users.password_changed_at',
'roles.id as role_id',
'roles.name as role_name'
)
.first();
} catch (joinError) {
// Fail CLOSED on anything that isn't a genuinely missing roles schema:
// the fallback below fabricates super_admin, so a transient query failure
// (connection reset, deadlock, statement timeout, pool exhaustion) must
// not become a free privilege upgrade for every scoped admin. Rethrow →
// outer catch → 401, which is already how every other transient DB fault
// in this try block behaves (isTokenRevoked / isTokenBeforeCutoff both
// hit the DB here). apiTokenAuth takes the same posture on the v1
// surface, differing only in its 500.
if (!isMissingRolesSchema(joinError)) throw joinError;
// Fallback: roles table may not exist yet during upgrade
// Query without role join - user will have no role info but can still authenticate
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'password_changed_at')
.first();
if (admin) {
admin.role_id = null;
admin.role_name = 'super_admin'; // Assume super_admin for existing users during upgrade
}
}
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if password was changed after token was issued. JWT `iat` has
// 1-second resolution; `password_changed_at` is sub-second. Floor the
// comparison so a token issued in the *same* second as the password
// change isn't incorrectly rejected — that race used to bite anyone
// logging in immediately after a password reset/change.
if (admin.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(admin.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
logger.warn('Token used after password change', { userId: decoded.id });
return res.status(401).json({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED'
});
}
const admin = await sessionAccess.admin(decoded);
const requestIp = req.ip || req.connection?.remoteAddress;
if (decoded.ip && requestIp && decoded.ip !== requestIp) {
logger.info('admin session IP changed', { accountId: admin.id, tokenIp: decoded.ip, requestIp });
}
// Add user info to request (enhanced with role)
@@ -146,7 +52,10 @@ async function adminAuth(req, res, next) {
next();
} catch (error) {
logger.error('Auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
res.status(error.statusCode || 401).json({
error: error.isOperational ? error.message : 'Authentication failed',
...(error.isOperational && { code: error.code }),
});
}
}
+15
View File
@@ -0,0 +1,15 @@
const { mutationOriginAllowed } = require('../utils/requestOrigin');
module.exports = function csrfProtection(req, res, next) {
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return next();
if (!mutationOriginAllowed(req)) {
return res.status(403).json({ error: 'Cross-site request rejected' });
}
const contentType = (req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
const hasBody = Number(req.headers['content-length']) > 0 || !!req.headers['transfer-encoding'];
const jsonLike = contentType === 'application/json' || contentType.endsWith('+json');
if (hasBody && !jsonLike && contentType !== 'multipart/form-data') {
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
}
next();
};
+12 -72
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Customer Authentication Middleware
*
@@ -10,10 +11,7 @@
*/
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const sessionAccess = require('../services/sessionAccessService');
const logger = require('../utils/logger');
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
@@ -25,7 +23,7 @@ async function customerAuth(req, res, next) {
// normal (page polling, pre-login session probes). Bump to debug
// for noisy investigations only.
logger.debug('[customerAuth] no token on request', {
url: req.originalUrl,
url: requestLogPath(req.originalUrl),
hasCookieHeader: !!req.headers?.cookie,
cookieKeys: Object.keys(req.cookies || {}),
});
@@ -42,7 +40,7 @@ async function customerAuth(req, res, next) {
decoded = verified.payload;
} catch (err) {
logger.warn('[customerAuth] jwt verification failed', {
url: req.originalUrl,
url: requestLogPath(req.originalUrl),
errorName: err.name,
errorMessage: err.message,
});
@@ -52,71 +50,10 @@ async function customerAuth(req, res, next) {
return res.status(401).json({ error: 'Invalid token', code: 'JWT_INVALID' });
}
if (await isTokenRevoked(decoded)) {
logger.warn('[customerAuth] token revoked', {
url: req.originalUrl,
customerId: decoded.customerId,
tokenType: decoded.type,
iat: decoded.iat,
});
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
if (decoded.type !== 'customer') {
logger.warn('[customerAuth] wrong token type', {
url: req.originalUrl,
tokenType: decoded.type,
});
return res.status(403).json({ error: 'Insufficient permissions', code: 'WRONG_TOKEN_TYPE' });
}
// IP drift gets logged but doesn't reject — same lenient policy as
// adminAuth. Customers may roam between mobile networks frequently.
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.info('Customer token used from different IP', {
customerId: decoded.customerId,
tokenIp: decoded.ip,
currentIp,
});
}
const customer = await db('customer_accounts')
.where({ id: decoded.customerId, is_active: formatBoolean(true) })
.select('id', 'email', 'display_name', 'first_name', 'last_name', 'password_changed_at', 'preferred_language')
.first();
if (!customer) {
// Either deleted, deactivated, or the id was forged. 401 across the
// board so the frontend session-expiry handler kicks in.
logger.warn('[customerAuth] customer row not found / inactive', {
url: req.originalUrl,
customerId: decoded.customerId,
});
return res.status(401).json({ error: 'Invalid token', code: 'CUSTOMER_NOT_FOUND' });
}
if (customer.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(customer.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
logger.warn('[customerAuth] token rejected: password_changed_at', {
url: req.originalUrl,
customerId: decoded.customerId,
iat: decoded.iat,
passwordChangedSeconds,
});
return res.status(401).json({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED',
});
}
const customer = await sessionAccess.customer(decoded);
const requestIp = req.ip || req.connection?.remoteAddress;
if (decoded.ip && requestIp && decoded.ip !== requestIp) {
logger.info('customer session IP changed', { accountId: customer.id, tokenIp: decoded.ip, requestIp });
}
req.customer = {
@@ -131,7 +68,10 @@ async function customerAuth(req, res, next) {
next();
} catch (error) {
logger.error('Customer auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
res.status(error.statusCode || 401).json({
error: error.isOperational ? error.message : 'Authentication failed',
...(error.isOperational && { code: error.code }),
});
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Global error handler middleware.
* Catches all errors and returns standardized responses.
@@ -119,7 +120,7 @@ const errorHandler = (err, req, res, next) => {
// Log the error
const logContext = {
url: req.originalUrl,
url: requestLogPath(req.originalUrl),
method: req.method,
ip: req.ip,
statusCode,
@@ -161,7 +162,7 @@ const errorHandler = (err, req, res, next) => {
*/
const notFoundHandler = (req, res, next) => {
const { NotFoundError } = require('../utils/errors');
next(new NotFoundError('Route', req.originalUrl));
next(new NotFoundError('Route', requestLogPath(req.originalUrl)));
};
/**
+5 -1
View File
@@ -1,3 +1,4 @@
const cleanupTimers = new Set();
const crypto = require('crypto');
const { db } = require('../database/db');
const logger = require('../utils/logger');
@@ -211,7 +212,7 @@ function strictRateLimit(options = {}) {
const store = new Map();
// Clean up old entries periodically
setInterval(() => {
const cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, data] of store.entries()) {
if (data.resetTime < now) {
@@ -219,6 +220,8 @@ function strictRateLimit(options = {}) {
}
}
}, windowMs);
cleanupTimer.unref();
cleanupTimers.add(cleanupTimer);
return (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress;
@@ -255,6 +258,7 @@ function strictRateLimit(options = {}) {
}
module.exports = {
dispose() { cleanupTimers.forEach(clearInterval); cleanupTimers.clear(); },
feedbackRateLimit,
strictRateLimit,
generateGuestIdentifier,
+70 -233
View File
@@ -1,267 +1,104 @@
const jwt = require('jsonwebtoken');
const { db, withRetry } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const access = require('../services/galleryAccessService');
/**
* True when a logged-in admin is explicitly previewing this gallery (#868).
*
* Two conditions, both required:
* 1. The explicit intent flag `?admin_preview=1` is present. The plain share
* link stays byte-identical to a guest's, so the password gate is still
* testable as a guest while logged in as admin — and the bypass is visible
* in the URL without being reusable (it carries no secret).
* 2. A VERIFIED admin session — the httpOnly `admin_token` cookie (rides along
* on same-origin API calls) or an Authorization: Bearer header, never the
* URL. Must decode as `type: 'admin'`, issuer `picpeak-auth`.
*
* The cookie is tried FIRST and the Bearer is accepted only when it is itself an
* admin token (#981 review): the frontend attaches a gallery Bearer to gallery
* endpoints, and a header-first, type-blind read would let a coexisting gallery
* session shadow the admin cookie and wrongly disable the preview.
*
* Fails closed on any verification error. Replaces the old `?preview=<raw-JWT>`
* scheme, which leaked a 24h admin token into the address bar.
*/
// Cookie first: a coexisting gallery Bearer must not shadow an admin preview.
function decodeAdminPreview(req) {
if (req.query?.admin_preview !== '1') return null;
// Cookie first, then a Bearer — but only an admin-typed token satisfies it.
const candidates = [];
if (req.cookies?.admin_token) candidates.push(req.cookies.admin_token);
const candidates = [req.cookies?.admin_token];
const header = req.headers?.authorization;
if (header && header.startsWith('Bearer ')) candidates.push(header.slice(7));
for (const token of candidates) {
if (header?.startsWith('Bearer ')) candidates.push(header.slice(7));
for (const token of candidates.filter(Boolean)) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth', algorithms: ['HS256'],
});
if (decoded.type === 'admin') return decoded;
} catch { /* try the next candidate */ }
}
return null;
}
// Signature-only predicate retained for UI-intent callers. It never authorizes.
function isAdminPreview(req) {
return decodeAdminPreview(req) !== null;
}
/**
* The full session check behind the preview bypass. A verified signature is
* not a live session: adminAuth also rejects revoked tokens, tokens issued
* before the restore cutoff, deactivated admins and tokens minted before the
* admin's last password change. Without those a logged-out or deactivated
* admin token kept unlocking every draft and password gallery until `exp`
* (30 days with remember-me). Sets req.isAdminPreview on success so the
* downstream reveal-mode and logging checks read one verified flag.
*/
async function verifyAdminPreview(req) {
if (req.isAdminPreview === true) return true;
function attachAccess(req, event, grant) {
req.event = event;
req.galleryAccess = grant;
req.isAdminPreview = grant.kind === 'admin';
req.accessLevel = grant.session?.accessLevel || 'guest';
req.viaCustomer = grant.session?.via === 'customer';
req.sessionID = req.isAdminPreview ? `gallery_admin_preview_${event.id}`
: `gallery_${grant.kind === 'public' ? 'public_' : ''}${event.id}_${Date.now()}`;
const ip = req.ip || req.connection?.remoteAddress || 'unknown';
const userAgent = req.get?.('User-Agent') || 'unknown';
req.clientInfo = {
ip, userAgent, fingerprint: `${ip}-${userAgent}`.substring(0, 32), timestamp: Date.now(),
};
}
async function verifyAdminPreview(req, event) {
if (req.isAdminPreview && req.galleryAccess && (!event || event.id === req.event?.id)) return true;
const decoded = decodeAdminPreview(req);
if (!decoded) return false;
try {
if (await isTokenRevoked(decoded) || await isTokenBeforeCutoff(decoded)) return false;
const admin = await withRetry(async () => db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'password_changed_at')
.first());
if (!admin) return false;
if (admin.password_changed_at) {
const changedSeconds = Math.floor(new Date(admin.password_changed_at).getTime() / 1000);
if (decoded.iat < changedSeconds) return false;
}
} catch (err) {
logger.warn('Admin preview session check failed', { error: err.message });
const slug = req.params?.slug || req.requestedSlug;
if (!event && !slug) return false;
event = event || await db('events').where({ slug }).select('*').first();
if (!event) return false;
const grant = access.grant(event, 'admin', decoded);
await access.authorize(event, grant);
attachAccess(req, event, grant);
return true;
} catch (error) {
logger.debug('Admin gallery preview denied', { code: error.code });
req.adminPreviewDenied = error;
return false;
}
req.isAdminPreview = true;
return true;
}
// Middleware to verify gallery access
function decodeGalleryToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'], issuer: 'picpeak-auth' });
} catch (error) {
// Legacy gallery tokens lacked an issuer, but still need the same type,
// lifecycle and session checks as current tokens.
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
}
throw error;
}
}
async function verifyGalleryAccess(req, res, next) {
try {
const requestedSlug = req.params.slug || req.requestedSlug;
// Admin preview (#868) is resolved BEFORE any gallery credential (#981
// review): a coexisting gallery token/Bearer must not shadow it, and the
// admin session must never fall into the `type !== 'gallery'` reject path
// below. Per-request bypass — draft + password relaxed, NO gallery JWT
// minted (a lingering guest cookie would muddy the coexisting-cookies case).
// req.isAdminPreview flags downstream logging to keep it out of guest stats.
if (await verifyAdminPreview(req)) {
if (!requestedSlug) {
return res.status(401).json({ error: 'No token provided' });
}
const previewEvent = await withRetry(async () => db('events')
.where({ slug: requestedSlug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('*').first());
if (!previewEvent) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
req.event = previewEvent;
req.isAdminPreview = true;
req.sessionID = `gallery_admin_preview_${previewEvent.id}`;
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
timestamp: Date.now()
};
return next();
}
const token = getGalleryTokenFromRequest(req, requestedSlug);
let event;
if (!token) {
if (!requestedSlug) {
return res.status(401).json({ error: 'No token provided' });
}
event = await withRetry(async () => db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
})
.select('*').first());
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (!requiresPassword) {
req.event = event;
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
timestamp: Date.now()
};
return next();
}
return res.status(401).json({ error: 'No token provided' });
}
// Try to verify with issuer first, fallback to no issuer for backward compatibility
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth'
});
} catch (error) {
// If verification fails with issuer, try without issuer (backward compatibility)
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
} else {
throw error;
}
}
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
// Only gallery-scoped tokens grant gallery access. Every legitimate
// path (password login, share link, client access, customer-minted,
// slideshow) mints type:'gallery'. Reject anything else — e.g. a guest
// identity token (type:'guest', for feedback attribution) that carries a
// matching eventId — instead of relying on other token types incidentally
// lacking an eventId to fail the id match below.
if (decoded.type !== 'gallery') {
if (await verifyAdminPreview(req)) return next();
// The caller asked for a preview explicitly: report why it was refused
// instead of falling through to a misleading guest-token error.
if (req.adminPreviewDenied?.isOperational) throw req.adminPreviewDenied;
const slug = req.params.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, slug);
const decoded = token ? decodeGalleryToken(token) : null;
if (decoded && decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid token type for gallery access' });
}
// If we have a slug in the URL params or from pre-middleware, verify it matches.
// (Admin preview never reaches here — it returns above — so drafts stay
// filtered for every real gallery-token request.)
if (requestedSlug) {
// Verify by slug and ensure it matches the token's event
event = await withRetry(async () => db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
})
.select('*').first());
// Verify the token's eventId matches
if (event && event.id !== decoded.eventId) {
return res.status(403).json({ error: 'Token does not match requested gallery' });
}
} else {
// Fallback to using eventId from token
event = await withRetry(async () => db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
})
.select('*').first());
}
if (!event) {
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
return res.status(404).json({ error: 'Gallery not found or expired' });
}
// Customer-minted gallery JWTs (#354): when the customer obtained
// this token via /api/customer/events/:slug/access-token, the
// payload carries `via:'customer'` and `customerId`. The admin
// can revoke the customer's access at any time by removing the
// event_customer_assignments row from the "Manage galleries"
// dialog on the customer detail page. Re-check that row here so
// the revocation takes effect on the customer's very next
// request — no token-blacklisting machinery required.
if (decoded.via === 'customer' && decoded.customerId) {
const assignment = await withRetry(async () => {
return await db('event_customer_assignments')
.where({
event_id: event.id,
customer_account_id: decoded.customerId,
})
.first();
});
if (!assignment) {
logger.info('[verifyGalleryAccess] Customer assignment revoked, rejecting token', {
customerId: decoded.customerId,
eventId: event.id,
});
return res.status(403).json({
error: 'Access to this gallery has been revoked',
code: 'CUSTOMER_ASSIGNMENT_REVOKED',
});
}
}
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
req.event = event;
req.accessLevel = decoded.accessLevel || 'guest';
// Customer-portal provenance (#746/#849): portal-minted tokens carry
// via:'customer' but NO accessLevel (they default to guest), while
// PIN-client logins carry accessLevel:'client' without `via`. Activity
// attribution/dedup needs the distinction, so surface it explicitly.
req.viaCustomer = decoded.via === 'customer';
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
// Create client info for logging (similar to secureImageMiddleware but simpler)
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32), // Limit to 32 chars for DB column
timestamp: Date.now()
};
logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
next();
if (!slug && !decoded?.eventId) return res.status(401).json({ error: 'No token provided' });
const event = await withRetry(() => db('events').where(slug ? { slug } : { id: decoded.eventId }).select('*').first());
if (!event) return res.status(404).json({ error: 'Gallery not found or expired' });
const grant = access.grant(event, decoded ? 'gallery' : 'public', decoded);
await access.authorize(event, grant);
attachAccess(req, event, grant);
return next();
} catch (error) {
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
res.status(401).json({ error: 'Invalid token' });
if (!error.isOperational) logger.error('Error verifying gallery access', { error: error.message });
return res.status(error.statusCode || 401).json({
error: error.isOperational ? error.message : 'Invalid token',
...(error.code && error.isOperational && { code: error.code }),
});
}
}
+7 -1
View File
@@ -1,6 +1,11 @@
const { db } = require('../database/db');
const logger = require('../utils/logger');
function canAccessEvent(admin, event) {
return Boolean(admin && event && (admin.roleName === 'super_admin'
|| event.created_by == null || Number(event.created_by) === Number(admin.id)));
}
/**
* Middleware to enforce event ownership for non-super_admin users.
* Super admins bypass the check. Other admins can only access events they created.
@@ -23,7 +28,7 @@ function requireEventOwnership(req, res, next) {
return res.status(404).json({ error: 'Event not found' });
}
// Allow access if: event has no owner (legacy/system), or admin owns it
if (event.created_by && event.created_by !== req.admin.id) {
if (!canAccessEvent(req.admin, event)) {
return res.status(403).json({ error: 'Access denied' });
}
next();
@@ -165,6 +170,7 @@ function requireProjectOwnership(req, res, next) {
}
module.exports = {
canAccessEvent,
requireEventOwnership,
filterOwnedEventIds,
scopeEventsQuery,
+3 -2
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Permission Checking Middleware for RBAC
* Provides role-based access control with caching for performance
@@ -143,7 +144,7 @@ function requirePermission(permissions, options = { requireAll: false }) {
userId: req.admin.id,
username: req.admin.username,
requiredPermissions: permArray,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
method: req.method
});
throw new ForbiddenError('Insufficient permissions');
@@ -180,7 +181,7 @@ function requireSuperAdmin() {
logger.warn('Super admin access denied', {
userId: req.admin.id,
username: req.admin.username,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
method: req.method
});
throw new ForbiddenError('Super Admin access required');
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
const { db } = require('../database/db');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
@@ -10,12 +11,24 @@ class SecureImageMiddleware {
this.suspiciousIPs = new Set();
this.blockedFingerprints = new Set();
this.rateLimitViolations = new Map();
this.cleanupTimer = null;
}
start() {
if (this.cleanupTimer) return;
this.cleanupTimer = setInterval(() => this.cleanup(), 300000);
this.cleanupTimer.unref();
}
dispose() {
clearInterval(this.cleanupTimer); this.cleanupTimer = null;
this.suspiciousIPs.clear(); this.blockedFingerprints.clear(); this.rateLimitViolations.clear();
}
/**
* Main security middleware for image access
*/
secureImageAccess = async (req, res, next) => {
this.start();
try {
const startTime = Date.now();
const clientIP = this.getClientIP(req);
@@ -56,7 +69,7 @@ class SecureImageMiddleware {
error: error.message,
stack: error.stack,
ip: req.ip,
path: req.path
path: requestLogPath(req.originalUrl || req.path)
});
res.status(500).json({
@@ -328,7 +341,7 @@ class SecureImageMiddleware {
client_ip: req.clientInfo?.ip || req.ip,
client_fingerprint: req.clientInfo?.fingerprint,
user_agent: req.get('User-Agent')?.substring(0, 255),
request_path: req.path,
request_path: requestLogPath(req.originalUrl || req.path),
request_method: req.method,
details: JSON.stringify(details),
timestamp: new Date().toISOString()
@@ -409,9 +422,4 @@ class SecureImageMiddleware {
// Create singleton instance
const secureImageMiddleware = new SecureImageMiddleware();
// Setup cleanup interval
setInterval(() => {
secureImageMiddleware.cleanup();
}, 300000); // Every 5 minutes
module.exports = secureImageMiddleware;
+2 -1
View File
@@ -26,7 +26,7 @@ const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries
// behaviour is unchanged: the timer fires every 5 min as long as
// the server has anything else keeping the loop alive (HTTP server,
// other intervals), which is always.
setInterval(() => {
const cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [token, lastActivity] of sessions.entries()) {
if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) {
@@ -208,6 +208,7 @@ function getActiveSessions() {
}
module.exports = {
dispose: () => { clearInterval(cleanupTimer); sessions.clear(); cachedTimeout = null; cacheExpiry = 0; },
sessionTimeoutMiddleware,
isSessionExpired,
endSession,
+14 -578
View File
@@ -5,7 +5,7 @@
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../../database/db');
const { formatBoolean } = require('../../utils/dbCompat');
const { slugify } = require('../../utils/slug');
const { adminAuth } = require('../../middleware/auth');
const { requirePermission, userHasAllPermissions } = require('../../middleware/permissions');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../../utils/emailNormalization');
@@ -25,14 +25,13 @@ const eventTypeService = require('../../services/eventTypeService');
const { normaliseEventTimeTriple } = require('../../services/eventService');
const { hasColumnCached } = require('../../utils/schemaCache');
const { requireEventOwnership } = require('../../middleware/ownership');
const { getAppSetting } = require('../../utils/appSettings');
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
const { clampIntOrUndefined } = require('../../utils/numericHelpers');
const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl');
const downloadZipService = require('../../services/downloadZipService');
const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults');
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults,
getImageSecurityDefaults, resolveImageSecurityColumns, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
const { KEYBIND_MODES } = require('../../services/feedbackDefaults');
const { validateHeroImageAnchor, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade } = require('./helpers');
/**
* `events.slug` is UNIQUE, and both routes that mint one do a read-then-insert
@@ -174,7 +173,6 @@ async function queueGalleryCreatedEmail(event, { password, requirePassword } = {
module.exports = (router) => {
// Create new event
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').notEmpty().trim().custom(async (value) => {
@@ -240,7 +238,6 @@ module.exports = (router) => {
body('watermark_text').optional().trim(),
// #328 follow-up: per-event opt-in for presigned-URL "Download All".
// Bypasses watermarks; admin must enable knowingly.
body('allow_presigned_download').optional().isBoolean(),
// Feedback sub-toggles (#1044). Optional: omitting them inherits the
// global Settings > Events defaults.
body('allow_ratings').optional().isBoolean(),
@@ -298,571 +295,13 @@ module.exports = (router) => {
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
// Get field requirements from settings
const fieldRequirements = await getEventFieldRequirements();
const {
event_type,
event_name,
event_date,
// Migration 137 — calendar time fields. is_full_day defaults to
// true at the service layer when undefined (legacy form payloads).
event_time_start,
event_time_end,
is_full_day,
admin_email,
password,
welcome_message = '',
color_theme = null,
expiration_days = 30,
allow_user_uploads = false,
upload_category_id = null,
allow_downloads = true,
disable_right_click = false,
enable_devtools_protection: enableDevtoolsProtectionInput,
watermark_downloads = false,
watermark_text = null,
allow_presigned_download = false,
require_password: requirePasswordInput,
// Feedback settings. The allow_* sub-toggles deliberately have NO
// destructuring defaults: `undefined` means "the caller didn't say",
// which inherits the global Settings > Events default (#1044). The
// admin create form posts explicit values (it seeds its own panel
// from the same globals), so inheritance here is what covers the v1
// API and any other caller that omits them.
feedback_enabled: feedbackEnabledInput,
allow_ratings: allowRatingsInput,
allow_likes: allowLikesInput,
allow_comments: allowCommentsInput,
allow_favorites: allowFavoritesInput,
allow_reactions: allowReactionsInput,
allow_color_labels: allowColorLabelsInput,
keybind_mode: keybindModeInput,
require_name_email = false,
moderate_comments = true,
show_feedback_to_guests = true,
// The create form has always shown the identity-mode chooser and this
// route has never read it, so a gallery created as 'guest' quietly came
// out 'simple' and the photographer had to set it again on the event.
// Surfaced by adding a third mode (#1197); the fix is the same for all
// three. Unknown values fall back rather than reaching the column,
// which on Postgres is guarded by a CHECK constraint.
identity_mode: identityModeInput,
// CSS Template
css_template_id = null,
// Hero logo settings
hero_logo_visible = true,
// Header style settings
header_style = 'standard',
hero_divider_style = 'wave',
// Hero image anchor position (#162)
hero_image_anchor = 'center',
// Photo cap
photo_cap = null,
// Client access settings (#172)
client_access_enabled = false,
client_password = null,
// Draft mode
is_draft = true,
// Default photo sort
default_photo_sort = 'upload_date_desc',
// Banner overrides (#440 / #932) — see the insert below.
promo_mode = 'inherit',
promo_markdown = null,
info_mode = 'inherit',
info_markdown = null
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
const customerEmail = getCustomerEmailFromPayload(req.body);
// Phone field is opt-in via the global setting (#322). If disabled,
// ignore whatever the client posted — defence in depth against form
// bypass.
const phoneEnabled = await isPhoneFieldEnabled();
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
const customerColumnsAvailable = await hasCustomerContactColumns();
// Conditional validation based on settings
const validationErrors = [];
if (fieldRequirements.require_customer_name && !customerName) {
validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' });
}
if (fieldRequirements.require_customer_email && !customerEmail) {
validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' });
}
if (fieldRequirements.require_admin_email && !admin_email) {
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
}
if (fieldRequirements.require_event_date && !event_date) {
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
}
if (validationErrors.length > 0) {
return res.status(400).json({ errors: validationErrors });
}
// Default require_password from global "event_default_require_password"
// setting when the body omits it (#317 — admins want to flip the default).
let requirePasswordFallback = true;
if (requirePasswordInput === undefined) {
const setting = await readBooleanSetting('event_default_require_password');
if (setting !== undefined) requirePasswordFallback = setting;
}
const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
// Default feedback_enabled from global "event_default_feedback_enabled"
// setting when the body omits it (#520 — same pattern as require_password
// above, lets admins make Guest Feedback ON the out-of-box default for
// new events instead of toggling it on every time).
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await readBooleanSetting('event_default_feedback_enabled');
if (setting !== undefined) feedbackEnabledFallback = setting;
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
// Sub-toggle defaults from the global Settings > Events values (#1044).
// One batched read; an explicitly-sent body value still wins.
const feedbackDefaults = applyFeedbackDefaults({
allow_ratings: allowRatingsInput,
allow_likes: allowLikesInput,
allow_comments: allowCommentsInput,
allow_favorites: allowFavoritesInput,
allow_reactions: allowReactionsInput,
allow_color_labels: allowColorLabelsInput,
keybind_mode: keybindModeInput,
}, await resolveEventFeedbackDefaults());
// Debug logging
logger.debug('Download control values', {
allow_downloads,
disable_right_click,
watermark_downloads,
watermark_text,
require_password: requirePassword,
types: {
allow_downloads: typeof allow_downloads,
disable_right_click: typeof disable_right_click,
watermark_downloads: typeof watermark_downloads
}
});
let passwordValidation = null;
if (requirePassword) {
passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
}
// Generate unique slug. Uses the shared util so accented names
// (Família, Decoração, etc.) get transliterated instead of dropped
// — see backend/src/utils/slug.js for the why (#525).
const processedEventName = slugify(event_name);
// Use event_date in slug if provided, otherwise use random suffix
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds (random placeholder when not required)
const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date)
// If expiration is not required, expires_at will be null (never expires)
// If event_date is not provided, use current date as base for expiration
let expires_at = null;
if (fieldRequirements.require_expiration) {
const baseDate = event_date || new Date().toISOString().split('T')[0];
// Parse YYYY-MM-DD format as local date to avoid timezone issues
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(baseDate);
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
}
// Create folder structure
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
const eventPath = path.join(storagePath, 'events/active', slug);
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158).
let effectiveHeaderStyle = header_style;
let effectiveDividerStyle = hero_divider_style;
if (color_theme && (!req.body.header_style || !req.body.hero_divider_style)) {
try {
if (typeof color_theme === 'string' && color_theme.startsWith('{')) {
const parsed = JSON.parse(color_theme);
if (!req.body.header_style && parsed.headerStyle) {
effectiveHeaderStyle = parsed.headerStyle;
}
if (!req.body.hero_divider_style && parsed.heroDividerStyle) {
effectiveDividerStyle = parsed.heroDividerStyle;
}
}
} catch (_) {
// color_theme is not JSON nothing to extract
}
}
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
const brandingDefaults = await getBrandingDefaults();
// hero_logo_visible: store NULL ("inherit") unless the admin explicitly
// set it, so the global branding_logo_display_hero toggle keeps
// controlling this gallery afterwards (#756). Only an explicit per-event
// choice overrides the global. `!= null` treats an explicit null the same
// as omitted (both → inherit); otherwise formatBoolean(null) would coerce
// to 0/false on SQLite instead of NULL (the PUT handler already does this).
const effectiveHeroLogoVisible = req.body.hero_logo_visible != null
? formatBoolean(hero_logo_visible)
: null;
// NULL = inherit the global branding_logo_size (#756), resolved at read
// time. Only an explicit per-event size overrides it.
const effectiveHeroLogoSize = req.body.hero_logo_size || null;
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
// Inherit "Detect dev tools" from the global Image Security setting unless
// the request explicitly overrides it (#317 — admin disabled it globally
// but new events still got it ON because the column default is true).
const protectionDefaults = await getDownloadProtectionDefaults();
// #1296 — the other four Image-security settings, which were written,
// rendered as controls, and read by nothing. Same inheritance rule as
// the devtools setting below. Creation-time only; see
// getImageSecurityDefaults for why existing events are left alone.
const imageSecurityColumns = resolveImageSecurityColumns(
req.body,
await getImageSecurityDefaults(),
);
const effectiveEnableDevtoolsProtection =
enableDevtoolsProtectionInput !== undefined
? enableDevtoolsProtectionInput
: protectionDefaults.enable_devtools_protection !== undefined
? protectionDefaults.enable_devtools_protection
: true;
// Migration 137 — normalise calendar time triple. Throws AppError
// 400 when is_full_day=false but times are malformed/inverted.
const calendarTriple = normaliseEventTimeTriple({
event_time_start, event_time_end, is_full_day,
});
const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
// Insert into database
// Seed the new event's Live Slideshow display style from the PICPEAK-WIDE
// preset (app_settings, Settings → Slideshow). New events inherit it and the
// admin can still override per event. Watermark is left NULL = inherit the
// global watermark; the share token is minted on demand, not seeded. Guarded
// so un-migrated installs (mid-branch) don't reference missing columns.
let slideshowSeed = {};
if (await hasColumnCached('events', 'show_interval_ms')) {
try {
// parseInt-first: the previous `Number.isFinite(+v)` pre-check let
// NaN through for null/''/true (+null is 0, parseInt(null) is NaN),
// producing show_interval_ms=NaN in the INSERT — PG rejects that
// with "invalid input syntax for type integer" while SQLite
// silently stores NULL, so event creation 500'd on PG whenever the
// slideshow app_settings rows were absent.
const intP = (v, min, max) => clampIntOrUndefined(v, min, max);
const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined);
const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000);
const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS);
const tms = intP(await getAppSetting('slideshow_transition_ms', undefined), 100, 5000);
const cf = oneOf(await getAppSetting('slideshow_colorfilter', undefined), SLIDESHOW_COLORFILTERS);
if (i !== undefined) slideshowSeed.show_interval_ms = i;
if (tr) slideshowSeed.show_transition = tr;
if (tms !== undefined) slideshowSeed.show_transition_ms = tms;
if (cf) slideshowSeed.show_colorfilter = cf;
} catch (e) {
logger.warn('Failed to seed slideshow settings from global preset', { error: e.message });
}
}
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
...slideshowSeed,
event_date: event_date || null,
...(calendarColumnsExist ? {
event_time_start: calendarTriple.event_time_start,
event_time_end: calendarTriple.event_time_end,
is_full_day: formatBoolean(calendarTriple.is_full_day),
} : {}),
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName || null,
host_email: customerEmail || null,
admin_email: admin_email || null,
password_hash,
// Opt-in recoverable copy (#1271), written with the hash so the two
// can never disagree. Empty unless the security setting is on.
...(await galleryPasswordColumns({
...(requirePassword && password ? { password } : {}),
...(client_access_enabled && client_password ? { clientPassword: client_password } : {}),
})),
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads,
upload_category_id,
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection),
// Request value, else the global default, else the column default —
// a key absent here is one the database fills in (#1296).
...imageSecurityColumns,
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text,
allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'),
require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null,
// Already formatBoolean-coerced above, or null = inherit global (#756).
hero_logo_visible: effectiveHeroLogoVisible,
hero_logo_size: effectiveHeroLogoSize,
hero_logo_position: effectiveHeroLogoPosition,
// Banner overrides. Both were accepted by the validators above and
// then dropped here, so an API client could POST info_mode:'off' or a
// custom banner, get 201, and find the row still on 'inherit'.
// Markdown is only stored for 'custom' — same rule the PUT applies.
promo_mode: ['inherit', 'custom', 'off'].includes(promo_mode) ? promo_mode : 'inherit',
promo_markdown: promo_mode === 'custom' && typeof promo_markdown === 'string' && promo_markdown.trim()
? promo_markdown.trim() : null,
info_mode: ['inherit', 'custom', 'off'].includes(info_mode) ? info_mode : 'inherit',
info_markdown: info_mode === 'custom' && typeof info_markdown === 'string' && info_markdown.trim()
? info_markdown.trim() : null,
header_style: effectiveHeaderStyle || 'standard',
hero_divider_style: effectiveDividerStyle || 'wave',
hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null,
is_draft: formatBoolean(parseBooleanInput(is_draft, true)),
default_photo_sort: default_photo_sort || 'upload_date_desc',
// Client access (#172)
client_access_enabled: formatBoolean(client_access_enabled),
...(client_access_enabled && client_password ? {
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
client_share_token: crypto.randomBytes(32).toString('hex')
} : {}),
// Per-event opt-in for hero-photo OG share image (#474). Defaults
// false on create — admin opts in from the event detail page once
// they've picked a hero they're comfortable surfacing publicly.
og_image_share_enabled: formatBoolean(req.body.og_image_share_enabled === true),
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// #1271 — the setting was read before the hashes; re-check after the write
await dropCopiesIfStorageOff(eventId);
// Apply customer-account assignments (#354). Skip when the customer
// portal flag is off — the frontend hides the picker in that case,
// but a stale tab could still POST customer_account_ids; we ignore
// them rather than 403 the entire create.
if (Array.isArray(req.body.customer_account_ids)) {
try {
const customerAccountsService = require('../../services/customerAccountsService');
if (await customerAccountsService.isCustomerPortalEnabled()) {
await customerAccountsService.setAssignmentsForEvent(
eventId,
req.body.customer_account_ids,
req.admin.id
);
}
} catch (e) {
logger.error('Failed to set customer assignments on event create', {
eventId, error: e.message,
});
}
}
// Insert feedback settings if feedback is enabled
if (feedback_enabled) {
await db('event_feedback_settings').insert({
event_id: eventId,
feedback_enabled: formatBoolean(feedback_enabled),
allow_ratings: formatBoolean(feedbackDefaults.allow_ratings),
allow_likes: formatBoolean(feedbackDefaults.allow_likes),
allow_comments: formatBoolean(feedbackDefaults.allow_comments),
allow_favorites: formatBoolean(feedbackDefaults.allow_favorites),
allow_reactions: formatBoolean(feedbackDefaults.allow_reactions),
allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels),
keybind_mode: feedbackDefaults.keybind_mode,
require_name_email: formatBoolean(require_name_email),
moderate_comments: formatBoolean(moderate_comments),
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
identity_mode: ['simple', 'guest', 'shared'].includes(identityModeInput)
? identityModeInput
: 'simple',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
// Log activity
await logActivity('event_created',
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Fire event.created webhook (#327). If the event is being published
// immediately (not a draft), event.published also fires below.
// Payload uses canonical event subject (#341) so receivers always see
// the same shape (id/slug/event_name + customer contact + share_*).
try {
const webhookService = require('../../services/webhookService');
await webhookService.fire('event.created', {
event: {
...webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
}),
is_draft: parseBooleanInput(is_draft, true),
},
});
} catch (e) { /* webhookService.fire never throws but be defensive */ }
// Queue creation email (only if there is a recipient and event is not a draft)
// Language detection is handled by email processor
const isDraft = parseBooleanInput(is_draft, true);
if (customerEmail && !isDraft) {
// Build email data with optional client access info
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
};
// Include client access info in email when enabled (#172)
if (client_access_enabled && client_password) {
const createdEvent = await db('events').where('id', eventId).first();
// Same FRONTEND_URL-before-APP_URL order as before: APP_URL is
// passed as the override so it still outranks the general_site_url
// setting and the request origin. Chaining it after the resolver
// would make it dead code, because the resolver only returns falsy
// when NOTHING is configured (#1104).
const frontendUrl = await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL });
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
emailData.client_password = client_password;
}
await db('email_queue').insert({
event_id: eventId,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
// scheduled_at will use default value
});
}
// WhatsApp gallery_ready notification (#640D). Fires when the event is
// created NOT as a draft, the `whatsapp` flag is on, a config exists, and
// the customer supplied a phone number. Non-fatal: a queue failure should
// never block gallery creation.
if (!isDraft && customerPhone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
customer_name: customerName || '',
event_name,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : '',
expiry_date: expires_at ? expires_at.toISOString() : null,
language: null, // resolved by processor via general_default_language
});
}
} catch (waError) {
logger.warn('Failed to queue WhatsApp notification on create', { error: waError.message });
}
}
// Fire event.published when the event is created NOT as a draft. The
// separate /publish endpoint fires it for the draft → live transition;
// this covers the "create-and-publish in one shot" path.
if (!isDraft) {
try {
const webhookService = require('../../services/webhookService');
await webhookService.fire('event.published', {
event: webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
}),
});
} catch (e) { /* non-fatal */ }
}
res.json({
id: eventId,
slug,
event_name,
event_type,
customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
photo_cap: photo_cap || null,
is_draft: isDraft,
share_link: shareUrl,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
const created = await require('../../services/eventCreationService').createEvent(req.body, {
actor: req.admin,
frontendUrl: await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL }),
});
res.json(created);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json(error.responseBody || { error: error.message, code: error.code });
if (isDuplicateSlugError(error)) {
logger.warn('Event creation lost the slug race', { error: error.message });
return res.status(409).json(DUPLICATE_SLUG_RESPONSE);
@@ -1425,6 +864,7 @@ module.exports = (router) => {
share_token: shareToken,
expires_at: newExpiresAt ? newExpiresAt.toISOString() : null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads: source.allow_user_uploads,
upload_category_id: source.upload_category_id,
@@ -1441,7 +881,6 @@ module.exports = (router) => {
use_canvas_rendering: source.use_canvas_rendering,
watermark_downloads: source.watermark_downloads,
watermark_text: source.watermark_text,
allow_presigned_download: source.allow_presigned_download,
require_password: source.require_password,
css_template_id: source.css_template_id || null,
hero_logo_visible: source.hero_logo_visible,
@@ -1598,7 +1037,6 @@ module.exports = (router) => {
body('disable_right_click').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(),
body('allow_presigned_download').optional().isBoolean(),
body('source_mode').optional().isIn(['managed', 'reference']),
body('external_path').optional({ nullable: true }).isString().trim(),
body('external_watch').optional().isBoolean(),
@@ -2029,8 +1467,6 @@ module.exports = (router) => {
}
}
// Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158). This ensures the
// database columns stay in sync even if the frontend only sends the
@@ -2232,12 +1668,12 @@ module.exports = (router) => {
return res.status(404).json({ error: 'Event not found' });
}
const newStatus = !event.is_active;
const newStatus = !parseBooleanInput(event.is_active, false);
await db('events')
.where('id', id)
.update({
is_active: newStatus,
updated_at: new Date()
is_active: formatBoolean(newStatus),
updated_at: new Date().toISOString()
});
// Log activity
+2 -421
View File
@@ -1,392 +1,9 @@
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
// Shared helpers + module-level caches used across the adminEvents sub-routers.
const { db, logActivity } = require('../../database/db');
const fs = require('fs').promises;
const path = require('path');
const logger = require('../../utils/logger');
const { parseStringInput } = require('../../utils/parsers');
const settings = require('../../services/eventSettings');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (value) => {
if (['top', 'center', 'bottom'].includes(value)) return true;
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
}
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
};
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
// Helper to get event field requirements from settings
const getEventFieldRequirements = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email',
'event_require_event_date',
'event_require_expiration'
])
.select('setting_key', 'setting_value');
const requirements = {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
value = value === 'true';
}
}
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
});
return requirements;
} catch (error) {
logger.error('Failed to get event field requirements', { error: error.message });
return {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
}
};
// Helper to read app_settings booleans by key, used to inherit per-setting
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
// so callers can fall back to a legacy default.
/**
* Decode an app_settings value into the JS value it represents.
*
* setting_value is JSON text on SQLite and may already be decoded by the
* driver on a PG json column, so one parse does not normalise both. On top
* of that, the Image Security tab used to PUT back values it had read
* undecoded, wrapping another layer of quoting around each one on every
* save — the GET handler decodes now, but installs carry however many
* layers they accumulated before that.
*
* Every reader of app_settings has to agree about this, or the admin UI
* shows one thing while event creation does another.
*
* Terminates: each parse of a string is strictly shorter than its input.
*/
const decodeSettingValue = (raw) => {
let value = raw;
while (typeof value === 'string') {
let parsed;
try { parsed = JSON.parse(value); } catch { break; }
if (parsed === value) break;
value = parsed;
}
return value;
};
const readBooleanSetting = async (key) => {
try {
const setting = await db('app_settings').where('setting_key', key).first();
if (!setting) return undefined;
const value = decodeSettingValue(setting.setting_value);
return typeof value === 'boolean' ? value : undefined;
} catch (error) {
logger.error('Failed to read app setting', { key, error: error.message });
return undefined;
}
};
// Helper to read the global "enable_devtools_protection" admin setting so
// new events inherit it instead of always falling back to the DB column default
// (#317 — admin disabled it globally but new events still got it ON).
const getDownloadProtectionDefaults = async () => {
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
};
/**
* The rest of Settings → Image security, as creation defaults (#1296).
*
* Four settings in that panel were written, reloaded and rendered as
* controls, and read by nothing:
*
* default_protection_level → events.protection_level
* default_image_quality → events.image_quality
* enable_canvas_rendering → events.use_canvas_rendering
*
* Each maps onto a column migration 038 already created, and each is
* labelled "… by default", so applying them at creation is what the panel
* has always claimed to do. `enable_devtools_protection` above is the only
* one of the five that was ever wired.
*
* Creation-time only, deliberately. Applying them to EXISTING events would
* silently change live galleries on upgrade — an install with
* enable_canvas_rendering already on would switch every grid to canvas
* rendering, which is memory-expensive at scale and is the profile under
* investigation in #1287. New events only; existing rows untouched.
*
* Any value that is missing or malformed comes back undefined so the caller
* falls through to the column default, exactly as before this existed.
*/
const PROTECTION_LEVELS = ['basic', 'standard', 'enhanced', 'maximum'];
// parseInt would rescue malformed settings instead of rejecting them:
// parseInt('72oops') is 72, parseInt(72.5) is 72, parseInt([72]) is 72.
// That matters because the settings PUT stores whatever JSON it is handed
// without validating the value (adminImageSecurity.js writes
// JSON.stringify(value) for any allow-listed key), so those shapes really
// can be sitting in app_settings. Accept only a genuine integer, or a
// string that is exactly one.
const toInteger = (value) => {
if (typeof value === 'number') return Number.isInteger(value) ? value : undefined;
if (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim())) return Number(value.trim());
return undefined;
};
const getImageSecurityDefaults = async (trx = null) => {
const defaults = {};
try {
// Accepts a transaction the way getAppSetting does. It matters on
// sqlite3, whose pool holds a single connection: a caller already inside
// db.transaction() that read through the global `db` would block on the
// connection its own transaction holds until the acquire timeout, and
// the catch below would then quietly swallow it and drop the defaults.
const query = trx || db;
const rows = await query('app_settings')
.whereIn('setting_key', [
'default_protection_level',
'default_image_quality',
'enable_canvas_rendering',
])
.select('setting_key', 'setting_value');
// app_settings holds JSON text on SQLite, while a PG json column comes
// back already decoded — so one parse is not enough to normalise both.
// Worse, GET /api/admin/image-security/settings returns setting_value
// without decoding it and the settings tab PUTs the whole fetched object
// straight back through JSON.stringify, so opening the tab and saving
// re-encodes every value it read as text. After one such round trip
// `true` is stored as "\"true\"" and a single parse yields the string
// 'true', which the type checks below reject — the settings would go
// quietly dead again, which is the bug this whole change exists to fix.
// The GET handler now decodes, so this stops accumulating — but installs
// that already stacked N layers have to keep working, and N is however
// many times someone opened that tab. So unwrap until it stops being a
// JSON string rather than to a fixed depth; this terminates because each
// parse of a string is strictly shorter than its input.
const read = (key) => {
const row = rows.find((r) => r.setting_key === key);
if (!row) return undefined;
return decodeSettingValue(row.setting_value);
};
const level = read('default_protection_level');
if (typeof level === 'string' && PROTECTION_LEVELS.includes(level)) {
defaults.protection_level = level;
}
// The column is an integer percentage; anything outside 1..100 is a
// misconfiguration and falls through rather than being clamped into
// something the operator did not choose.
const quality = toInteger(read('default_image_quality'));
if (quality !== undefined && quality >= 1 && quality <= 100) {
defaults.image_quality = quality;
}
const canvas = read('enable_canvas_rendering');
if (typeof canvas === 'boolean') {
defaults.use_canvas_rendering = canvas;
}
} catch (error) {
// A settings read must never block event creation; the column defaults
// are a correct fallback.
logger.error('Failed to read image-security defaults', { error: error.message });
}
return defaults;
};
/**
* Build the image-security columns for a NEW event: an explicit request
* value wins, then the global default, then the column default (the key is
* omitted entirely so the database supplies it).
*
* Shared by the admin create route and POST /api/v1/events so the configured
* security level cannot depend on which entry point created the gallery —
* the same split that made #592 (devtools) a separate bug from #317.
*
* `body` values are already validated by the route's express-validator
* chain; `defaults` come from getImageSecurityDefaults(), which validates
* them itself.
*/
const resolveImageSecurityColumns = (body = {}, defaults = {}) => {
const { formatBoolean } = require('../../utils/dbCompat');
const columns = {};
// express-validator runs isInt/isIn/isBoolean element-wise on arrays, so a
// single-element array like `image_quality: [72]` passes the route's chain
// and arrives here still an array. The routes reject those with
// .not().isArray(); this guard means any future caller cannot write one
// into a scalar column (a PG insert error, or `[false]` coerced to true).
const scalar = (v) => (v !== null && typeof v === 'object' ? undefined : v);
const pick = (key) => {
const fromBody = scalar(body[key]);
return fromBody !== undefined ? fromBody : defaults[key];
};
const level = pick('protection_level');
if (level !== undefined) columns.protection_level = level;
const quality = pick('image_quality');
if (quality !== undefined) columns.image_quality = quality;
const canvas = pick('use_canvas_rendering');
if (canvas !== undefined) columns.use_canvas_rendering = formatBoolean(canvas);
return columns;
};
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
//
// Note: `branding_logo_position` (header bar — left/center/right) is a
// different concept from `hero_logo_position` (hero block — top/center/
// bottom) and must NOT be mapped here. A previous version copied the
// branding value over, which wrote 'left'/'right' into per-event
// hero_logo_position columns and broke any subsequent PUT validation
// (#357). Migration 084 heals existing rows.
const getBrandingDefaults = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'branding_logo_display_hero',
'branding_logo_size'
])
.select('setting_key', 'setting_value');
const defaults = {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
}
if (s.setting_key === 'branding_logo_display_hero') {
defaults.hero_logo_visible = value !== false;
}
if (s.setting_key === 'branding_logo_size' && value) {
defaults.hero_logo_size = value;
}
});
return defaults;
} catch (error) {
logger.error('Failed to get branding defaults', { error: error.message });
return {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
}
};
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
// Whether the global "phone field" toggle (#322) is enabled. Cached for
// the request via a module-level read; drift is acceptable since this
// only governs whether to persist the field, not security boundaries.
const isPhoneFieldEnabled = async () => {
try {
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
if (!row) return false;
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return value === true;
} catch (error) {
logger.debug('Failed to read event_phone_field_enabled', { error: error.message });
return false;
}
};
const RECOVERABLE_PASSWORD_COLUMNS = ['password_recoverable', 'client_password_recoverable'];
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
customer_phone,
// Bound only to exclude the secrets from `...rest` — never read.
password_hash: _ph, client_password_hash: _cph,
...rest
} = event;
// #1271 — the encrypted copies never leave the server except via
// /:id/password. Removed by name (not destructured) so a secret scanner
// does not read the binding as a hard-coded password.
for (const column of RECOVERABLE_PASSWORD_COLUMNS) delete rest[column];
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null,
customer_phone: customer_phone ?? null
};
};
let customerColumnCache = null;
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
logger.debug('Failed to detect customer_email column', { error: error.message });
return false;
}
};
// Cascade-delete a single event: photos, audit/access logs, queued emails,
// the event row itself (in one transaction), then the on-disk folder /
// archive zip / hero logo (best-effort — file failures don't unwind the DB
// changes since the source of truth is the database). Used by both the
// per-event DELETE /:id route and the bulk-delete route to avoid drift.
//
// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the
// bulk-delete loop can report it as a per-id failure without aborting the
// whole batch. Any other error propagates and is the caller's problem.
async function deleteEventCascade(eventId, adminContext) {
const event = await db('events').where('id', eventId).first();
if (!event) {
@@ -678,40 +295,4 @@ async function deleteEventCascade(eventId, adminContext) {
return { id: event.id, name: event.event_name };
}
// ---------------------------------------------------------------------------
// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live
// events that auto-picks-up new uploads (migration 138). Mirrors the
// client-access second-token pattern: the link is minted on demand, rotatable
// and disable-able, independent of the gallery password / share link.
// ---------------------------------------------------------------------------
// Allowed slide transition styles (kept in sync with the SlideshowPage).
// dipwhite/dipblack = fade through highlights / lowlights between images.
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
// Allowed per-slide color filters.
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
// Allowed slideshow play orders (#202). 'chronological' = upload order,
// 'random' = client-side shuffle.
const SLIDESHOW_ORDERS = ['chronological', 'random'];
module.exports = {
RECOVERABLE_PASSWORD_COLUMNS,
validateHeroImageAnchor,
getStoragePath,
getEventFieldRequirements,
readBooleanSetting,
decodeSettingValue,
getDownloadProtectionDefaults,
getImageSecurityDefaults,
resolveImageSecurityColumns,
getBrandingDefaults,
getCustomerNameFromPayload,
getCustomerEmailFromPayload,
getCustomerPhoneFromPayload,
isPhoneFieldEnabled,
mapEventForApi,
hasCustomerContactColumns,
deleteEventCascade,
SLIDESHOW_ORDERS,
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
};
module.exports = { ...settings, deleteEventCascade };
+31 -100
View File
@@ -1,6 +1,8 @@
const { isGalleryAvailable } = require('../utils/galleryLifecycle');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
@@ -420,7 +422,7 @@ router.post('/gallery/verify', [
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
if (!isGalleryAvailable(event)) {
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
await bcrypt.compare(password || '', DUMMY_BCRYPT_HASH);
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
@@ -496,6 +498,9 @@ router.post('/gallery/verify', [
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
@@ -545,7 +550,7 @@ router.post('/gallery/:slug/client-login', [
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event || !event.client_access_enabled || !event.client_password_hash) {
if (!isGalleryAvailable(event) || !event.client_access_enabled || !event.client_password_hash) {
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid credentials' });
}
@@ -570,6 +575,9 @@ router.post('/gallery/:slug/client-login', [
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
accessLevel: 'client',
ip: ipAddress,
loginTime: Date.now()
@@ -631,14 +639,14 @@ router.post('/gallery/share-login', [
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
if (!isGalleryAvailable(event)) {
const resolved = await resolveShareIdentifier(slug);
if (resolved?.event) {
event = resolved.event;
}
}
if (!event) {
if (!isGalleryAvailable(event)) {
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
return res.status(404).json({ error: 'Gallery not found' });
}
@@ -666,6 +674,9 @@ router.post('/gallery/share-login', [
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
@@ -743,106 +754,26 @@ router.get('/session', async (req, res) => {
issuer: 'picpeak-auth'
});
// Check if token has been revoked (e.g. after logout)
const { isTokenRevoked } = require('../utils/tokenRevocation');
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ valid: false, error: 'Session has been invalidated' });
}
// The redirect loop reported on the v3.32.4-beta.0 release came
// from /auth/session reporting valid: true while the protected
// adminAuth / galleryAuth middleware rejected the same token for
// reasons /auth/session never checked: the admin user was
// deactivated, the admin's password had been changed since iat,
// or the gallery event was archived/deleted. Mirror those checks
// here so the session endpoint is always at least as strict as
// what the protected endpoints will enforce next.
// Full user payload for admin sessions — the SSO callback establishes
// the session via redirect (no JSON response the SPA could store), so
// session restoration must be able to hydrate the user object (#798).
const sessions = require('../services/sessionAccessService');
let adminUser = null;
if (decoded.type === 'admin') {
let admin = null;
try {
admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
.select(
'admin_users.id', 'admin_users.username', 'admin_users.email',
'admin_users.password_changed_at', 'admin_users.must_change_password',
'roles.name as role_name', 'roles.display_name as role_display_name'
)
.first();
} catch (lookupErr) {
// admin_users table not present (test fixture, fresh DB) — fall
// through and trust the token. Real deployments always have it.
admin = null;
// intentional swallow; if the table is missing we do not want
// to fail-closed during e.g. early bootstrap.
}
if (admin === null) {
// Lookup didn't run because the table is missing; skip the
// existence/password checks and treat the token as valid.
} else if (!admin) {
return res.json({ valid: false, error: 'Admin account no longer active' });
} else if (admin.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(admin.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
return res.json({ valid: false, error: 'Token invalid due to password change' });
}
}
// Mirror the session-timeout check that sessionTimeoutMiddleware
// enforces on every /api/admin endpoint. Without this, /auth/session
// returns valid:true for an idle/old-iat token that protected
// endpoints reject with 401 SESSION_TIMEOUT — the same redirect-loop
// shape as the issuer-claim and password-change asymmetries (issue
// #350 recurrence on v3.39.1-beta.0).
try {
const { isSessionExpired } = require('../middleware/sessionTimeout');
if (await isSessionExpired(token, decoded)) {
return res.json({ valid: false, error: 'Session expired' });
}
} catch (timeoutErr) {
// Helper lookup failed (test stub may not export it) — fall through
// and trust the token. Real deployments always have the middleware.
}
if (admin) {
adminUser = {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
};
const admin = await sessions.admin(decoded, { includeProfile: true });
const { isSessionExpired } = require('../middleware/sessionTimeout');
if (await isSessionExpired(token, decoded)) {
return res.json({ valid: false, error: 'Session expired' });
}
adminUser = {
id: admin.id, username: admin.username, email: admin.email,
mustChangePassword: !!admin.must_change_password,
role: admin.role_name ? { name: admin.role_name, displayName: admin.role_display_name } : null,
};
} else if (decoded.type === 'gallery') {
try {
const event = await db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
})
.first();
if (!event) {
return res.json({ valid: false, error: 'Gallery no longer available' });
}
if (event.expires_at && new Date(event.expires_at) < new Date()) {
return res.json({ valid: false, error: 'Gallery has expired' });
}
} catch (galleryLookupErr) {
// events table missing in this context — same fallback as
// admin path; trust the token rather than fail-closed.
}
const access = require('../services/galleryAccessService');
const event = await db('events').where({ id: decoded.eventId }).first();
if (!event) return res.json({ valid: false, error: 'Gallery no longer available' });
await access.authorize(event, access.grant(event, 'gallery', decoded));
} else {
return res.status(403).json({ valid: false, error: 'Invalid token type' });
}
// Calculate remaining time
+9 -3
View File
@@ -1,3 +1,4 @@
const { isGalleryAvailable, isGalleryExpired } = require('../utils/galleryLifecycle');
/**
* Customer dashboard routes
*
@@ -14,6 +15,7 @@
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { body, param, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { getBcryptRounds, MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
@@ -165,10 +167,12 @@ router.get('/events/:slug/access-token', [
if (event.is_archived) {
return res.status(410).json({ error: 'This gallery has been archived' });
}
if (event.expires_at && new Date(event.expires_at) < new Date()) {
if (isGalleryExpired(event)) {
return res.status(410).json({ error: 'This gallery has expired' });
}
if (!isGalleryAvailable(event)) return res.status(404).json({ error: 'Event not found' });
const hasAccess = await customerAccountsService.customerHasAccessToEvent(
req.customer.id,
event.id
@@ -189,10 +193,12 @@ router.get('/events/:slug/access-token', [
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
ip: ipAddress,
loginTime: Date.now(),
// Optional bookkeeping claim — surfaces the originating customer in
// logs when the token is later used. Doesn't affect authorization.
// Rechecked on each gallery/media request, including account status.
via: 'customer',
customerId: req.customer.id,
}, process.env.JWT_SECRET, {
File diff suppressed because it is too large Load Diff
+945
View File
@@ -0,0 +1,945 @@
const express = require('express');
const { db, logActivity } = require('../../database/db');
const { parseBooleanInput } = require('../../utils/parsers');
const archiver = require('archiver');
const path = require('path');
const { resolvePhotoContentType } = require('../../utils/photoContentType');
const router = express.Router();
const watermarkService = require('../../services/watermarkService');
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
const { noStoreCache } = require('../../middleware/noStoreCache');
const logger = require('../../utils/logger');
const { pipeStreamToResponse } = require('../../utils/streamResponse');
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../../services/photoResolver');
const { errorResponse } = require('../../utils/routeHelpers');
const { blockHiddenGallery } = require('../../utils/revealMode');
const downloadZipService = require('../../services/downloadZipService');
const { renderPhotoForDownload, resolveWatermarkSettings } = require('../../services/downloadRendition');
const downloadJobService = require('../../services/downloadJobService');
const {
resolveEventDownloadPolicy,
pickRequestedResolution,
parseResolution,
} = require('../../utils/downloadResolutions');
const { applyPhotoVisibilityFilter, canSeeHiddenPhotos } = require('../../utils/photoVisibility');
const {
getUseOriginalFilenames,
pickRawDownloadName,
getZipEntryNames,
} = require('../../services/downloadFilenameService');
const { buildContentDisposition } = require('../../utils/filenameSanitizer');
const { getStorage } = require('../../services/storage');
const fs = require('fs');
function parseByteRange(header, size) {
if (!header || typeof header !== 'string' || !size) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return null;
const [, rawStart, rawEnd] = match;
if (rawStart === '' && rawEnd === '') return null;
let start;
let end;
if (rawStart === '') {
// Suffix form: the last N bytes.
const suffix = parseInt(rawEnd, 10);
if (!suffix) return null;
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = parseInt(rawStart, 10);
end = rawEnd === '' ? size - 1 : parseInt(rawEnd, 10);
}
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
if (start > end || start >= size) return null;
return { start, end: Math.min(end, size - 1) };
}
function galleryActor(req) {
// Portal tokens run as accessLevel 'guest' but carry via:'customer'
// (req.viaCustomer); PIN-client logins carry accessLevel 'client'.
// Both are customers, not guests (codex review of #849, final round).
const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client'));
return { type: isCustomer ? 'customer' : 'guest' };
}
const SINGLE_DOWNLOAD_DEBOUNCE_MS = 60 * 60 * 1000;
const singleDownloadNotifiedAt = new Map();
function notifySinglePhotoDownload(event, req) {
const now = Date.now();
const last = singleDownloadNotifiedAt.get(event.id) || 0;
if (now - last < SINGLE_DOWNLOAD_DEBOUNCE_MS) return;
singleDownloadNotifiedAt.set(event.id, now);
logActivity('gallery_downloaded', { scope: 'single' }, event.id, galleryActor(req));
}
router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
const { photoId } = req.params;
// Check if downloads are allowed for this event
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Per-category download permission (#640). Photos without a category are
// always downloadable when the event allows downloads — only categorised
// photos can opt out per-category.
if (photo.category_id) {
const cat = await db('photo_categories')
.where('id', photo.category_id)
.first('allow_downloads');
if (cat && !parseBooleanInput(cat.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this category' });
}
}
// Download resolution (#858). Resolved BEFORE the counters below: a
// rejected resolution must not inflate download stats, which a guest
// could otherwise do by replaying ?resolution=bogus.
const isVideo = photo.media_type === 'video'
|| (photo.mime_type && photo.mime_type.startsWith('video/'));
const policy = await resolveEventDownloadPolicy(req.event);
const requested = pickRequestedResolution(policy, req.query.resolution);
if (requested === null) {
return res.status(400).json({ error: 'Resolution not available for this gallery' });
}
const box = isVideo ? null : parseResolution(requested);
// A HEAD is a metadata probe, not a download. Answering it below the
// counters recorded every probe as a real download, and answering it below
// renderPhotoForDownload fetched and watermarked an image whose body Node
// then discards. Both happen before this point in a GET, so HEAD leaves
// here — with no side effects and no bytes read.
if (req.method === 'HEAD') {
const headUseOriginal = await getUseOriginalFilenames();
const headHeaders = {
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': buildContentDisposition(pickRawDownloadName(photo, headUseOriginal)),
'Accept-Ranges': 'bytes',
};
// Content-Length only when the bytes ship untransformed AND the size can
// be read without fetching them. A watermark or resize changes the
// length, and the only way to learn the new one is to do the work this
// branch exists to avoid — HEAD is allowed to omit it.
const headWatermark = await resolveWatermarkSettings(req.event);
if (!box && !headWatermark) {
try {
const headKey = resolvePhotoStorageKey(req.event, photo);
const headStorage = getStorage();
if (headKey && headStorage.kind() !== 'local') {
const headStat = await headStorage.stat(headKey);
if (!headStat) return res.status(404).json({ error: 'Photo file not found' });
headHeaders['Content-Length'] = headStat.size;
if (headStat.mtime) headHeaders['Last-Modified'] = new Date(headStat.mtime).toUTCString();
}
} catch (headErr) {
// No length is a valid HEAD; not worth failing the probe over.
logger.debug('HEAD probe could not stat the object', { photoId, error: headErr.message });
}
}
res.set(headHeaders);
return res.end();
}
// Admin preview (#868) downloads are excluded from the download count +
// guest analytics — kept out of client-facing stats.
if (!req.isAdminPreview) {
// Update download count
await db('photos').where('id', photoId).increment('download_count', 1);
// Log download
await db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download',
photo_id: photoId
});
}
// Surface in the admin notification bell (#746) — debounced, and only
// once the response actually finished: notifying up-front would log a
// download that then 404s/fails and the debounce would suppress the
// next real one for an hour (codex review of #849).
res.on('finish', () => {
if (res.statusCode < 400 && !req.isAdminPreview) notifySinglePhotoDownload(req.event, req);
});
// #493: if the admin enabled "use original filenames", surface the
// pre-rename camera filename in Content-Disposition. Storage path is
// unchanged — only the user-visible download name is swapped.
const useOriginal = await getUseOriginalFilenames();
const downloadName = pickRawDownloadName(photo, useOriginal);
const contentDisposition = buildContentDisposition(downloadName);
// The gallery's standard applies to EVERY ordinary download, single photos
// included — otherwise a lowered standard is trivially bypassed by
// downloading photos one at a time. `box` was resolved above, before the
// counters. Videos have no resize path and always ship as-is.
//
// renderPhotoForDownload (#858) owns the resize-then-watermark ordering
// and the storage fetch, and is what the zip builders below already use.
// It returns null when the photo needs no transformation at all, which is
// the default gallery's common case and lets us ship the stored bytes
// without buffering a full-size original into memory.
const effectiveSettings = await resolveWatermarkSettings(req.event);
let rendered;
try {
rendered = await renderPhotoForDownload(req.event, photo, box, effectiveSettings);
} catch (renderError) {
// Classify, the same way the pass-through branch below does. This can
// reject because the source object is gone, but equally because
// getToFile timed out, the tmp filesystem filled up, or sharp failed —
// and reporting an operational failure as 404 tells the guest their
// photo does not exist and tells us nothing.
const gone = renderError.code === 'ENOENT'
|| renderError.name === 'NoSuchKey'
|| renderError.name === 'NotFound'
|| renderError.$metadata?.httpStatusCode === 404;
logger.error('Failed to render photo for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: renderError.message,
});
return gone
? res.status(404).json({ error: 'Photo file not found' })
: res.status(500).json({ error: 'Failed to download photo' });
}
if (rendered) {
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': contentDisposition,
'Content-Length': rendered.length
});
return res.send(rendered);
}
// Untransformed: ship the stored bytes.
//
// Managed photos live behind the storage abstraction and on an S3/R2
// deployment are not on local disk at all — resolving a filesystem path
// unconditionally here is what made every single-photo download 404 with
// ENOENT in S3 mode (#1048), while download-all and secure-images worked
// because they already went through getStorage().
//
// resolvePhotoStorageKey returns null for external/reference photos: those
// live on a local mount and keep the sendFile path.
let storageKey = null;
try {
storageKey = resolvePhotoStorageKey(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo storage key for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
const storage = getStorage();
if (storageKey && storage.kind() !== 'local') {
// Deliberately NOT the local path: res.sendFile emits Content-Length,
// Accept-Ranges, ETag and Last-Modified and answers Range requests with
// a 206, and a bare stream.pipe(res) has none of that. On local disk
// sendFile stays the better implementation, so it stays the branch.
//
// On S3 we reproduce the parts that matter for a download: the length
// (browsers need it for the progress indicator, which matters most on
// exactly the large files this route serves) and Range, so an
// interrupted download resumes instead of appending a second full body
// onto the partial file. Conditional requests are not reproduced —
// there is no ETag here, so a client revalidating gets the whole body,
// same as it does today.
const stat = await storage.stat(storageKey);
if (!stat) {
logger.error('Photo not found in storage backend for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
storageKey,
});
return res.status(404).json({ error: 'Photo file not found' });
}
const lastModified = stat.mtime ? new Date(stat.mtime).toUTCString() : null;
const headers = {
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': contentDisposition,
'Accept-Ranges': 'bytes',
};
if (lastModified) headers['Last-Modified'] = lastModified;
// If-Range: a client resuming an interrupted download sends back the
// validator it was given last time. If the object has been replaced
// since — the watcher re-importing a swapped file, an admin re-upload —
// answering 206 from the NEW bytes lets the client splice two different
// versions into one corrupt file. A validator that doesn't match means
// a full 200, which is the whole point of the header.
const ifRange = req.headers['if-range'];
const staleValidator = !!ifRange && (!lastModified || ifRange.trim() !== lastModified);
const range = staleValidator ? null : parseByteRange(req.headers.range, stat.size);
// Open the stream BEFORE any header is staged or sent. stat() succeeding
// does not mean get() will: a concurrent delete or replace, or a
// transient backend error, lands here. Once writeHead(206) has gone out
// the outer catch can do nothing but throw ERR_HTTP_HEADERS_SENT, and in
// the non-range case it would send its 500 JSON underneath the staged
// image/jpeg attachment headers — a .jpg file full of JSON.
let stream;
try {
stream = range
? await storage.getRange(storageKey, range.start, range.end)
: await storage.get(storageKey);
} catch (fetchError) {
const gone = fetchError.code === 'ENOENT'
|| fetchError.name === 'NoSuchKey'
|| fetchError.name === 'NotFound'
|| fetchError.$metadata?.httpStatusCode === 404;
logger.error('Failed to open photo stream for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
storageKey,
error: fetchError.message,
});
return gone
? res.status(404).json({ error: 'Photo file not found' })
: res.status(500).json({ error: 'Failed to download photo' });
}
if (range) {
// status()+set() rather than writeHead(): writeHead commits the
// response immediately, so a stream that resolves and THEN errors
// before its first chunk would leave pipeStreamToResponse able only to
// destroy the connection. Staged headers are flushed by the first body
// write, which means an error at byte zero can still clear them and
// return a clean, retryable status instead of a transport reset.
res.status(206).set({
...headers,
'Content-Range': `bytes ${range.start}-${range.end}/${stat.size}`,
'Content-Length': (range.end - range.start) + 1,
});
} else {
res.set({ ...headers, 'Content-Length': stat.size });
}
pipeStreamToResponse(stream, res, {
context: range ? `download range for photo ${photo.id}` : `download for photo ${photo.id}`,
});
return;
}
let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// res.download() builds Content-Disposition itself but doesn't emit the
// RFC 5987 filename* parameter, so unicode camera filenames would lose
// their bytes on download. Set the header explicitly and stream the
// file with res.sendFile-equivalent semantics.
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': contentDisposition,
});
res.sendFile(filePath, (downloadError) => {
if (downloadError) {
logger.error('Error streaming gallery download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: downloadError.message,
});
}
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to download photo');
}
});
// Download all photos as ZIP
// Zip downloads count toward each contained photo's download_count (#895)
// — previously only single-photo downloads did, so galleries whose guests
// grab the zip showed 0 per-photo downloads forever. Used by the
// pre-generated-zip branches only: it mirrors downloadZipService._build,
// which zips EVERY event photo with no per-category allow_downloads
// filter — the counter has to reflect what actually shipped. (That the
// prebuilt zip ignores per-category download opt-outs is a separate,
// pre-existing issue.) Known approximation: _build skips entries whose
// WATERMARK step fails and still publishes the zip; counting those
// would need a persisted archive manifest, which isn't worth it for
// that tail case. Fire-and-forget at the call sites: counters must
// never fail a download.
async function bumpEventDownloadCounts(eventId) {
await db('photos').where('event_id', eventId).increment('download_count', 1);
}
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
// Try to serve pre-generated zip (instant download with Content-Length).
// Guests may use the prebuilt cache ONLY when the event has no hidden
// photos: a cache built before a photo was hidden — or before this
// visibility-aware builder shipped — could otherwise still leak it, and
// getZipInfo only checks the DB pointer + file stat, not freshness. When
// hidden photos exist, guests fall through to the visibility-filtered
// stream below. PIN-clients always stream a full archive.
const isClient = canSeeHiddenPhotos(req.accessLevel);
const eventHasHidden = await db('photos')
.where({ event_id: req.event.id, visibility: 'hidden' })
.first()
.then(Boolean);
const zipInfo = (isClient || eventHasHidden)
? null
: await downloadZipService.getZipInfo(req.event.id);
if (zipInfo) {
const storage = getStorage();
// Stream via the authenticated route so logout, restore and account
// changes are checked on every download, including S3-backed archives.
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Length', zipInfo.size);
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
const stream = await storage.get(zipInfo.key);
pipeStreamToResponse(stream, res, { context: `prepared zip for event ${req.event.id}`, missingStatus: 410 });
// Log bulk download (admin preview #868 excluded — stats stay client-only).
if (!req.isAdminPreview) {
db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download_all'
}).catch(() => {});
bumpEventDownloadCounts(req.event.id).catch(() => {});
// Surface in the admin notification bell (#746) — only once the
// stream actually finished; logging at pipe-time would report
// downloads that then broke mid-transfer (codex review of #849).
res.on('finish', () => {
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
});
}
return;
}
// Fallback: on-the-fly streaming (existing behavior). Only pre-build the
// guest cache when it will actually be served next time — a guest
// download of an event with no hidden photos. Client bypasses and
// hidden-photo events always stream, so rebuilding the guest archive on
// those requests is wasted I/O (codex review).
if (!isClient && !eventHasHidden) {
downloadZipService.generateZip(req.event.id).catch(err =>
logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message })
);
}
// Fetch photos — exclude photos in categories that disabled downloads (#640).
// Uncategorised photos are always included; categories without the column
// (pre-migration-135) fall through the LEFT JOIN's null and are included.
const photos = await applyPhotoVisibilityFilter(
db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.where(function () {
this.whereNull('photos.category_id')
.orWhere('photo_categories.allow_downloads', true)
.orWhereNull('photo_categories.allow_downloads');
}),
req.accessLevel
)
.select('photos.*')
.orderBy('photos.type', 'asc')
.orderBy('photos.uploaded_at', 'desc');
if (photos.length === 0) {
return res.status(404).json({ error: 'No photos found' });
}
// Count unique types
const uniqueTypes = new Set(photos.map(p => p.type)).size;
const hasMultipleTypes = uniqueTypes > 1;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', (err) => {
throw err;
});
archive.pipe(res);
// Get watermark settings - apply if global setting OR event-level setting is enabled
const watermarkSettings = await watermarkService.getWatermarkSettings();
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
const effectiveSettings = shouldApplyWatermark ? {
...watermarkSettings,
enabled: true,
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
} : null;
// The gallery's standard resolution applies to the streamed archive too,
// not only the cached one (#858).
const { standardBox: bulkBox } = await resolveEventDownloadPolicy(req.event);
// Add photos to archive — managed photos via storage backend, external via local path.
const { resolvePhotoStorageKey } = require('../../services/photoResolver');
const storage = getStorage();
// #493: resolve a unique display filename per photo up-front so collisions
// get a deterministic `_1` suffix before the entries hit the archive.
const useOriginalBulk = await getUseOriginalFilenames();
const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk);
// Only photos whose append succeeded count as downloaded (#895) — the
// catch below deliberately skips missing/corrupt sources, and those
// never make it into the archive.
const appendedIds = [];
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
const storageKey = resolvePhotoStorageKey(req.event, photo);
const entryName = bulkEntryNames[i];
let archiveName;
if (hasMultipleTypes) {
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
archiveName = path.join(folderName, entryName);
} else {
archiveName = entryName;
}
try {
// Verify the source exists BEFORE appending — but only for local
// sources: fs.createReadStream is lazy, so its error fires outside
// this try/catch and the archive 'error' handler then kills the
// whole response instead of skipping one photo (#895 review). S3's
// get() awaits GetObject and rejects right here on a missing key,
// so a preflight HEAD per entry would just be a redundant serial
// round trip (500-photo zip = 500 extra HEADs).
if (storageKey && storage.kind() === 'local') {
const srcStat = await storage.stat(storageKey);
if (!srcStat) {
throw new Error(`Photo missing in storage: ${storageKey}`);
}
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
throw new Error('Photo file missing on disk');
}
// Resize to the gallery's standard resolution (#858) and/or watermark.
// This branch runs whenever the cached zip isn't usable — the first
// download after an invalidation, PIN clients, and galleries with
// hidden photos all land here, so skipping the cap would leak
// full-resolution files for exactly those cases.
const rendered = await renderPhotoForDownload(req.event, photo, bulkBox, effectiveSettings);
if (rendered) {
archive.append(rendered, { name: archiveName });
} else if (storageKey) {
const stream = await storage.get(storageKey);
archive.append(stream, { name: archiveName });
} else {
archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName });
}
appendedIds.push(photo.id);
} catch (err) {
logger.warn('Skipping photo in bulk download due to error', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: err.message,
});
}
}
// Notification only after the response actually finished — finalize()
// ends Archiver's input, not the HTTP transfer (codex review of #849,
// confirmation round). Registered before finalize so it can't be missed.
// Admin preview (#868) streams the archive but is excluded from stats.
if (!req.isAdminPreview) {
res.on('finish', () => {
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
});
}
await archive.finalize();
if (!req.isAdminPreview) {
// Log bulk download
await db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download_all'
});
// Exactly the photos that made it into this archive (#895) — skipped
// (missing/corrupt) sources don't count.
if (appendedIds.length > 0) {
db('photos').whereIn('id', appendedIds)
.increment('download_count', 1).catch(() => {});
}
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to create download archive');
}
});
// Download selected photos as ZIP
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
const ids = Array.isArray(req.body?.photo_ids) ? req.body.photo_ids : [];
if (!ids.length) {
return res.status(400).json({ error: 'photo_ids is required (non-empty array)' });
}
// Clean IDs
const photoIds = ids
.map((v) => parseInt(v, 10))
.filter((v) => Number.isInteger(v))
.slice(0, 500);
if (photoIds.length === 0) {
return res.status(400).json({ error: 'No valid photo IDs provided' });
}
// Fetch photos — exclude photos in categories that disabled downloads (#640).
// Same LEFT JOIN pattern as the download-all endpoint.
const photos = await applyPhotoVisibilityFilter(
db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.whereIn('photos.id', photoIds)
.where(function () {
this.whereNull('photos.category_id')
.orWhere('photo_categories.allow_downloads', true)
.orWhereNull('photo_categories.allow_downloads');
}),
req.accessLevel
)
.select('photos.*')
.orderBy('photos.uploaded_at', 'desc');
if (photos.length === 0) {
return res.status(404).json({ error: 'No photos found for selected IDs' });
}
// Download resolution (#858). Resolve BEFORE any header goes out — once
// the archive starts streaming we can no longer return a JSON error.
const selectedPolicy = await resolveEventDownloadPolicy(req.event);
const selectedResolution = pickRequestedResolution(selectedPolicy, req.body?.resolution);
if (selectedResolution === null) {
return res.status(400).json({ error: 'Resolution not available for this gallery' });
}
const selectedBox = parseResolution(selectedResolution);
const archiveName = `${req.event.slug}-selected.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', (err) => {
logger.error('Zip error generating selected download', {
slug: req.params.slug,
eventId: req.event?.id,
error: err.message,
});
try {
res.status(500).end();
} catch (_) {
// ignore double-send errors
}
});
archive.pipe(res);
// Check watermark settings - apply if global setting OR event-level setting is enabled
const watermarkSettings = await watermarkService.getWatermarkSettings();
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
const effectiveSettings = shouldApplyWatermark ? {
...watermarkSettings,
enabled: true,
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
} : null;
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../../services/photoResolver');
const selectedStorage = getStorage();
// #493: same display-name resolution as bulk download, with dedup.
const useOriginalSelected = await getUseOriginalFilenames();
const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected);
// Only photos whose append succeeded count as downloaded (#895).
const appendedIds = [];
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`;
const storageKey = resolveSelectedKey(req.event, photo);
try {
// Same pre-append source check as download-all (#895 review),
// local backend only: a lazy fs stream's async error would kill
// the response instead of skipping the photo; S3's get() rejects
// at the await below, so no redundant per-entry HEAD there.
if (storageKey && selectedStorage.kind() === 'local') {
const srcStat = await selectedStorage.stat(storageKey);
if (!srcStat) {
throw new Error(`Photo missing in storage: ${storageKey}`);
}
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
throw new Error('Photo file missing on disk');
}
// Resize (#858) and/or watermark. renderPhotoForDownload returns null
// when neither applies, so the untransformed case still streams from
// storage rather than buffering the whole photo.
const rendered = await renderPhotoForDownload(req.event, photo, selectedBox, effectiveSettings);
if (rendered) {
archive.append(rendered, { name });
} else if (storageKey) {
const stream = await selectedStorage.get(storageKey);
archive.append(stream, { name });
} else {
archive.file(resolvePhotoFilePath(req.event, photo), { name });
}
appendedIds.push(photo.id);
} catch (err) {
logger.warn('Skipping selected photo due to error', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: err.message,
});
}
}
// See download-all: notify only on response 'finish'.
// Admin preview (#868) streams the archive but is excluded from stats.
if (!req.isAdminPreview) {
res.on('finish', () => {
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'selected', photo_count: photoIds.length }, req.event.id, galleryActor(req));
});
}
await archive.finalize();
if (!req.isAdminPreview) {
await db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download_selected'
});
// Exactly the photos that made it into this archive (#895) — skipped
// (missing/corrupt) sources don't count.
if (appendedIds.length > 0) {
db('photos').whereIn('id', appendedIds)
.increment('download_count', 1).catch(() => {});
}
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to download selected photos');
}
});
// ──────────────────────────────────────────────────────────────────────────
// Custom-resolution download jobs (#858).
//
// The plain download-all is served from the pre-built cache at the gallery's
// STANDARD resolution. Picking a different size has nothing to cache against,
// and resizing a whole gallery inside one request would sit far past any
// reverse-proxy timeout — so those archives are built as a job the client
// polls. Same access rules as the download routes above.
// ──────────────────────────────────────────────────────────────────────────
// Kick off (or join) a build. Returns the polling token.
router.post('/:slug/download-jobs', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
const policy = await resolveEventDownloadPolicy(req.event);
if (!policy.pickerEnabled) {
return res.status(403).json({ error: 'Resolution choice is not enabled for this gallery' });
}
const resolution = pickRequestedResolution(policy, req.body?.resolution);
if (resolution === null) {
return res.status(400).json({ error: 'Resolution not available for this gallery' });
}
// Optional subset. Absent = the whole visible gallery.
let photoIds = null;
if (Array.isArray(req.body?.photo_ids) && req.body.photo_ids.length) {
photoIds = req.body.photo_ids
.map((v) => parseInt(v, 10))
.filter((v) => Number.isInteger(v))
.slice(0, 500);
if (photoIds.length === 0) {
return res.status(400).json({ error: 'No valid photo IDs provided' });
}
}
let job;
try {
job = await downloadJobService.createJob({
event: req.event,
resolution,
photoIds,
accessLevel: req.accessLevel,
});
} catch (err) {
if (err.code === 'NO_PHOTOS') {
return res.status(404).json({ error: 'No photos available for this selection' });
}
if (err.code === 'BUSY') {
return res.status(429).json({ error: 'Too many downloads are being prepared right now — please try again shortly' });
}
throw err;
}
res.status(202).json({
token: job.token,
status: job.status,
resolution: job.resolution,
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to start download preparation');
}
});
// Poll. The token is unguessable, but it is never sufficient on its own —
// verifyGalleryAccess still runs and the job must belong to THIS event.
// no-store: a cached 'preparing' would strand the caller in a poll that can
// never observe the job finishing.
router.get('/:slug/download-jobs/:token', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, noStoreCache, async (req, res) => {
try {
const job = await downloadJobService.getStatus(req.params.token);
if (!job || job.event_id !== req.event.id) {
return res.status(404).json({ error: 'Download job not found' });
}
res.json({
status: job.status,
resolution: job.resolution,
photo_count: job.photo_count || 0,
size_bytes: job.size_bytes || null,
error: job.status === 'failed' ? (job.error || 'Preparation failed') : undefined,
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to read download job');
}
});
// Deliver the finished archive.
router.get('/:slug/download-jobs/:token/file', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
// Downloads can be switched off after a job was created — every other
// download route re-checks this per request, so this one must too.
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
const job = await downloadJobService.getStatus(req.params.token);
if (!job || job.event_id !== req.event.id) {
return res.status(404).json({ error: 'Download job not found' });
}
// The token alone never grants access: the archive was built under one
// visibility scope, and only a requester still in that scope may take it.
// Without this, a leaked client token would hand hidden photos to a guest.
if (job.visibility_scope !== downloadJobService.visibilityScopeFor(req.accessLevel)) {
return res.status(404).json({ error: 'Download job not found' });
}
if (job.status !== 'ready' || !job.zip_path) {
return res.status(409).json({ error: 'Download is not ready yet', status: job.status });
}
if (new Date(job.expires_at).getTime() <= Date.now()) {
return res.status(410).json({ error: 'This download has expired — please request it again' });
}
// A photo hidden AFTER this archive was built is still inside it, and the
// scope check above can't see that — both sides remain 'public'. Re-run
// the visibility query over the packaged set before handing it over.
if (!(await downloadJobService.isStillDeliverable(job, req.event, req.accessLevel))) {
return res.status(409).json({
error: 'This gallery changed since the download was prepared — please request it again',
status: 'stale',
});
}
const storage = getStorage();
const stat = await storage.stat(job.zip_path);
if (!stat) {
return res.status(410).json({ error: 'This download is no longer available' });
}
// Stats parity with the other bulk paths (#895): only count once the
// response actually completed, and keep admin previews out of guest stats.
res.on('finish', () => {
if (res.statusCode >= 400 || req.isAdminPreview) return;
// The DELIVERED set, not the requested one: a photo whose source was
// missing at build time isn't in the zip and must not be counted.
let ids = [];
try {
ids = JSON.parse(job.delivered_photo_ids || job.photo_ids || '[]');
} catch (_) { /* malformed row — skip counting rather than fail */ }
if (ids.length > 0) {
db('photos').whereIn('id', ids).increment('download_count', 1).catch(() => {});
}
db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download',
photo_id: null,
}).catch(() => {});
logActivity('gallery_downloaded', { scope: 'all', resolution: job.resolution },
req.event.id, galleryActor(req));
});
const suffix = job.resolution === 'original' ? 'original' : job.resolution;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Length', stat.size);
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}-${suffix}.zip"`);
const stream = await storage.get(job.zip_path);
pipeStreamToResponse(stream, res, { context: `download job ${job.id}`, missingStatus: 410 });
} catch (error) {
errorResponse(res, error, 500, 'Failed to serve prepared download');
}
});
// Explicit per-photo view beacon (#895). Counting views on the image-
// serving routes is wrong in both directions: the lightbox preloads the
// prev/next neighbours (three fetches per open), while a preloaded
// neighbour that becomes the current slide is never re-fetched (#505
// keeps the DOM node alive across the swipe) — so request-level counters
// overcount preloads AND undercount swipe-throughs. Instead the lightbox
// pings this endpoint exactly when a photo becomes the visible slide.
// This also covers enhanced/maximum-protection galleries, whose bytes
// are served by /api/secure-images and never pass the routes below.
// The slideshow kiosk is excluded (denySlideshowToken; migration 138).
module.exports = router;
+639
View File
@@ -0,0 +1,639 @@
const express = require('express');
const { db } = require('../../database/db');
const path = require('path');
const { resolvePhotoContentType } = require('../../utils/photoContentType');
const router = express.Router();
const watermarkService = require('../../services/watermarkService');
const watermarkGeneratorService = require('../../services/watermarkGeneratorService');
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
const withPreview = (req, url) => (req.isAdminPreview ? `${url}${url.includes('?') ? '&' : '?'}admin_preview=1` : url);
const secureImageService = require('../../services/secureImageService');
const logger = require('../../utils/logger');
const { pipeStreamToResponse } = require('../../utils/streamResponse');
const { errorResponse } = require('../../utils/routeHelpers');
const { blockHiddenGallery } = require('../../utils/revealMode');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../../services/imageProcessor');
const { getStorage } = require('../../services/storage');
const fs = require('fs');
const { getStoragePath } = require('../../config/storage');
router.post('/:slug/photo/:photoId/view',
verifyGalleryAccess,
denySlideshowToken,
blockHiddenGallery,
async (req, res) => {
try {
const photo = await db('photos')
.where({ id: req.params.photoId, event_id: req.event.id })
.first('id', 'visibility');
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Admin preview (#981 review) is excluded from per-photo view analytics.
if (!req.isAdminPreview) {
await db('photos').where('id', photo.id).increment('view_count', 1);
}
res.status(204).end();
} catch (error) {
errorResponse(res, error, 500, 'Failed to record view');
}
});
// View single photo (with watermark if enabled)
router.get('/:slug/photo/:photoId',
verifyGalleryAccess,
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Check if this is a video
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
// Check protection level - basic and standard protection allow direct JWT access
const protectionLevel = req.event.protection_level || 'standard';
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
// For enhanced/maximum protection, redirect to secure endpoint
return res.status(302).json({
error: 'Secure access required',
secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`,
photoId: photoId
});
}
// Resolve where to read the photo bytes from. For external/reference
// photos the source is always a local mount path. For managed photos
// we go through the storage abstraction so S3 deployments work too
// (#432 — previously this route did fs.* directly and 500'd in S3
// mode because the file wasn't on the container's local fs).
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../../services/photoResolver');
const storage = getStorage();
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
const useStorageBackend = !isExternal;
let filePath = null; // Local fs path (external photos OR LocalFs storage)
let storageKey = null; // Relative storage key (managed photos via storage abstraction)
let stat;
let fileSize;
if (useStorageBackend) {
try {
storageKey = resolvePhotoStorageKey(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo storage key', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
photoPath: photo.path,
photoFilename: photo.filename
});
return res.status(404).json({ error: 'Photo file not found' });
}
stat = await storage.stat(storageKey);
if (!stat) {
logger.error('Photo not found in storage backend', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
storageKey
});
return res.status(404).json({ error: 'Photo file not found' });
}
fileSize = stat.size;
} else {
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
photoPath: photo.path,
photoFilename: photo.filename
});
return res.status(404).json({ error: 'Photo file not found' });
}
if (!fs.existsSync(filePath)) {
logger.error('Photo file does not exist at resolved path', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
resolvedPath: filePath,
photoPath: photo.path
});
return res.status(404).json({ error: 'Photo file not found' });
}
stat = fs.statSync(filePath);
fileSize = stat.size;
}
// Handle video streaming with range requests
if (isVideo) {
const range = req.headers.range;
if (range) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
// Validate before writing the 206: a NaN, inverted or out-of-file
// range used to be committed to the headers and then throw while
// streaming (or read past the end).
if (!Number.isInteger(start) || !Number.isInteger(end)
|| start < 0 || end < start || start >= fileSize) {
res.set('Content-Range', `bytes */${fileSize}`);
return res.status(416).end();
}
const boundedEnd = Math.min(end, fileSize - 1);
const chunksize = (boundedEnd - start) + 1;
res.writeHead(206, {
'Content-Range': `bytes ${start}-${boundedEnd}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': resolvePhotoContentType(photo),
'Cache-Control': 'private, max-age=1800',
'X-Protection-Level': 'basic'
});
const file = useStorageBackend
? await storage.getRange(storageKey, start, boundedEnd)
: fs.createReadStream(filePath, { start, end: boundedEnd });
pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` });
} else {
res.writeHead(200, {
'Content-Length': fileSize,
'Content-Type': resolvePhotoContentType(photo),
'Accept-Ranges': 'bytes',
'Cache-Control': 'private, max-age=1800',
'X-Protection-Level': 'basic'
});
const file = useStorageBackend
? await storage.get(storageKey)
: fs.createReadStream(filePath);
pipeStreamToResponse(file, res, { context: `video for photo ${photo.id}` });
}
return;
}
// Image path
const watermarkSettings = await watermarkService.getWatermarkSettings();
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
// orientation_checked_at participates because the backfill (#1198) can
// change these bytes without touching either of the other two inputs:
// it rewrites the derived renditions while the ORIGINAL's mtime and the
// watermark settings both stay exactly as they were. Without it a guest
// holding a pre-fix ETag keeps getting 304 and keeps their cached
// sideways image, however many times the backfill succeeds.
const orientationVersion = photo.orientation_checked_at
? `-o${new Date(photo.orientation_checked_at).getTime()}`
: '';
const etag = `"${photoId}-${mtimeMs}${watermarkHash}${orientationVersion}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
if (watermarkSettings && watermarkSettings.enabled) {
// Pre-generated watermarked file: served via the storage backend
// (managed) or directly from local fs (external).
if (photo.watermark_path) {
try {
if (useStorageBackend) {
const wmStat = await storage.stat(photo.watermark_path);
if (wmStat) {
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Length': wmStat.size,
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
const wmStream = await storage.get(photo.watermark_path);
return pipeStreamToResponse(wmStream, res, { context: `watermarked photo ${photo.id}` });
}
} else {
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
if (fs.existsSync(watermarkFilePath)) {
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
return res.sendFile(watermarkFilePath);
}
}
} catch (err) {
logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
}
}
// Fallback: apply watermark on-the-fly. applyWatermark needs a
// local file path (sharp + fs.readFile) — for managed photos in
// S3 mode, withLocalCopy materializes to a tmp file and cleans up.
const watermarkedBuffer = useStorageBackend
? await withLocalCopy(storageKey, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings))
: await watermarkService.applyWatermark(filePath, watermarkSettings);
// Queue watermark generation in background for next request
watermarkGeneratorService.generateForPhoto(photo.id)
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
res.send(watermarkedBuffer);
} else {
res.set({
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
if (useStorageBackend) {
res.set('Content-Length', stat.size);
res.set('Content-Type', resolvePhotoContentType(photo));
const stream = await storage.get(storageKey);
pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` });
} else {
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
res.sendFile(absolutePath);
}
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to serve photo');
}
}
);
// Serve thumbnail
router.get('/:slug/thumbnail/:photoId',
verifyGalleryAccess,
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Ensure thumbnail exists and is valid, regenerate if needed
// Responsive tier (#1095), whitelisted the same way the preview route's
// is. Unrecognised or absent falls through to the canonical 300px
// thumbnail, so existing clients are untouched.
const { THUMBNAIL_WIDTHS, normalizeTierWidth, ensureThumbnailAtWidth } =
require('../../services/imageProcessor');
const thumbTier = normalizeTierWidth(req.query.w, THUMBNAIL_WIDTHS);
const thumbnailPath = thumbTier
? (await ensureThumbnailAtWidth(photo, thumbTier)) || (await ensureThumbnail(photo))
: await ensureThumbnail(photo);
// What was actually resolved, not what was asked for. A tier request can
// land on the canonical thumbnail — generation failed, or the row is a
// video — and stamping the requested tier into the ETag below would then
// have the client cache a 300px image under its 900px key for the full
// max-age, with no way to notice.
const servedTier = thumbTier && thumbnailPath
&& path.basename(thumbnailPath).startsWith(`thumb_w${thumbTier}_`)
? thumbTier
: null;
if (!thumbnailPath) {
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
return res.status(404).json({ error: 'Thumbnail generation failed' });
}
// Read thumbnail metadata via the storage abstraction so we work in
// both LocalFs and S3 modes (#432). The previous fs.statSync on the
// resolved local path 500'd in S3 deployments because the thumbnail
// only exists in the bucket, not on the container's local fs.
const storage = getStorage();
const stat = await storage.stat(thumbnailPath);
if (!stat) {
logger.error(`Thumbnail not found in storage backend for photo ${photoId}`, { thumbnailPath });
return res.status(404).json({ error: 'Thumbnail not found' });
}
// Log thumbnail access
await secureImageService.logImageAccess(
photoId,
req.event.id,
req.clientInfo,
'thumbnail'
);
// Check if watermarks are enabled and apply to thumbnail
const watermarkSettings = await watermarkService.getWatermarkSettings();
// ETag uses storage stat mtime + photo id + watermark hash.
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
// Tier in the ETag, same reason as the preview route: without it a
// client holding the 300px thumbnail gets a 304 for its 600px request
// and renders the small one, which is this feature inverted.
const etag = `"thumb-${photoId}-${servedTier || 'def'}-${mtimeMs}${watermarkHash}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
// Set appropriate headers with enhanced security
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Reduced cache time
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Protected-Thumbnail': 'true',
'ETag': etag
});
if (watermarkSettings && watermarkSettings.enabled) {
// Watermarking needs a local file path (sharp + fs.readFile).
// Materialize via withLocalCopy — no-op in local mode, downloads
// to a tmp file then cleans up in S3 mode.
const watermarkedBuffer = await withLocalCopy(thumbnailPath, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings)
);
res.send(watermarkedBuffer);
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(thumbnailPath);
pipeStreamToResponse(stream, res, { context: `thumbnail for photo ${photoId}` });
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to serve thumbnail');
}
}
);
// Serve hero-optimized image (1920x1080 for full-width hero sections)
router.get('/:slug/hero/:photoId',
verifyGalleryAccess,
// Reveal-gated too: this route serves a 1920px derivative of ANY photo id,
// not just the chosen hero — an open bypass while hidden (review round 1).
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Check if this is a video - videos don't get hero images
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
if (isVideo) {
// For videos, redirect to the regular photo endpoint
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
}
// Ensure hero image exists and is valid, regenerate if needed
const heroPath = await ensureHeroImage(photo);
if (!heroPath) {
// If hero generation fails, fall back to original photo
logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`);
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
}
// Hero images are always written via the storage abstraction (see
// imageProcessor.generateHeroImage), so they're a managed-storage
// key in both LocalFs and S3 modes (#432). Read via storage.
const storage = getStorage();
const stat = await storage.stat(heroPath);
if (!stat) {
logger.error('Hero image file does not exist in storage backend', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
heroPath
});
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
}
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const etag = `"hero-${photoId}-${mtimeMs}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
const watermarkSettings = await watermarkService.getWatermarkSettings();
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Hero-Image': 'true',
'ETag': etag
});
if (watermarkSettings && watermarkSettings.enabled) {
// applyWatermark needs a local file path; materialize via
// withLocalCopy so this works in S3 mode too.
const watermarkedBuffer = await withLocalCopy(heroPath, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings)
);
res.send(watermarkedBuffer);
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(heroPath);
pipeStreamToResponse(stream, res, { context: `hero for photo ${photoId}` });
}
} catch (error) {
logger.error('Error serving hero image:', {
error: error.message,
photoId: req.params.photoId,
eventId: req.event?.id
});
// Fall back to original photo on any error
res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`));
}
}
);
// Lightbox preview tier (#492). Aspect-preserved JPEG capped at 1920px
// long edge — admin-controlled opt-in via app_settings.lightbox_preview_enabled.
// Mirrors the hero route shape: same auth, ETag from preview mtime,
// fall back to original on any failure so the lightbox never shows a
// broken image. The watermark application path is preserved so a
// preview surfaced in the lightbox carries the same protection a
// guest would see on the full original.
router.get('/:slug/preview/:photoId',
verifyGalleryAccess,
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Videos don't get a preview tier — fall through to the regular
// photo endpoint (which serves the source). The frontend should
// already be checking media_type before requesting /preview but
// belt-and-braces in case a stale tab does.
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
if (isVideo) {
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
}
// Responsive tier (#1095). Whitelisted only — an open ?w= would let
// anyone fill the disk with renditions nobody asked for. An unrecognised
// or absent value falls through to the canonical 1920 preview, so old
// clients and hand-typed URLs behave exactly as before.
const { PREVIEW_WIDTHS, normalizeTierWidth, ensurePreviewImageAtWidth } =
require('../../services/imageProcessor');
const tierWidth = normalizeTierWidth(req.query.w, PREVIEW_WIDTHS);
// Lazy generation: ensurePreviewImage returns null on any
// failure (corrupt source, sharp OOM, storage unavailable, …).
// Fall back to the original so the lightbox always renders.
const previewPath = tierWidth
? (await ensurePreviewImageAtWidth(photo, tierWidth)) || (await ensurePreviewImage(photo))
: await ensurePreviewImage(photo);
if (!previewPath) {
logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`);
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
}
const storage = getStorage();
const stat = await storage.stat(previewPath);
if (!stat) {
logger.error('Preview file does not exist in storage backend', {
slug: req.params.slug, photoId, eventId: req.event.id, previewPath,
});
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
}
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const watermarkSettings = await watermarkService.getWatermarkSettings();
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
// Tier is part of the etag: without it a client that already holds the
// 1920 rendition would get a 304 for its 640 request and render the
// wrong size, which is the whole point of the feature inverted.
const etag = `"preview-${photoId}-${tierWidth || 'def'}-${mtimeMs}${watermarkHash}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({
// From the key, not hard-coded: a preview of a transparent or animated
// source is WebP, because JPEG carries neither. `nosniff` below means
// getting this wrong shows a broken image rather than being silently
// corrected by the browser. Pre-existing keys have no .webp suffix and
// are JPEG, so they keep their old header.
'Content-Type': previewPath.endsWith('.webp') ? 'image/webp' : 'image/jpeg',
// Cache aggressively — preview only changes on photo
// re-upload (which generates a new preview key) or settings
// regenerate (which writes a new mtime + ETag).
'Cache-Control': 'private, max-age=3600',
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Preview-Image': 'true',
'ETag': etag,
});
if (watermarkSettings && watermarkSettings.enabled) {
// No Content-Type override here. applyWatermark PRESERVES the source
// format (watermarkService.js: png -> png, webp -> webp, else jpeg),
// and its input is this preview — so the output format matches the key
// the header was already derived from. Forcing image/jpeg would
// mislabel a watermarked WebP preview, and `nosniff` means the browser
// will not correct it.
//
// What is still lost is the animation: the compositor flattens a
// multi-frame source to one frame while keeping the WebP container.
// That is a separate problem and a much larger one.
const watermarkedBuffer = await withLocalCopy(previewPath, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings)
);
res.send(watermarkedBuffer);
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(previewPath);
pipeStreamToResponse(stream, res, { context: `preview for photo ${photoId}` });
}
} catch (error) {
logger.error('Error serving preview image:', {
error: error.message,
photoId: req.params.photoId,
eventId: req.event?.id,
});
res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`));
}
}
);
// GET /:slug/feedback-settings lives in galleryFeedback.js. A duplicate of it
// used to sit here, and since server.js mounts galleryRoutes before
// galleryFeedback it shadowed the real handler — dropping the per-guest caps
// (#655) from the guest payload, so the gallery could never render the
// favorite/like limits or their counters (#1030).
// Get photo stats. no-store: view/download/visitor counters are private
// gallery analytics and change on every request.
module.exports = router;
+249
View File
@@ -0,0 +1,249 @@
const { isGalleryExpired } = require('../../utils/galleryLifecycle');
const express = require('express');
const { db } = require('../../database/db');
const { formatBoolean } = require('../../utils/dbCompat');
const { getAppSetting } = require('../../utils/appSettings');
const { timingSafeEqualStr } = require('../../utils/timingSafe');
const router = express.Router();
const { resolveHeroLogoVisible } = require('../../services/galleryModel');
const { verifyAdminPreview } = require('../../middleware/gallery');
const { noStoreCache } = require('../../middleware/noStoreCache');
const logger = require('../../utils/logger');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../../services/shareLinkService');
const { handleAsync, errorResponse } = require('../../utils/routeHelpers');
const { isGalleryHidden } = require('../../utils/revealMode');
const { NotFoundError } = require('../../utils/errors');
async function checkSlugRedirect(slug) {
try {
const hasTable = await db.schema.hasTable('slug_redirects');
if (!hasTable) return null;
const redirect = await db('slug_redirects')
.where({ old_slug: slug })
.first();
return redirect ? redirect.new_slug : null;
} catch (error) {
logger.warn('Error checking slug redirect:', { slug, error: error.message });
return null;
}
}
router.get('/resolve/:identifier', handleAsync(async (req, res) => {
const { identifier } = req.params;
let result = await resolveShareIdentifier(identifier);
// If not found, check for redirect
if (!result) {
const newSlug = await checkSlugRedirect(identifier);
if (newSlug) {
return res.status(301).json({
redirect: true,
newSlug,
message: 'Gallery has been renamed'
});
}
throw new NotFoundError('Gallery');
}
const { event, matchType, shareToken } = result;
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
// The share_token is a bearer secret. Only return it (and the share
// links/URLs that embed it) when the caller already proved they hold it —
// i.e. they resolved via the token or the full share link. A bare *slug*
// lookup (slugs appear in gallery URLs and are guessable) must NOT hand
// back the secret, or an anonymous caller could turn a known slug into
// share-link access to a no-password gallery (GHSA-rh8r).
const callerHasToken = matchType !== 'slug';
if (!callerHasToken) {
return res.json({ slug: event.slug, matchType, requires_password: requiresPassword });
}
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
res.json({
slug: event.slug,
token: shareToken,
matchType,
share_link: event.share_link,
share_path: linkVariants.sharePath,
share_url: linkVariants.shareUrl,
short_enabled: linkVariants.shortEnabled,
requires_password: requiresPassword
});
}));
// Verify share token. no-store: this is an authorization decision — a cached
// `{ valid: true }` would keep answering for a token the admin has rotated.
router.get('/:slug/verify-token/:token', noStoreCache, handleAsync(async (req, res) => {
const { slug, token } = req.params;
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) })
.select('id', 'share_link', 'share_token')
.first();
if (!event) {
throw new NotFoundError('Gallery');
}
const expectedToken = getEventShareToken(event);
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
throw new NotFoundError('Gallery', 'Invalid gallery link');
}
res.json({ valid: true });
}));
// Get gallery info (with optional token verification)
router.get('/:slug/info', async (req, res) => {
try {
const { slug } = req.params;
const { token } = req.query;
let event = await db('events')
.where({ slug })
.select(
'id',
'created_by',
'event_name',
'event_type',
'event_date',
'expires_at',
'is_active',
'is_archived',
'share_link',
'share_token',
'allow_downloads',
'allow_user_uploads',
'reveal_mode',
'reveal_at',
'revealed_at',
'disable_right_click',
'watermark_downloads',
'watermark_text',
'require_password',
'color_theme',
'enable_devtools_protection',
'use_canvas_rendering',
'hero_logo_visible',
'hero_logo_size',
'hero_logo_position',
'hero_logo_url',
'login_logo_visible',
'header_style',
'hero_divider_style',
'hero_image_anchor',
'is_draft',
'default_photo_sort',
// Per-event promotional override (#440). Resolution into a
// ready-to-render markdown string happens below so the
// frontend doesn't have to know about modes.
'promo_mode',
'promo_markdown',
'info_mode',
'info_markdown'
)
.first();
if (!event) {
// Check for redirect
const newSlug = await checkSlugRedirect(slug);
if (newSlug) {
return res.status(301).json({
redirect: true,
newSlug,
message: 'Gallery has been renamed'
});
}
return res.status(404).json({ error: 'Gallery not found' });
}
// Check if event is archived
if (event.is_archived) {
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
}
// Admin preview (#868) bypasses both the draft gate and — below — the
// password gate. Computed once and reused.
const adminPreview = await verifyAdminPreview(req, event);
// Check if event is a draft (allow admin preview)
if (event.is_draft && !adminPreview) {
return res.status(404).json({ error: 'Gallery is not yet published' });
}
// If token provided, verify it matches the share link
if (token) {
const expectedToken = getEventShareToken(event);
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
return res.status(404).json({ error: 'Invalid gallery link' });
}
}
// Admin preview skips the guest password on published, protected galleries
// (#868) — the admin already sees every photo through the admin routes.
const requiresPassword = adminPreview
? false
: !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
res.json({
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
expires_at: event.expires_at,
is_active: event.is_active,
is_expired: !event.is_active || isGalleryExpired(event),
requires_password: requiresPassword,
color_theme: event.color_theme,
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1',
// Reveal mode (#838): effective hidden state (computed, time-exact) so
// the landing page can hint at the reveal before login too.
hidden_until_reveal: isGalleryHidden(event),
reveal_at: isGalleryHidden(event) ? (event.reveal_at || null) : null,
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
watermark_text: event.watermark_text,
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
// #894: only an explicit false hides the logo on the password page;
// NULL keeps the default (show).
login_logo_visible: !(event.login_logo_visible === false || event.login_logo_visible === 0 || event.login_logo_visible === '0'),
// #756: NULL per-event size inherits the global branding_logo_size.
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
hero_logo_position: event.hero_logo_position || 'top',
hero_logo_url: event.hero_logo_url || null,
header_style: event.header_style || 'standard',
hero_divider_style: event.hero_divider_style || 'wave',
hero_image_anchor: event.hero_image_anchor || 'center',
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
// Per-event promotional override (#440). Frontend resolves
// 'inherit' against branding_promo_markdown from public settings.
promo_mode: event.promo_mode || 'inherit',
promo_markdown: event.promo_markdown || null,
// Info banner (#932). Same inherit/custom/off semantics as promo,
// resolved against branding_info_markdown from public settings.
info_mode: event.info_mode || 'inherit',
info_markdown: event.info_markdown || null
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch gallery info');
}
});
// ---------------------------------------------------------------------------
// Live Slideshow ("Diashow") — token-only fullscreen kiosk surface
// (migration 138). The token in the URL IS the secret (no gallery password),
// so these routes are unauthenticated except for the token match itself. The
// slideshow shows ALL public/visible, finished photos — exactly the guest
// set — so once /session mints a short-lived `accessLevel:'slideshow'` JWT,
// the page reuses the normal /photos + image endpoints unchanged.
// ---------------------------------------------------------------------------
// Photos a slideshow may display: published, finished, non-hidden. Mirrors the
// guest filter in GET /:slug/photos so the live count matches the rendered set.
module.exports = router;
+192
View File
@@ -0,0 +1,192 @@
const express = require('express');
const { db, logActivity } = require('../../database/db');
const router = express.Router();
const { verifyGalleryAccess } = require('../../middleware/gallery');
const { resolveGuest } = require('../../middleware/guestAuth');
const { noStoreCache } = require('../../middleware/noStoreCache');
const { generateGuestIdentifier } = require('../../middleware/feedbackRateLimit');
const { errorResponse } = require('../../utils/routeHelpers');
const { guestBlockedByReveal } = require('../../utils/revealMode');
const downloadZipService = require('../../services/downloadZipService');
const GALLERY_OPENED_DEBOUNCE_MS = 6 * 60 * 60 * 1000;
const galleryOpenedNotifiedAt = new Map();
function galleryActor(req) {
// Portal tokens run as accessLevel 'guest' but carry via:'customer'
// (req.viaCustomer); PIN-client logins carry accessLevel 'client'.
// Both are customers, not guests (codex review of #849, final round).
const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client'));
return { type: isCustomer ? 'customer' : 'guest' };
}
function notifyGalleryOpened(event, req) {
// Customer-PORTAL opens already log `customer_event_access` on the
// access-token mint — a second `gallery_opened` per portal click would
// double-notify. Keyed on the portal provenance (req.viaCustomer), NOT
// on accessLevel: PIN-client logins are 'client' without any other
// open signal and must keep notifying (codex review of #849, final
// round — the previous check had this inverted).
if (req && req.viaCustomer) return;
const now = Date.now();
const last = galleryOpenedNotifiedAt.get(event.id) || 0;
if (now - last < GALLERY_OPENED_DEBOUNCE_MS) return;
galleryOpenedNotifiedAt.set(event.id, now);
// Fire-and-forget — logActivity swallows its own errors.
logActivity('gallery_opened', {}, event.id, galleryActor(req));
}
router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => {
try {
const payload = await require('../../services/galleryQueryService').getGalleryPhotos({
event: req.event, slug: req.params.slug, query: req.query,
identity: { guestId: req.guest?.id, guestIdentifier: generateGuestIdentifier(req) },
accessLevel: req.accessLevel, adminPreview: req.isAdminPreview,
hiddenForGuest: guestBlockedByReveal(req),
});
// Log view — but NOT for the Live Slideshow kiosk. A running projector
// refetches this list on every new-upload poll, which would massively
// inflate total_views / unique_visitors. The slideshow is explicitly
// excluded from real visitor analytics (migration 138 design).
// Admin preview (#868) is excluded from guest analytics + the "gallery
// opened" bell — it's the photographer looking at their own gallery.
if (req.accessLevel !== 'slideshow' && !req.isAdminPreview && !(Number(req.query.page) > 1)) {
await db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'view'
});
notifyGalleryOpened(req.event, req);
}
res.json(payload);
} catch (error) { errorResponse(res, error, 500, 'Failed to fetch photos'); }
});
/**
* People in this gallery (#1074).
*
* Returns [] rather than 403 whenever the feature is unavailable a guest
* must not be able to tell "this gallery has no people" from "this gallery
* has the feature switched off". Same reasoning as reveal mode returning an
* empty photo set rather than an error.
*
* Counts and cover faces are computed against the caller's own visibility
* scope inside facePeopleService; nothing here reads face_count_total.
*/
// no-store for the same reason as /photos: the people list and its scan
// progress are scoped to what THIS viewer may see.
router.get('/:slug/people', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => {
try {
const isClient = req.accessLevel === 'client';
const { isEnabledForEvent, areFacesVisibleToGuests, getThresholds } =
require('../../services/faceSettings');
if (!(await isEnabledForEvent(req.event))) {
return res.json({ people: [] });
}
if (!isClient && !areFacesVisibleToGuests(req.event)) {
return res.json({ people: [] });
}
// While a gallery is hidden behind reveal mode (#838), a plain guest sees
// no photos — so they see no people either.
if (guestBlockedByReveal(req)) {
return res.json({ people: [] });
}
const { listPeople, getScanStatus } = require('../../services/facePeopleService');
const thresholds = await getThresholds();
const people = await listPeople(req.event.id, {
isClient,
forAdmin: false,
minClusterSize: thresholds.face_min_cluster_size,
});
// Drives the "Finding people… 240/1200" progress line during a backfill.
// Scoped to what this viewer may see — an unscoped total would leak the
// number of hidden photos through the progress bar.
const status = await getScanStatus(req.event.id, { isClient });
res.json({
people,
scan: {
in_progress: status.in_progress,
scanned: status.scanned,
total: status.total,
},
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch people');
}
});
// Toggle photo visibility (client-only)
router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => {
try {
if (req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Client access required' });
}
const { photoId } = req.params;
const { visibility } = req.body;
if (!['visible', 'hidden'].includes(visibility)) {
return res.status(400).json({ error: 'Invalid visibility value' });
}
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
await db('photos')
.where({ id: photoId, event_id: req.event.id })
.update({ visibility });
// A client hiding/showing a photo changes the guest download bundle —
// drop the cached ZIP so it rebuilds fresh (codex review).
downloadZipService.invalidate(req.event.id);
res.json({ message: 'Photo visibility updated', visibility });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update photo visibility');
}
});
// Bulk toggle photo visibility (client-only)
router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => {
try {
if (req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Client access required' });
}
const { photoIds, visibility } = req.body;
if (!Array.isArray(photoIds) || photoIds.length === 0) {
return res.status(400).json({ error: 'Invalid photo IDs' });
}
if (!['visible', 'hidden'].includes(visibility)) {
return res.status(400).json({ error: 'Invalid visibility value' });
}
const count = await db('photos')
.whereIn('id', photoIds)
.where('event_id', req.event.id)
.update({ visibility });
// Client bulk hide/show alters the guest download bundle — invalidate
// the cached ZIP (codex review).
downloadZipService.invalidate(req.event.id);
res.json({ message: `${count} photos updated`, visibility });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update photo visibility');
}
});
// Download single photo
module.exports = router;
+293
View File
@@ -0,0 +1,293 @@
const { isGalleryAvailable } = require('../../utils/galleryLifecycle');
const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { db } = require('../../database/db');
const { formatBoolean } = require('../../utils/dbCompat');
const router = express.Router();
const { noStoreCache } = require('../../middleware/noStoreCache');
const logger = require('../../utils/logger');
const { getEventShareToken, buildShareLinkVariants } = require('../../services/shareLinkService');
const { handleAsync } = require('../../utils/routeHelpers');
const { NotFoundError } = require('../../utils/errors');
const { setGalleryAuthCookies } = require('../../utils/tokenUtils');
const { getSlideshowGlobals } = require('../../utils/slideshowGlobals');
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
function slideshowPhotosQuery(eventId, categoryId = null) {
const q = db('photos')
.where('photos.event_id', eventId)
.where(function() {
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
})
.where(function() {
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
});
// Category filter (#202) — keep the /session + /state count in sync with the
// photos the kiosk actually renders.
if (categoryId) q.where('photos.category_id', categoryId);
return q;
}
// Resolve an active slideshow by slug + token. Returns the event row, or null
// when the link is missing/rotated/disabled or the gallery isn't live (archived
// / draft / inactive / expired) — every one of those collapses to a 404 so a
// dead link reveals nothing and stops any projector on its next poll.
async function resolveSlideshow(slug, token) {
if (!token) return null;
// The `slideshow` feature flag is a master kill-switch: when an admin turns
// Live Slideshow off, every existing /show/ link dies on its next request
// (the running projector stops within one /state poll), not just the admin UI.
if (!(await isFeatureEnabled('slideshow'))) return null;
const event = await db('events')
.where({
slug,
show_share_token: token,
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
})
.first();
if (!isGalleryAvailable(event)) return null;
return event;
}
// Resolve the slideshow's live styling, including the ZDF/ARD-ident-style
// watermark (a white, semi-transparent corner logo). The logo URL is resolved
// from the chosen source so the kiosk renders it without knowing about
// branding/event internals; null url = nothing to overlay.
async function slideshowSettings(event, req) {
// The global look/fit (Settings → Slideshow) + branding logo URLs come from a
// short-TTL cached bundle so a 3s projector poll doesn't re-fire ~10 settings
// reads each time (PR #646 review, concern 2).
const g = await getSlideshowGlobals();
// Watermark: the LOOK (logo/position/opacity/style/size) is configured ONCE
// globally; it is NOT duplicated per event. The only per-event control is
// whether the watermark shows: `show_watermark` NULL inherits the global
// enabled flag, true/false force it on/off.
const wm = event.show_watermark;
const inherit = (wm === null || wm === undefined);
const enabled = inherit ? g.watermark_enabled : (wm === true || wm === 1 || wm === '1');
let watermark = null;
if (enabled) {
// Resolve the chosen logo to a URL. Branding assets come from settings;
// the event source uses the event's own hero logo.
let url;
if (g.watermark_source === 'event') {
url = event.hero_logo_url || null;
} else if (g.watermark_source === 'logo_dark') {
url = g.branding_logo_url_dark;
} else if (g.watermark_source === 'favicon') {
url = g.branding_favicon_url;
} else {
url = g.branding_logo_url;
}
if (url) {
watermark = {
url,
position: g.watermark_position,
opacity: g.watermark_opacity,
style: g.watermark_style,
size: g.watermark_size,
};
}
}
// QR overlay (#837): like the watermark, the LOOK is global-only and the
// per-event `show_qr` tri-state (NULL = inherit) decides visibility. The QR
// encodes the gallery share URL and ships as a data URI so the public
// slideshow client needs no QR library and no extra authenticated endpoint.
const qrOverride = event.show_qr;
const qrInherit = (qrOverride === null || qrOverride === undefined);
const qrEnabled = qrInherit ? g.qr_enabled : (qrOverride === true || qrOverride === 1 || qrOverride === '1');
let qr = null;
if (qrEnabled) {
const dataUrl = await slideshowQrDataUrl(event, req);
if (dataUrl) {
qr = {
data_url: dataUrl,
position: g.qr_position,
opacity: g.qr_opacity,
size: g.qr_size,
};
}
}
return {
interval_ms: event.show_interval_ms || 5000,
transition: event.show_transition || 'crossfade',
transition_ms: event.show_transition_ms || 800,
colorfilter: event.show_colorfilter || 'none',
// Play order (#202): 'chronological' | 'random'. The client shuffles when
// 'random' so live-appended uploads keep working.
order: event.show_order || 'chronological',
fit: g.fit,
watermark,
qr,
};
}
// The state endpoint is polled every ~3s per projector — cache the generated
// QR data URI per share URL instead of re-encoding on every poll. Bounded:
// entries live for past events / rotated tokens too, so without eviction the
// map would grow with every share URL ever displayed (codex review of #848).
// Insertion-order eviction is enough — concurrently-shown events stay hot.
const SLIDESHOW_QR_CACHE_MAX = 50;
// Keyed by event id (NOT by URL): the origin is caller-influenced when the
// configured base is loopback, so URL-keyed caching would let a slideshow
// -link holder force a fresh QRCode.toDataURL per request with unique
// origins — a cheap CPU-exhaustion path (codex review of #848,
// confirmation round). Per-event entries + a regeneration throttle bound
// the encode rate regardless of what the caller sends.
const SLIDESHOW_QR_REGEN_MS = 60_000;
const slideshowQrCache = new Map(); // eventId -> { url, dataUrl, at }
// Localhost/relative guard (codex review of #848): with the compose-default
// FRONTEND_URL=http://localhost:3000 (or none configured) the QR would send
// scanning phones to THEIR localhost. The state poll comes from the kiosk
// browser itself, so its Host header + protocol are exactly the public
// origin guests can reach — prefer that whenever the configured base is
// missing or loopback. trust proxy is configured, so req.protocol respects
// X-Forwarded-Proto behind the standard reverse-proxy setups.
// Centralised in utils/frontendUrl (#705) so the QR path and the public-origin
// resolver agree on what counts as a non-shareable base.
const QR_LOCAL_BASE_RE = { test: (v) => require('../../utils/frontendUrl').isLoopbackBase(v) };
const QR_ORIGIN_RE = /^https?:\/\/[^\s/]+$/i;
async function slideshowQrDataUrl(event, req) {
try {
const shareToken = getEventShareToken(event);
if (!shareToken) return null;
let { shareUrl, sharePath } = await buildShareLinkVariants({ slug: event.slug, shareToken });
if (!/^https?:\/\//i.test(shareUrl) || QR_LOCAL_BASE_RE.test(shareUrl)) {
// Prefer the kiosk's own window.location.origin (?origin=, validated):
// req.get('host') is NOT the browser origin behind the standard
// proxies — frontend/nginx.conf forwards $host (port stripped), so a
// compose LAN deployment on :3000 would encode port 80. A LOOPBACK
// kiosk origin is rejected too: it is no more guest-reachable than
// the loopback base it would replace (codex review of #848).
const rawOrigin = req?.query?.origin;
const queryOrigin = typeof rawOrigin === 'string' && QR_ORIGIN_RE.test(rawOrigin) && !QR_LOCAL_BASE_RE.test(rawOrigin)
? rawOrigin.replace(/\/$/, '')
: null;
const host = req && req.get ? req.get('host') : null;
const hostOrigin = host ? `${req.protocol}://${host}` : null;
if (queryOrigin) shareUrl = `${queryOrigin}${sharePath}`;
else if (hostOrigin && !QR_LOCAL_BASE_RE.test(hostOrigin)) shareUrl = `${hostOrigin}${sharePath}`;
// Still loopback/relative → no reachable URL exists; suppress the
// overlay rather than encode a QR that sends phones to localhost.
else return null;
}
const cached = slideshowQrCache.get(event.id);
if (cached && cached.url === shareUrl) return cached.dataUrl;
// URL differs from the cached one: NEVER serve the mismatched artifact —
// a slideshow-token holder could otherwise poison the projector's QR
// with an attacker origin for a whole throttle window (codex review of
// #848, final round). Inside the window the overlay is briefly
// suppressed instead; regeneration stays bounded per event.
if (cached && Date.now() - cached.at < SLIDESHOW_QR_REGEN_MS) {
return cached.pending ? cached.dataUrl : null;
}
// Single-flight: concurrent polls on a cold cache must not each
// schedule their own 512px encode — reserve the entry with a shared
// promise before awaiting.
if (cached && cached.pending && cached.url === shareUrl) return cached.pending;
const QRCode = require('qrcode');
const entry = { url: shareUrl, dataUrl: null, at: Date.now(), pending: null };
entry.pending = QRCode.toDataURL(shareUrl, { width: 512, margin: 4 }).then((dataUrl) => {
entry.dataUrl = dataUrl;
entry.pending = null;
return dataUrl;
}).catch((e) => {
slideshowQrCache.delete(event.id);
throw e;
});
if (!slideshowQrCache.has(event.id) && slideshowQrCache.size >= SLIDESHOW_QR_CACHE_MAX) {
slideshowQrCache.delete(slideshowQrCache.keys().next().value);
}
slideshowQrCache.set(event.id, entry);
return await entry.pending;
} catch (e) {
logger.error('Slideshow QR generation failed:', e);
return null;
}
}
// Open a slideshow session: validate the token and mint a short-lived gallery
// JWT scoped to `accessLevel:'slideshow'` (treated as a guest by the photo /
// image endpoints → visible photos only, no client-only/hidden). The page
// stores this token and the existing axios interceptor injects it.
// no-store: this response *is* a credential (it mints a gallery JWT and sets
// the per-slug auth cookie), so it must never be retained anywhere.
router.get('/:slug/show/:token/session', noStoreCache, handleAsync(async (req, res) => {
const { slug, token } = req.params;
const event = await resolveSlideshow(slug, token);
if (!event) {
throw new NotFoundError('Slideshow');
}
const sessionToken = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
// Unique per token: the revocation key falls back to eventId+iat otherwise,
// so one guest's logout would revoke every same-second login (#1357).
jti: crypto.randomUUID(),
accessLevel: 'slideshow',
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '12h',
issuer: 'picpeak-auth'
});
// <img> tags can't carry an Authorization header, so the photo/thumbnail/
// preview endpoints authenticate via the per-slug gallery cookie. Set it
// here so the kiosk's image requests are authorized with zero extra wiring.
setGalleryAuthCookies(res, sessionToken, event.slug);
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
res.json({
token: sessionToken,
event: {
event_name: event.event_name,
event_type: event.event_type,
color_theme: event.color_theme
},
settings: await slideshowSettings(event, req),
photo_count: parseInt(count, 10) || 0,
expires_at: event.expires_at || null
});
}));
// Cheap live-poll endpoint (tiny payload, hit every ~3s by the running show):
// current settings + the visible photo count. The page diffs photo_count to
// decide when to refetch the full list, and re-reads settings so admin changes
// take effect live. A dead/disabled link 404s here → the projector stops.
router.get('/:slug/show/:token/state', noStoreCache, handleAsync(async (req, res) => {
const { slug, token } = req.params;
const event = await resolveSlideshow(slug, token);
if (!event) {
throw new NotFoundError('Slideshow');
}
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
res.json({
...(await slideshowSettings(event, req)),
photo_count: parseInt(count, 10) || 0,
expires_at: event.expires_at || null
});
}));
// Get all photos.
//
// no-store (B6): the payload is private and per-guest — it carries the
// viewer's own likes/favorites/ratings and, for a client token, photos hidden
// from plain guests. With no Cache-Control at all a browser applies heuristic
// freshness and may reuse a body it stored on disk, on a shared device, for a
// gallery whose password has since been rotated. Express still computes its
// weak ETag, so a caller that does revalidate (React Query's own in-memory
// cache is unaffected either way) still gets a correct 304.
module.exports = router;
+44
View File
@@ -0,0 +1,44 @@
const express = require('express');
const { db } = require('../../database/db');
const router = express.Router();
const { verifyGalleryAccess } = require('../../middleware/gallery');
const { noStoreCache } = require('../../middleware/noStoreCache');
const { blockHiddenGallery } = require('../../utils/revealMode');
router.get('/:slug/stats', verifyGalleryAccess, blockHiddenGallery, noStoreCache, async (req, res) => {
try {
const totalPhotos = await db('photos')
.where('event_id', req.event.id)
.count('id as count')
.first();
const totalViews = await db('access_logs')
.where('event_id', req.event.id)
.where('action', 'view')
.count('id as count')
.first();
const totalDownloads = await db('photos')
.where('event_id', req.event.id)
.sum('download_count as total')
.first();
const uniqueVisitors = await db('access_logs')
.where('event_id', req.event.id)
.countDistinct('ip_address as count')
.first();
res.json({
total_photos: totalPhotos.count,
total_views: totalViews.count,
total_downloads: totalDownloads.total || 0,
unique_visitors: uniqueVisitors.count
});
} catch (error) {
res.status(500).json({ error: 'Failed to fetch stats' });
}
});
// User photo upload endpoint
module.exports = router;
+41
View File
@@ -0,0 +1,41 @@
const express = require('express');
const { db } = require('../../database/db');
const router = express.Router();
const logger = require('../../utils/logger');
router.get('/:slug/css-template', async (req, res) => {
try {
const { slug } = req.params;
// Find the event by slug
const event = await db('events')
.where({ slug })
.select('css_template_id')
.first();
if (!event || !event.css_template_id) {
// No custom CSS - return 204 No Content
return res.status(204).send();
}
// Get the template if it's enabled
const template = await db('css_templates')
.where({ id: event.css_template_id, is_enabled: true })
.select('css_content')
.first();
if (!template || !template.css_content) {
return res.status(204).send();
}
// Return CSS with caching headers
res.setHeader('Content-Type', 'text/css');
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
res.send(template.css_content);
} catch (error) {
logger.error('Get CSS template error:', error);
res.status(500).send('/* Error loading template */');
}
});
module.exports = router;
+211
View File
@@ -0,0 +1,211 @@
const express = require('express');
const { db } = require('../../database/db');
const router = express.Router();
const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery');
const { noStoreCache } = require('../../middleware/noStoreCache');
const logger = require('../../utils/logger');
const { errorResponse } = require('../../utils/routeHelpers');
router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId);
// Verify the event matches the token
if (req.event.id !== eventId) {
return res.status(403).json({ error: 'Access denied' });
}
// Check if user uploads are allowed
if (!req.event.allow_user_uploads) {
return res.status(403).json({ error: 'User uploads are not allowed for this event' });
}
// Ensure temp upload directory exists
const fs = require('fs');
const tempUploadDir = '/tmp/uploads/';
if (!fs.existsSync(tempUploadDir)) {
try {
fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 });
logger.info('Created temp upload directory:', tempUploadDir);
} catch (mkdirErr) {
return errorResponse(res, mkdirErr, 500, 'Server configuration error: unable to create upload directory');
}
}
// Import multer and photo processing
const multer = require('multer');
const { getAllowedMimeTypes, getMaxFilesPerUpload, getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings');
const { validateFileType } = require('../../utils/fileSecurityUtils');
// Resolve allowed MIME types from settings
let allowedMimeTypes;
try {
allowedMimeTypes = await getAllowedMimeTypes();
} catch {
allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
}
// #613 — per-batch file count was hardcoded to 10 here, so the admin's
// Settings → General → "Max Files per Upload" value silently didn't
// apply to guest uploads (only admin uploads honoured it via
// adminPhotos.js:131). Zszywany reported uploading 16 files succeeded
// even with the limit set to 10. Mirror the admin path: resolve from
// settings (cached for 60s in the service) and feed multer both
// `limits.files` and the `.array(...)` cap. Fall back to the service's
// default if the read fails.
let maxFilesPerUpload;
try {
maxFilesPerUpload = await getMaxFilesPerUpload();
} catch {
maxFilesPerUpload = 500;
}
// Per-file size cap was hardcoded to 50MB here, so the admin's Settings →
// General → "Max File Size (MB)" value (general_max_file_size_mb) never
// applied to guest uploads — a guest could not upload a large video even
// when the admin allowed it (reported on #613 by mat1990dj). Resolve it from
// settings like the count above; fall back to the 50MB default on read error.
let maxFileSizeBytes;
try {
maxFileSizeBytes = await getMaxFileSizeBytes();
} catch {
maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024;
}
const upload = multer({
dest: tempUploadDir,
limits: {
fileSize: maxFileSizeBytes,
files: maxFilesPerUpload
},
fileFilter: (req, file, cb) => {
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
cb(null, true);
} else {
cb(new Error('Invalid file type'));
}
}
}).array('photos', maxFilesPerUpload);
// Handle upload
upload(req, res, async (err) => {
if (err) {
logger.error('Upload error:', err);
// Turn multer's generic "File too large" into an actionable message
// that names the configured limit.
if (err.code === 'LIMIT_FILE_SIZE') {
const limitMb = Math.floor(maxFileSizeBytes / (1024 * 1024));
return res.status(400).json({ error: `File too large. Maximum size is ${limitMb} MB per file.` });
}
return res.status(400).json({ error: err.message });
}
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const { queueFilesForProcessing } = require('../../services/photoProcessor');
const rawCategory = req.body.category_id || req.event.upload_category_id || null;
const numericCategoryId = (() => {
if (rawCategory === null || rawCategory === undefined) return null;
const n = parseInt(rawCategory, 10);
return Number.isFinite(n) ? n : null;
})();
try {
// Queue files as 'pending' — the background worker will process
// thumbnails / EXIF / dimensions off the request thread (#357).
const result = await queueFilesForProcessing(req.files, {
eventId,
photoType: 'individual',
categoryId: numericCategoryId,
});
res.status(202).json({
message: 'Photos queued for processing',
upload_id: result.uploadId,
count: result.photos.length,
photo_ids: result.photos.map((p) => p.id),
photos: result.photos,
errors: result.errors.length > 0 ? result.errors : undefined,
});
} catch (processError) {
errorResponse(res, processError, 500, 'Failed to process photos');
}
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to upload photos');
}
});
// A guest upload_id is `crypto.randomBytes(16).toString('hex')`
// (photoProcessor.js). The pattern is deliberately a little wider than that so
// an id-format change does not silently 400, but narrow enough that the value
// can only ever be an opaque token.
const UPLOAD_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
// The guest UI uploads one file per request, so a batch of N files yields N
// upload ids. Batching them into a single poll keeps the request rate flat
// regardless of batch size; the cap bounds the IN-list.
const MAX_UPLOAD_STATUS_IDS = 50;
/**
* GET /:slug/uploads/status?ids=<upload_id>[,<upload_id>]
*
* Guest-facing processing status for the guest's own uploads (B7).
*
* The upload route answers 202 and queues the files, and /photos only returns
* rows that reached `processing_status: 'complete'`. Without this the gallery
* had to poll /photos blind, could not say "processing…", and could not tell a
* slow worker from a photo that failed outright the guest just watched their
* upload not appear.
*
* Authorization: `verifyGalleryAccess` already resolved `req.event` from the
* caller's gallery token, and the query is filtered on `event_id = req.event.id`
* as well as the ids. An id belonging to another gallery therefore matches no
* row rather than being reported as forbidden no cross-event read, and no
* existence oracle either. Slideshow tokens are denied because a kiosk never
* uploads.
*
* The response is counts only. The guest already knows which files they sent;
* anything more (filenames, `processing_error` strings, which can carry
* internal paths) would be leaking beyond "how far along is my upload".
*/
router.get('/:slug/uploads/status', verifyGalleryAccess, denySlideshowToken, noStoreCache, async (req, res) => {
try {
const ids = String(req.query.ids || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean);
if (ids.length === 0 || ids.length > MAX_UPLOAD_STATUS_IDS || !ids.every((id) => UPLOAD_ID_PATTERN.test(id))) {
return res.status(400).json({ error: 'Invalid upload ids' });
}
const rows = await db('photos')
.where('event_id', req.event.id)
.whereIn('upload_id', ids)
.select('processing_status');
const summary = { total: rows.length, pending: 0, processing: 0, complete: 0, failed: 0 };
for (const row of rows) {
// NULL is a pre-async-migration row, treated as complete exactly as the
// /photos filter treats it.
const status = row.processing_status || 'complete';
if (Object.prototype.hasOwnProperty.call(summary, status) && status !== 'total') {
summary[status] += 1;
}
}
res.json(summary);
} catch (error) {
errorResponse(res, error, 500, 'Failed to read upload status');
}
});
/**
* GET /:slug/css-template
* Get custom CSS template for gallery (public endpoint)
*/
module.exports = router;
+19 -13
View File
@@ -6,6 +6,7 @@ const { verifyGalleryAccess } = require('../middleware/gallery');
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
const watermarkService = require('../services/watermarkService');
const secureImageService = require('../services/secureImageService');
const galleryAccessService = require('../services/galleryAccessService');
const { getStorage } = require('../services/storage');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver');
const { withLocalCopy } = require('../services/imageProcessor');
@@ -19,16 +20,13 @@ const router = express.Router();
/**
* Generate a signed URL token for image access
*/
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false) {
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false, galleryAccess) {
const secret = process.env.JWT_SECRET;
const expires = Date.now() + (expiresIn * 1000);
// Third segment (#838): whether the minting context bypasses reveal mode
// (slideshow/client/admin). Fourth segment: whether the minter was a
// PIN-client, allowing the serve route to still deliver a photo that was
// hidden AFTER minting (TOCTOU) — a guest's token carries 0, so it stops
// working the moment the photo is hidden. Old shorter tokens verify
// unchanged and read both flags as no-bypass.
const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}:${clientBypass ? 1 : 0}`;
// Bind the URL to its issuing access grant. Old tokens without a grant
// must be refreshed: they cannot prove session revocation or ownership.
const grant = Buffer.from(JSON.stringify(galleryAccess)).toString('base64url');
const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}:${clientBypass ? 1 : 0}:${grant}`;
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
return `${Buffer.from(data).toString('base64')}.${signature}`;
}
@@ -41,7 +39,7 @@ function verifyImageToken(token) {
const secret = process.env.JWT_SECRET;
const [data, signature] = token.split('.');
const decoded = Buffer.from(data, 'base64').toString();
const [photoId, expires, bypassFlag, clientFlag] = decoded.split(':');
const [photoId, expires, bypassFlag, clientFlag, grant] = decoded.split(':');
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
@@ -50,7 +48,7 @@ function verifyImageToken(token) {
}
// Check expiration
if (Date.now() > parseInt(expires)) {
if (!Number.isFinite(Number(expires)) || Date.now() >= Number(expires) || !grant) {
return null;
}
@@ -59,6 +57,7 @@ function verifyImageToken(token) {
expires: parseInt(expires),
revealBypass: bypassFlag === '1',
clientBypass: clientFlag === '1',
galleryAccess: JSON.parse(Buffer.from(grant, 'base64url').toString()),
};
} catch (error) {
return null;
@@ -170,6 +169,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery
res.send(finalImage);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving protected image:', error);
res.status(500).json({ error: 'Failed to serve image' });
}
@@ -207,6 +207,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
// Generate secure token. clientBypass lets a client's token keep serving
// a photo hidden after minting; a guest's stops at the serve route.
const token = secureImageService.generateSecureToken(photoId, req.sessionID || 'anonymous', {
galleryAccess: req.galleryAccess,
expiresIn,
maxUses: protectionLevel === 'maximum' ? 1 : 3,
clientFingerprint,
@@ -222,6 +223,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
});
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error generating secure token:', error);
res.status(500).json({ error: 'Failed to generate token' });
}
@@ -262,7 +264,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
// Generate signed token. The client-bypass flag lets a PIN-client's
// token keep serving a photo hidden after minting; a guest's token
// (clientBypass=0) stops the moment the photo is hidden.
const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel));
const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel), req.galleryAccess);
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
res.json({
@@ -271,6 +273,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
});
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error generating signed URL:', error);
res.status(500).json({ error: 'Failed to generate URL' });
}
@@ -299,6 +302,8 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
return res.status(404).json({ error: 'Event not found' });
}
await galleryAccessService.authorize(event, tokenData.galleryAccess);
// Reveal mode (#838): a signed URL minted before a re-hide must not keep
// serving hidden photos; tokens minted by bypass contexts carry the flag.
if (isGalleryHidden(event) && !tokenData.revealBypass) {
@@ -338,7 +343,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Length': imageBuffer.length,
'Cache-Control': 'private, max-age=3600',
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff'
});
@@ -346,9 +351,10 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
res.send(imageBuffer);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving signed image:', error);
res.status(500).json({ error: 'Failed to serve image' });
}
});
module.exports = router;
module.exports = router;
+12
View File
@@ -4,6 +4,7 @@ const { db } = require('../database/db');
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
const secureImageService = require('../services/secureImageService');
const galleryAccessService = require('../services/galleryAccessService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
@@ -58,6 +59,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
// Generate secure token with appropriate settings
const tokenOptions = {
galleryAccess: req.galleryAccess,
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
maxUses: accessType === 'download' ? 1 : 3,
clientFingerprint,
@@ -96,6 +98,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
});
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error generating secure token', {
error: error.message,
photoId: req.body.photoId,
@@ -151,6 +154,10 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Gallery not found' });
}
// Revalidate the issuing session, ownership and gallery lifecycle at
// every use, including capabilities minted before logout or restore.
await galleryAccessService.authorize(event, tokenValidation.data?.galleryAccess);
// Bind the token to the gallery + photo it was minted for
// (GHSA-g94x-8vv8-3c9f). This route serves via <img src> with the
// token in the URL, so it can't require verifyGalleryAccess like the
@@ -248,6 +255,7 @@ router.get('/:slug/secure/:photoId/:token',
res.send(processedImage);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving secure image', {
error: error.message,
photoId,
@@ -292,6 +300,8 @@ router.get('/:slug/secure-download/:photoId/:token',
return res.status(403).json({ error: 'Invalid or expired token' });
}
await galleryAccessService.authorize(req.event, tokenValidation.data?.galleryAccess);
// Bind the token to the photo it was minted for (GHSA-crxv) — the
// /secure serve route does this, but secure-download did not, so a
// token minted for photo A could download photo B (incl. a hidden one).
@@ -386,6 +396,7 @@ router.get('/:slug/secure-download/:photoId/:token',
res.send(fileBuffer);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving secure download', {
error: error.message,
photoId: req.params.photoId
@@ -414,6 +425,7 @@ router.get('/security/stats', adminAuth, requirePermission('settings.view'), asy
res.json(stats);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error getting security stats', { error: error.message });
res.status(500).json({ error: 'Failed to get security stats' });
}
@@ -1,283 +1,74 @@
/**
* Regression tests for issue #550.
*
* Two related bugs in POST /v1/events:
* 1. color_theme was not accepted on the request body and never written
* to the events row. Editing such an event later in the admin UI
* snapped the theme picker to GALLERY_THEME_PRESETS.default and
* saving overwrote whatever theme was inherited visually.
* 2. event_feedback_settings row was never created, so the gallery UI
* read it as "feedback off" regardless of the global
* event_default_feedback_enabled toggle (#520).
*
* Test pattern mirrors events.category.test.js queue up db() chains
* with db.__setImplementations() in the exact order the handler invokes
* them, then assert against the captured payloads.
*/
/** Persisted contracts, not a mock tied to the number/order of Knex calls. */
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../../../../__tests__/integration/helpers/crmDb');
const request = require('supertest');
const express = require('express');
const buildChain = ({ firstResult, insertResult, returningResult, selectResult } = {}) => {
const chain = {
where: jest.fn().mockReturnThis(),
whereIn: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
// `select` resolves to an array so `await db(...).whereIn(...).select(...)`
// gives an iterable result (used by the branding-defaults probe added in
// #592 follow-up). Tests that don't need it leave selectResult undefined
// and get `[]`, which is a safe no-op for any caller that iterates.
select: jest.fn().mockResolvedValue(selectResult ?? []),
first: jest.fn().mockResolvedValue(firstResult),
insert: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]),
};
return chain;
};
jest.mock('../../../database/db', () => {
const dbMock = jest.fn();
dbMock.raw = jest.fn();
dbMock.__setImplementations = (...chains) => {
dbMock.mockReset();
chains.forEach((chain) => {
dbMock.mockImplementationOnce(() => chain);
});
};
return {
db: dbMock,
logActivity: jest.fn().mockResolvedValue(undefined),
};
let db, cleanup, app, adminId, adminToken, apiToken;
const base = { event_type: 'wedding', event_name: 'Creation parity', event_date: '2030-06-15',
customer_name: 'Ada', customer_email: '[email protected]', admin_email: '[email protected]',
require_password: false, is_draft: false, expires_at: '2030-07-15T00:00:00.000Z' };
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb()); ({ adminId } = await seedMinimal(db)); await assignAdminRole(db, adminId);
adminToken = mintAdminToken(adminId);
const generated = require('../../../middleware/apiTokenAuth').generateApiToken(); apiToken = generated.plaintext;
await db('api_tokens').insert({ name: 'parity', hashed_token: generated.hashed, scopes: 'admin', created_by: adminId });
app = express(); app.use(express.json());
app.use('/admin', require('../../adminEvents'));
app.use('/v1', require('../events'));
}, 120000);
afterAll(async () => { await require('../../../services/serviceShutdown').stopServices(); await cleanup(); });
async function create(source, extra) {
const input = { ...base, ...extra };
if (source === 'legacy') return require('../../../services/eventService').createEvent(input, { actor: { id: adminId } });
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send(input);
expect(response.status).toBe(source === 'admin' ? 200 : 201);
return response.body;
}
it.each(['admin', 'v1', 'legacy'])('%s stores theme, owner, dates and feedback defaults through one use case', async source => {
const theme = JSON.stringify({ primaryColor: '#ff0066' });
const created = await create(source, { color_theme: theme, feedback_enabled: true });
const row = await db('events').where({ id: created.id }).first();
expect(row).toMatchObject({ color_theme: theme, created_by: adminId, event_name: base.event_name, customer_email: base.customer_email });
expect(require('../../../utils/dateNormalize').toIso(row.expires_at)).toBe(base.expires_at);
expect([false, 0]).toContain(row.require_password);
expect(row.updated_at).toBeTruthy(); expect(row.share_token).toBeTruthy(); expect(row.password_hash).toBeTruthy();
const feedback = await db('event_feedback_settings').where({ event_id: row.id }).first();
for (const key of ['feedback_enabled','allow_ratings','allow_likes','allow_comments','allow_favorites','allow_reactions','moderate_comments','show_feedback_to_guests']) expect([true, 1]).toContain(feedback[key]);
expect([false, 0]).toContain(feedback.allow_color_labels); expect(feedback.keybind_mode).toBe('colors');
});
// RBAC is enforced on these routes since GHSA-9697 (requirePermission), but
// this suite mocks the database, so a real permission lookup would 500. These
// tests cover route logic, not authorization — the intersection of token
// scopes and role permissions is pinned in __tests__/routes/v1EventOwnership.
jest.mock('../../../middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
userHasAnyPermission: async () => true,
userHasAllPermissions: async () => true,
}));
jest.mock('../../../middleware/apiTokenAuth', () => ({
apiTokenAuth: (req, _res, next) => {
req.apiToken = { id: 1, admin_id: 1, scopes: ['admin'] };
req.admin = { id: 1, username: 'token-admin' };
next();
},
requireApiScope: () => (_req, _res, next) => next(),
}));
// bcrypt.hash is awaited twice per request (real path + dummy path).
// Stub it to a constant so tests don't burn CPU on bcrypt rounds.
jest.mock('bcrypt', () => ({
hash: jest.fn().mockResolvedValue('$2b$10$mocked-hash'),
}));
jest.mock('../../../services/shareLinkService', () => ({
buildShareLinkVariants: jest.fn().mockResolvedValue({
shareUrl: 'https://example.test/gallery/some-slug?t=abc',
shareLinkToStore: '/gallery/some-slug?t=abc',
}),
}));
// Webhook fire is in a try/catch; stub to silence the predictable
// failure log so test output stays clean.
jest.mock('../../../services/webhookService', () => ({
fire: jest.fn().mockResolvedValue(undefined),
buildEventSubject: jest.fn().mockReturnValue({}),
}));
// event_type is validated against the live event_types catalog (#800) —
// that lookup would consume the first queued db() chain and shift the
// call sequence these tests pin. Stub it valid; the invalid path has its
// own test below.
jest.mock('../../../services/eventTypeService', () => ({
isValidEventType: jest.fn().mockResolvedValue(true),
}));
const { db } = require('../../../database/db');
const { isValidEventType } = require('../../../services/eventTypeService');
const eventsRouter = require('../events');
const buildApp = () => {
const app = express();
app.use(express.json());
app.use('/', eventsRouter);
return app;
};
const BASE_BODY = {
event_name: 'Issue 550 Wedding',
event_type: 'wedding',
event_date: '2026-06-15',
require_password: false,
};
// db() call sequence for BASE_BODY (no feedback / devtools provided,
// require_password supplied so its probe is skipped, no customer_phone,
// no slug collision):
// 1. app_settings.where('event_default_feedback_enabled').first() (#550)
// 2. app_settings.where('enable_devtools_protection').first() (#592)
// 3. app_settings.whereIn([branding_logo_display_hero,...]).select(...) (#592 follow-up)
// Then slug probe, events insert, optional feedback insert.
const baseSettingsChains = () => [
buildChain({ firstResult: null }), // feedback default
buildChain({ firstResult: null }), // devtools default
buildChain({ selectResult: [] }), // image-security whereIn → empty rows (#1296)
buildChain({ selectResult: [] }), // branding whereIn → empty rows
];
describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('persists color_theme to the events row when provided', async () => {
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 42 }] });
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, color_theme: 'default' })
.expect(201);
const insertedRow = insertChain.insert.mock.calls[0][0];
expect(insertedRow).toMatchObject({
event_name: 'Issue 550 Wedding',
color_theme: 'default',
});
});
it('accepts a JSON-encoded theme string and persists it verbatim', async () => {
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 43 }] });
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
const customTheme = JSON.stringify({ primaryColor: '#ff0066' });
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, color_theme: customTheme })
.expect(201);
const insertedRow = insertChain.insert.mock.calls[0][0];
expect(insertedRow.color_theme).toBe(customTheme);
});
it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => {
// feedback_enabled provided → feedback probe SKIPPED. Sequence:
// 1. devtools probe
// 2. image-security probe (whereIn → select, #1296)
// 3. branding probe (whereIn → select)
// 4. slug probe
// 5. events insert
// 6. feedback sub-toggle defaults probe (whereIn → select, #1044)
// 7. event_feedback_settings insert
const devtoolsChain = buildChain({ firstResult: null });
const imageSecurityChain = buildChain({ selectResult: [] });
const brandingChain = buildChain({ selectResult: [] });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 50 }] });
const feedbackDefaultsChain = buildChain({ selectResult: [] });
const feedbackInsertChain = buildChain();
db.__setImplementations(
devtoolsChain, imageSecurityChain, brandingChain, slugChain, insertChain,
feedbackDefaultsChain, feedbackInsertChain,
);
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, feedback_enabled: true })
.expect(201);
expect(db).toHaveBeenNthCalledWith(7, 'event_feedback_settings');
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
expect(feedbackRow).toMatchObject({ event_id: 50 });
// formatBoolean() returns 1/0 on SQLite and true/false on PG. Either
// way the value must be truthy/falsy in the right places — assert by
// coercion so the test stays driver-agnostic.
expect(Boolean(feedbackRow.feedback_enabled)).toBe(true);
expect(Boolean(feedbackRow.allow_ratings)).toBe(true);
expect(Boolean(feedbackRow.allow_likes)).toBe(true);
expect(Boolean(feedbackRow.allow_comments)).toBe(true);
expect(Boolean(feedbackRow.allow_favorites)).toBe(true);
// #1044: this insert used to omit allow_reactions entirely, so v1-created
// events only got reactions by accident of the column default.
expect(Boolean(feedbackRow.allow_reactions)).toBe(true);
// Colour labels are opt-in, so they stay off until the global is flipped.
expect(Boolean(feedbackRow.allow_color_labels)).toBe(false);
expect(feedbackRow.keybind_mode).toBe('colors');
expect(Boolean(feedbackRow.require_name_email)).toBe(false);
expect(Boolean(feedbackRow.moderate_comments)).toBe(true);
expect(Boolean(feedbackRow.show_feedback_to_guests)).toBe(true);
});
it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => {
// Feedback probe returns serialized "true" → fallback kicks in and
// the feedback insert runs. Sequence: feedback probe, devtools probe,
// image-security probe (#1296), branding probe, slug, insert, sub-toggle
// defaults probe (#1044), feedback insert (8 calls total).
const feedbackProbe = buildChain({
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' },
});
const devtoolsChain = buildChain({ firstResult: null });
const imageSecurityChain = buildChain({ selectResult: [] });
const brandingChain = buildChain({ selectResult: [] });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 51 }] });
const feedbackDefaultsChain = buildChain({ selectResult: [] });
const feedbackInsertChain = buildChain();
db.__setImplementations(
feedbackProbe, devtoolsChain, imageSecurityChain, brandingChain, slugChain,
insertChain, feedbackDefaultsChain, feedbackInsertChain,
);
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
expect(db).toHaveBeenNthCalledWith(8, 'event_feedback_settings');
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
});
it('does NOT create a feedback row when global setting is unset and body omits feedback_enabled', async () => {
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 52 }] });
db.__setImplementations(...baseSettingsChains(), slugChain, insertChain);
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
// 6 db() calls: feedback + devtools + image-security + branding probes,
// slug, insert. event_feedback_settings is never touched.
expect(db).toHaveBeenCalledTimes(6);
expect(db).not.toHaveBeenCalledWith('event_feedback_settings');
});
it('rejects non-boolean feedback_enabled with 400', async () => {
// Validators run before any db() call, so no chain queueing needed.
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
.expect(400);
});
it('rejects an event_type unknown to the catalog with 400 (#800)', async () => {
isValidEventType.mockResolvedValueOnce(false);
const res = await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, event_type: 'nope' })
.expect(400);
expect(isValidEventType).toHaveBeenCalledWith('nope');
expect(JSON.stringify(res.body.errors)).toContain('event_type');
expect(db).not.toHaveBeenCalled();
});
it('inherits global feedback and preserves explicit overrides for every entry point', async () => {
await db('app_settings').insert({ setting_key: 'event_default_feedback_enabled', setting_value: 'true', setting_type: 'boolean' })
.onConflict('setting_key').merge({ setting_value: 'true' });
for (const source of ['admin', 'v1', 'legacy']) {
const inherited = await create(source, {});
expect(await db('event_feedback_settings').where({ event_id: inherited.id }).first()).toBeTruthy();
const override = await create(source, { feedback_enabled: false });
expect(await db('event_feedback_settings').where({ event_id: override.id }).first()).toBeUndefined();
}
});
it('queues a publication email only for published galleries', async () => {
const draft = await create('admin', { is_draft: true });
expect(await db('email_queue').where({ event_id: draft.id })).toHaveLength(0);
const published = await create('v1', {});
expect(await db('email_queue').where({ event_id: published.id, email_type: 'gallery_created' })).toHaveLength(1);
});
it('rejects a required password that is missing with 400 on both routes', async () => {
for (const source of ['admin', 'v1']) {
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send({ ...base, require_password: true });
expect(response.status).toBe(400);
}
});
it('keeps accepting "0"/"1" string booleans on the v1 surface', async () => {
const created = await create('v1', { require_password: '0', feedback_enabled: '1' });
const row = await db('events').where({ id: created.id }).first();
expect([false, 0]).toContain(row.require_password);
expect(await db('event_feedback_settings').where({ event_id: row.id }).first()).toBeTruthy();
});
it.each([{ feedback_enabled: 'maybe' }, { event_type: 'unknown' }])('rejects invalid creation data before persistence: %j', async extra => {
for (const source of ['admin', 'v1']) {
const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events')
.set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send({ ...base, ...extra });
expect(response.status).toBe(400);
}
});
+7 -259
View File
@@ -30,15 +30,13 @@ const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ow
// requirePermission gates supply the missing half; they key on req.admin.id,
// which apiTokenAuth populates.
const { requirePermission } = require('../../middleware/permissions');
const { resolveEventFeedbackDefaults } = require('../../services/feedbackDefaults');
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { generateThumbnail } = require('../../services/imageProcessor');
const logger = require('../../utils/logger');
const { slugify } = require('../../utils/slug');
const { formatBoolean } = require('../../utils/dbCompat');
const { parseBooleanInput } = require('../../utils/parsers');
const { getImageSecurityDefaults, resolveImageSecurityColumns, decodeSettingValue } = require('../adminEvents/helpers');
const { isValidEventType } = require('../../services/eventTypeService');
const { replacePhoto } = require('../../services/photoReplacementService');
const { getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings');
@@ -195,262 +193,12 @@ router.post(
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const {
event_name, event_type, event_date,
customer_name = null, customer_email = null, customer_phone = null,
admin_email = null,
require_password: requirePasswordInput,
password,
expires_at = null,
color_theme = null,
feedback_enabled: feedbackEnabledInput,
enable_devtools_protection: devtoolsInput,
hero_logo_visible: heroLogoVisibleInput,
hero_logo_size: heroLogoSizeInput,
hero_logo_position: heroLogoPositionInput
} = req.body;
// Issue #550 — mirror the admin POST path so API-created events
// pick up the global "Enable Guest Feedback by default" toggle
// (event_default_feedback_enabled). Without this, the UI reads
// a missing event_feedback_settings row as "feedback off"
// regardless of the admin's chosen default.
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'event_default_feedback_enabled').first();
if (setting) {
try {
const parsed = JSON.parse(setting.setting_value);
if (typeof parsed === 'boolean') feedbackEnabledFallback = parsed;
} catch { /* keep false */ }
}
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
// Issue #592 — same shape as the feedback fallback above. The
// events table column default is `true`, so without this an admin
// who disabled devtools detection globally still gets it ON for
// every API-created gallery. Mirrors adminEvents.js behaviour.
let devtoolsFallback = true;
if (devtoolsInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'enable_devtools_protection').first();
if (setting) {
// Shared decoder: a legacy row can carry several layers of JSON
// quoting, and a single parse would leave the string 'false' here,
// reject it, and quietly enable protection the operator disabled.
const parsed = decodeSettingValue(setting.setting_value);
if (typeof parsed === 'boolean') devtoolsFallback = parsed;
}
}
const enable_devtools_protection = parseBooleanInput(devtoolsInput, devtoolsFallback);
// #1296 — same shape again, for the four Image Security settings that
// were stored and applied nowhere. Shared with the admin create route
// so a gallery's security level does not depend on which endpoint made
// it; #592 above is the bug this would otherwise repeat.
const imageSecurityColumns = resolveImageSecurityColumns(
req.body,
await getImageSecurityDefaults(),
);
// Same shape as the feedback / devtools fallbacks: honour the global
// event_default_require_password toggle (#317). Without this an admin
// who disabled "require password by default" globally still got
// password-required galleries through the API.
let requirePasswordFallback = true;
if (requirePasswordInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'event_default_require_password').first();
if (setting) {
try {
const parsed = JSON.parse(setting.setting_value);
if (typeof parsed === 'boolean') requirePasswordFallback = parsed;
} catch { /* keep true */ }
}
}
const require_password = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
// Branding inheritance (Feature 7) — mirror adminEvents.js
// getBrandingDefaults so API-created events inherit the global
// hero logo visibility + size. hero_logo_position is intentionally
// NOT settings-backed (see migration 084 / #357 — branding_logo_position
// is the *header bar*, a different concept than the hero block).
let heroLogoVisibleFallback = true;
let heroLogoSizeFallback = 'medium';
const brandingRows = await db('app_settings')
.whereIn('setting_key', ['branding_logo_display_hero', 'branding_logo_size'])
.select('setting_key', 'setting_value');
for (const row of brandingRows) {
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
if (row.setting_key === 'branding_logo_display_hero') heroLogoVisibleFallback = value !== false;
if (row.setting_key === 'branding_logo_size' && value) heroLogoSizeFallback = value;
}
const hero_logo_visible = heroLogoVisibleInput !== undefined ? heroLogoVisibleInput : heroLogoVisibleFallback;
const hero_logo_size = heroLogoSizeInput || heroLogoSizeFallback;
const hero_logo_position = heroLogoPositionInput || 'top';
if (require_password && (!password || password.length < 6)) {
return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' });
}
// Honour global phone-field toggle (#322).
let persistPhone = null;
if (customer_phone) {
const setting = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
const enabled = setting ? JSON.parse(setting.setting_value) === true : false;
persistPhone = enabled ? customer_phone : null;
}
// Generate unique slug.
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date || crypto.randomBytes(3).toString('hex')}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) slug = `${baseSlug}-${counter++}`;
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// password_hash is NOT NULL; use a random placeholder when no
// password is required so the column constraint is satisfied.
const bcrypt = require('bcrypt');
const passwordHash = require_password
? await bcrypt.hash(password, 10)
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10);
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
event_date: event_date || null,
host_name: customer_name,
host_email: customer_email,
admin_email,
password_hash: passwordHash,
// #1271 — recoverable copy rides with the hash; only when there is one
...(require_password && password ? await galleryPasswordColumns({ password }) : {}),
require_password,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at || null,
created_at: new Date().toISOString(),
created_by: req.admin.id,
is_draft: false,
// Issue #550 — without this, editing an API-created event in the
// admin UI snaps the theme picker to GALLERY_THEME_PRESETS.default
// and saving overwrites whatever theme was inherited visually.
color_theme,
// Issue #592 — write the resolved devtools setting (input value
// or global fallback) so the column default doesn't shadow it.
enable_devtools_protection: formatBoolean(enable_devtools_protection),
// Request value, else the global default, else the column default.
...imageSecurityColumns,
// Branding inheritance — resolved value from body or app_settings.
hero_logo_visible: formatBoolean(hero_logo_visible),
hero_logo_size,
hero_logo_position,
...(customer_name ? { customer_name } : {}),
...(customer_email ? { customer_email } : {}),
...(persistPhone ? { customer_phone: persistPhone } : {})
}).returning('id');
const id = insertResult[0]?.id || insertResult[0];
if (require_password && password) await dropCopiesIfStorageOff(id);
// Issue #550 — mirror adminEvents.js: create event_feedback_settings
// row when feedback is enabled, so the gallery actually shows feedback
// UI. The sub-flags come from the shared global defaults (#1044) rather
// than a hard-coded list, which is how this path silently shipped
// without allow_reactions for two releases.
if (feedback_enabled) {
const feedbackDefaults = await resolveEventFeedbackDefaults();
await db('event_feedback_settings').insert({
event_id: id,
feedback_enabled: formatBoolean(true),
allow_ratings: formatBoolean(feedbackDefaults.allow_ratings),
allow_likes: formatBoolean(feedbackDefaults.allow_likes),
allow_comments: formatBoolean(feedbackDefaults.allow_comments),
allow_favorites: formatBoolean(feedbackDefaults.allow_favorites),
allow_reactions: formatBoolean(feedbackDefaults.allow_reactions),
allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels),
keybind_mode: feedbackDefaults.keybind_mode,
require_name_email: formatBoolean(false),
moderate_comments: formatBoolean(true),
show_feedback_to_guests: formatBoolean(true),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
await logActivity('event_created', { via: 'api_v1', event_type }, id, {
type: 'admin', id: req.admin.id, name: req.admin.username
const created = await require('../../services/eventCreationService').createEvent(req.body, {
actor: req.admin, source: 'v1',
});
// Customer notifications (#647 follow-up). v1 events go live in the
// same call (not draft-aware), so the gallery_created email + WhatsApp
// fire here — mirroring the adminEvents.js create-and-publish path.
// Both are best-effort: a queue failure must not block the API response.
const expiryIso = expires_at ? new Date(expires_at).toISOString() : null;
if (customer_email) {
try {
const { queueEmail } = require('../../services/emailProcessor');
await queueEmail(id, customer_email, 'gallery_created', {
customer_name: customer_name || '',
customer_email,
host_name: customer_name || '',
event_name,
event_date: event_date || null,
gallery_link: shareUrl,
gallery_password: require_password ? password : 'No password required',
expiry_date: expiryIso,
welcome_message: ''
});
} catch (emailError) {
logger.warn('v1 POST /events: failed to queue gallery_created email', { error: emailError.message });
}
}
if (persistPhone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
await queueWhatsapp(id, persistPhone, 'gallery_created', {
customer_name: customer_name || '',
event_name,
gallery_link: shareUrl,
gallery_password: require_password ? password : '',
expiry_date: expiryIso,
language: null,
});
}
} catch (waError) {
logger.warn('v1 POST /events: failed to queue WhatsApp notification', { error: waError.message });
}
}
// Webhook lifecycle (#327). v1 events are not draft-aware, so they're
// both created AND published in the same call. Canonical event
// subject (#341) — customer contact + share_token always included.
try {
const webhookService = require('../../services/webhookService');
const eventSubject = webhookService.buildEventSubject({
id,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name,
customer_email,
customer_phone,
});
await webhookService.fire('event.created', { event: eventSubject });
await webhookService.fire('event.published', { event: eventSubject });
} catch (e) { /* non-fatal */ }
res.status(201).json({ id, slug, share_url: shareUrl, share_token: shareToken });
res.status(201).json({ id: created.id, slug: created.slug, share_url: created.share_link, share_token: created.share_token });
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json(error.responseBody || { error: error.message, code: error.code });
logger.error('v1 POST /events failed', { error: error.message, stack: error.stack });
res.status(500).json({ error: 'Failed to create event', detail: error.message });
}
+5 -6
View File
@@ -338,14 +338,13 @@ async function cleanupExpiredUploads() {
return expiredIds.length;
}
// Run cleanup every hour. unref so this module-level housekeeping timer
// never holds the process open on its own — in production the HTTP
// listener keeps the loop alive, and in Jest this exact handle kept the
// runner from exiting for every suite that requires adminPhotos (#908;
// it is why adminPhotos.reference sits on the CI ignore list).
setInterval(cleanupExpiredUploads, 60 * 60 * 1000).unref();
const cleanupTask = require('./scheduledTask').scheduledTask(cleanupExpiredUploads, {
interval: 60 * 60 * 1000
});
cleanupTask.start();
module.exports = {
stop: () => cleanupTask.stop(),
initializeUpload,
uploadChunk,
completeUpload,
@@ -12,21 +12,13 @@
* schedulers don't all wake at once.
*/
const cron = require('node-cron');
const { scheduledTask } = require('./scheduledTask');
const logger = require('../utils/logger');
const downloadJobService = require('./downloadJobService');
function startDownloadJobCleanup() {
// A restart leaves any in-flight build with no worker. Fail those rows once
// at startup so their owners get a clear error instead of polling forever.
downloadJobService.recoverOrphanedJobs().catch((err) =>
logger.error('Download job recovery failed', { error: err.message }));
cron.schedule('7,27,47 * * * *', async () => {
await runDownloadJobCleanup();
});
logger.info('Download job cleanup scheduler started');
}
const task = scheduledTask(runDownloadJobCleanup, { schedule: '7,27,47 * * * *' });
function startDownloadJobCleanup() { task.start(); }
const stopDownloadJobCleanup = () => task.stop();
async function runDownloadJobCleanup() {
try {
@@ -37,6 +29,7 @@ async function runDownloadJobCleanup() {
}
module.exports = {
stopDownloadJobCleanup,
startDownloadJobCleanup,
// exported for tests / manual invocation
runDownloadJobCleanup,
@@ -37,6 +37,13 @@ class DownloadZipService {
this.versions = new Map(); // eventId -> generation counter
}
async stop() {
for (const timer of this.debounceTimers.values()) clearTimeout(timer);
this.debounceTimers.clear();
await Promise.allSettled([...this.activeBuilds.values()].map(build => build.promise));
this.versions.clear();
}
/**
* Relative storage key for the cached zip.
*/
+4 -7
View File
@@ -532,11 +532,8 @@ async function pollOnce() {
}
/** Start the 1-minute poll loop (mirrors the outgoing queue cadence). */
function startIncomingMailPoller() {
const run = () => pollOnce().catch((e) => logger.error?.(`emailIntake: ${e.message}`));
setTimeout(run, 15000); // first run shortly after boot
setInterval(run, 60 * 1000);
logger.info?.('Incoming-mail poller started (every 60s when enabled)');
}
const mailPoller = require('./scheduledTask').scheduledTask(pollOnce, { interval: 60000, initialDelay: 15000 });
function startIncomingMailPoller() { mailPoller.start(); }
const stopIncomingMailPoller = () => mailPoller.stop();
module.exports = { pollOnce, startIncomingMailPoller, listFolders, testConnection, roundTripTest, _internal: { getImapConfig, isEnabled, saveAttachment } };
module.exports = { stopIncomingMailPoller, pollOnce, startIncomingMailPoller, listFolders, testConnection, roundTripTest, _internal: { getImapConfig, isEnabled, saveAttachment } };
+5 -31
View File
@@ -1514,39 +1514,13 @@ async function testEmailConnection() {
}
}
// Start email queue processor
let emailQueueInterval = null;
const emailTask = require('./scheduledTask').scheduledTask(processEmailQueue, { interval: 60000, initialDelay: 0 });
function startEmailQueueProcessor() {
logger.info('Email queue processor: Attempting to start...');
if (!emailQueueInterval) {
// Process immediately on start
processEmailQueue().catch(err => {
logger.error('Email queue processor: Initial processing failed:', err);
});
// Then process every minute
emailQueueInterval = setInterval(() => {
processEmailQueue().catch(err => {
logger.error('Email queue processor: Periodic processing failed:', err);
});
}, 60000);
processorStatus.started = true;
logger.info('Email queue processor started successfully');
} else {
logger.info('Email queue processor: Already running');
}
emailTask.start(); processorStatus.started = true;
}
function stopEmailQueueProcessor() {
if (emailQueueInterval) {
clearInterval(emailQueueInterval);
emailQueueInterval = null;
processorStatus.started = false;
logger.info('Email queue processor stopped');
}
async function stopEmailQueueProcessor() {
await emailTask.stop(); processorStatus.started = false;
transporter?.close?.(); transporter = null;
}
// Initialize on module load - DISABLED for production startup
@@ -25,6 +25,7 @@ const fs = require('fs').promises;
const axios = require('axios');
const logger = require('../utils/logger');
const { signPayload } = require('./webhookService');
const { pinnedRequestOptions } = require('../utils/pinnedRequest');
const { validateExternalUrlAsync } = require('../utils/networkValidation');
const SIGNATURE_HEADER = 'X-PicPeak-Signature';
@@ -167,6 +168,7 @@ async function send(mail) {
// Vetted before every send, not once at startup: DNS answers change, and the
// check is what stops an operator-supplied URL becoming a request to link
// local metadata or a service on the host network.
let connectionOptions = {};
if (!allowPrivateUrls) {
// https for anything leaving the machine. The HMAC proves who sent the
// body, not who can read it — and these bodies carry password-reset links
@@ -190,6 +192,7 @@ async function send(mail) {
+ 'private network (a container or LAN address).'
);
}
connectionOptions = pinnedRequestOptions(check);
}
const payload = {
@@ -218,6 +221,7 @@ async function send(mail) {
let response;
try {
response = await axios.post(url, rawBody, {
...connectionOptions,
headers: {
'Content-Type': 'application/json',
[SIGNATURE_HEADER]: signature,
@@ -0,0 +1,615 @@
const { db, logActivity } = require('../database/db');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const fs = require('fs').promises;
const path = require('path');
const logger = require('../utils/logger');
const { AppError } = require('../utils/errors');
const { formatBoolean } = require('../utils/dbCompat');
const { slugify } = require('../utils/slug');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('./shareLinkService');
const { parseBooleanInput } = require('../utils/parsers');
const { normaliseEventTimeTriple } = require('./eventService');
const { hasColumnCached } = require('../utils/schemaCache');
const { getAppSetting } = require('../utils/appSettings');
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../utils/galleryPasswordVault');
const { clampIntOrUndefined } = require('../utils/numericHelpers');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const { resolveEventFeedbackDefaults, applyFeedbackDefaults } = require('./feedbackDefaults');
const { getStoragePath, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults,
getImageSecurityDefaults, resolveImageSecurityColumns, getBrandingDefaults, getCustomerNameFromPayload,
getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, hasCustomerContactColumns,
SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./eventSettings');
const { validateCreationInput } = require('./eventCreationValidation');
function creationError(body) {
const error = new AppError(body.error || 'Invalid event', 400, 'EVENT_INVALID');
error.responseBody = body;
return error;
}
/** Shared creation operation. v1 explicitly publishes immediately and accepts
* an optional absolute expiry; admin/legacy use configured field requirements.
*/
async function createEvent(data, { actor, source = 'admin', frontendUrl } = {}) {
const input = await validateCreationInput(data);
if (source === 'v1') input.is_draft = false;
if (!actor || !Number.isInteger(actor.id)) throw new AppError('Event owner required', 400, 'EVENT_OWNER_REQUIRED');
// Get field requirements from settings
const fieldRequirements = source === 'v1'
? { require_expiration: false } : await getEventFieldRequirements();
const {
event_type,
event_name,
event_date,
// Migration 137 — calendar time fields. is_full_day defaults to
// true at the service layer when undefined (legacy form payloads).
event_time_start,
event_time_end,
is_full_day,
admin_email,
password,
welcome_message = '',
color_theme = null,
expiration_days = 30,
allow_user_uploads = false,
upload_category_id = null,
allow_downloads = true,
disable_right_click = false,
enable_devtools_protection: enableDevtoolsProtectionInput,
watermark_downloads = false,
watermark_text = null,
require_password: requirePasswordInput,
// Feedback settings. The allow_* sub-toggles deliberately have NO
// destructuring defaults: `undefined` means "the caller didn't say",
// which inherits the global Settings > Events default (#1044). The
// admin create form posts explicit values (it seeds its own panel
// from the same globals), so inheritance here is what covers the v1
// API and any other caller that omits them.
feedback_enabled: feedbackEnabledInput,
allow_ratings: allowRatingsInput,
allow_likes: allowLikesInput,
allow_comments: allowCommentsInput,
allow_favorites: allowFavoritesInput,
allow_reactions: allowReactionsInput,
allow_color_labels: allowColorLabelsInput,
keybind_mode: keybindModeInput,
require_name_email = false,
moderate_comments = true,
show_feedback_to_guests = true,
// The create form has always shown the identity-mode chooser and this
// route has never read it, so a gallery created as 'guest' quietly came
// out 'simple' and the photographer had to set it again on the event.
// Surfaced by adding a third mode (#1197); the fix is the same for all
// three. Unknown values fall back rather than reaching the column,
// which on Postgres is guarded by a CHECK constraint.
identity_mode: identityModeInput,
// CSS Template
css_template_id = null,
// Hero logo settings
hero_logo_visible = true,
// Header style settings
header_style = 'standard',
hero_divider_style = 'wave',
// Hero image anchor position (#162)
hero_image_anchor = 'center',
// Photo cap
photo_cap = null,
// Client access settings (#172)
client_access_enabled = false,
client_password = null,
// Draft mode
is_draft = source !== 'v1',
// Default photo sort
default_photo_sort = 'upload_date_desc',
// Banner overrides (#440 / #932) — see the insert below.
promo_mode = 'inherit',
promo_markdown = null,
info_mode = 'inherit',
info_markdown = null
} = input;
const customerName = getCustomerNameFromPayload(input);
const customerEmail = getCustomerEmailFromPayload(input);
// Phone field is opt-in via the global setting (#322). If disabled,
// ignore whatever the client posted — defence in depth against form
// bypass.
const phoneEnabled = await isPhoneFieldEnabled();
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(input) : null;
const customerColumnsAvailable = await hasCustomerContactColumns();
// Conditional validation based on settings
const validationErrors = [];
if (fieldRequirements.require_customer_name && !customerName) {
validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' });
}
if (fieldRequirements.require_customer_email && !customerEmail) {
validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' });
}
if (fieldRequirements.require_admin_email && !admin_email) {
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
}
if (fieldRequirements.require_event_date && !event_date) {
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
}
if (validationErrors.length > 0) {
throw creationError({ errors: validationErrors });
}
// Default require_password from global "event_default_require_password"
// setting when the body omits it (#317 — admins want to flip the default).
let requirePasswordFallback = true;
if (requirePasswordInput === undefined) {
const setting = await readBooleanSetting('event_default_require_password');
if (setting !== undefined) requirePasswordFallback = setting;
}
const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
// Default feedback_enabled from global "event_default_feedback_enabled"
// setting when the body omits it (#520 — same pattern as require_password
// above, lets admins make Guest Feedback ON the out-of-box default for
// new events instead of toggling it on every time).
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await readBooleanSetting('event_default_feedback_enabled');
if (setting !== undefined) feedbackEnabledFallback = setting;
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
// Sub-toggle defaults from the global Settings > Events values (#1044).
// One batched read; an explicitly-sent body value still wins.
const feedbackDefaults = applyFeedbackDefaults({
allow_ratings: allowRatingsInput,
allow_likes: allowLikesInput,
allow_comments: allowCommentsInput,
allow_favorites: allowFavoritesInput,
allow_reactions: allowReactionsInput,
allow_color_labels: allowColorLabelsInput,
keybind_mode: keybindModeInput,
}, await resolveEventFeedbackDefaults());
let passwordValidation = null;
if (requirePassword) {
// The v1 route validator marks password optional; the admin route's
// custom() guard is not shared, so enforce presence here for every path.
if (typeof password !== 'string' || password.length === 0) {
throw creationError({ error: 'Password is required when require_password is true' });
}
passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
throw creationError({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
}
// Generate unique slug. Uses the shared util so accented names
// (Família, Decoração, etc.) get transliterated instead of dropped
// — see backend/src/utils/slug.js for the why (#525).
const processedEventName = slugify(event_name);
// Use event_date in slug if provided, otherwise use random suffix
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds (random placeholder when not required)
const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date)
// If expiration is not required, expires_at will be null (never expires)
// If event_date is not provided, use current date as base for expiration
let expires_at = input.expires_at ? new Date(input.expires_at) : null;
if (!expires_at && fieldRequirements.require_expiration) {
const baseDate = event_date || new Date().toISOString().split('T')[0];
// Parse YYYY-MM-DD format as local date to avoid timezone issues
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(baseDate);
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
}
// Create folder structure
const storagePath = getStoragePath();
const eventPath = path.join(storagePath, 'events/active', slug);
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158).
let effectiveHeaderStyle = header_style;
let effectiveDividerStyle = hero_divider_style;
if (color_theme && (!input.header_style || !input.hero_divider_style)) {
try {
if (typeof color_theme === 'string' && color_theme.startsWith('{')) {
const parsed = JSON.parse(color_theme);
if (!input.header_style && parsed.headerStyle) {
effectiveHeaderStyle = parsed.headerStyle;
}
if (!input.hero_divider_style && parsed.heroDividerStyle) {
effectiveDividerStyle = parsed.heroDividerStyle;
}
}
} catch (_) {
// color_theme is not JSON nothing to extract
}
}
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
const brandingDefaults = await getBrandingDefaults();
// hero_logo_visible: store NULL ("inherit") unless the admin explicitly
// set it, so the global branding_logo_display_hero toggle keeps
// controlling this gallery afterwards (#756). Only an explicit per-event
// choice overrides the global. `!= null` treats an explicit null the same
// as omitted (both → inherit); otherwise formatBoolean(null) would coerce
// to 0/false on SQLite instead of NULL (the PUT handler already does this).
const effectiveHeroLogoVisible = input.hero_logo_visible != null
? formatBoolean(hero_logo_visible)
: null;
// NULL = inherit the global branding_logo_size (#756), resolved at read
// time. Only an explicit per-event size overrides it.
const effectiveHeroLogoSize = input.hero_logo_size || null;
const effectiveHeroLogoPosition = input.hero_logo_position || brandingDefaults.hero_logo_position;
// Inherit "Detect dev tools" from the global Image Security setting unless
// the request explicitly overrides it (#317 — admin disabled it globally
// but new events still got it ON because the column default is true).
const protectionDefaults = await getDownloadProtectionDefaults();
// #1296 — the other four Image-security settings, which were written,
// rendered as controls, and read by nothing. Same inheritance rule as
// the devtools setting below. Creation-time only; see
// getImageSecurityDefaults for why existing events are left alone.
const imageSecurityColumns = resolveImageSecurityColumns(
input,
await getImageSecurityDefaults(),
);
const effectiveEnableDevtoolsProtection =
enableDevtoolsProtectionInput !== undefined
? enableDevtoolsProtectionInput
: protectionDefaults.enable_devtools_protection !== undefined
? protectionDefaults.enable_devtools_protection
: true;
// Migration 137 — normalise calendar time triple. Throws AppError
// 400 when is_full_day=false but times are malformed/inverted.
const calendarTriple = normaliseEventTimeTriple({
event_time_start, event_time_end, is_full_day,
});
const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
// Insert into database
// Seed the new event's Live Slideshow display style from the PICPEAK-WIDE
// preset (app_settings, Settings → Slideshow). New events inherit it and the
// admin can still override per event. Watermark is left NULL = inherit the
// global watermark; the share token is minted on demand, not seeded. Guarded
// so un-migrated installs (mid-branch) don't reference missing columns.
let slideshowSeed = {};
if (await hasColumnCached('events', 'show_interval_ms')) {
try {
// parseInt-first: the previous `Number.isFinite(+v)` pre-check let
// NaN through for null/''/true (+null is 0, parseInt(null) is NaN),
// producing show_interval_ms=NaN in the INSERT — PG rejects that
// with "invalid input syntax for type integer" while SQLite
// silently stores NULL, so event creation 500'd on PG whenever the
// slideshow app_settings rows were absent.
const intP = (v, min, max) => clampIntOrUndefined(v, min, max);
const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined);
const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000);
const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS);
const tms = intP(await getAppSetting('slideshow_transition_ms', undefined), 100, 5000);
const cf = oneOf(await getAppSetting('slideshow_colorfilter', undefined), SLIDESHOW_COLORFILTERS);
if (i !== undefined) slideshowSeed.show_interval_ms = i;
if (tr) slideshowSeed.show_transition = tr;
if (tms !== undefined) slideshowSeed.show_transition_ms = tms;
if (cf) slideshowSeed.show_colorfilter = cf;
} catch (e) {
logger.warn('Failed to seed slideshow settings from global preset', { error: e.message });
}
}
const insertData = {
slug,
event_type,
event_name,
...slideshowSeed,
event_date: event_date || null,
...(calendarColumnsExist ? {
event_time_start: calendarTriple.event_time_start,
event_time_end: calendarTriple.event_time_end,
is_full_day: formatBoolean(calendarTriple.is_full_day),
} : {}),
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName || null,
host_email: customerEmail || null,
admin_email: admin_email || null,
password_hash,
// Opt-in recoverable copy (#1271), written with the hash so the two
// can never disagree. Empty unless the security setting is on.
...(await galleryPasswordColumns({
...(requirePassword && password ? { password } : {}),
...(client_access_enabled && client_password ? { clientPassword: client_password } : {}),
})),
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
created_by: actor.id,
allow_user_uploads: formatBoolean(allow_user_uploads),
upload_category_id,
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection),
// Request value, else the global default, else the column default —
// a key absent here is one the database fills in (#1296).
...imageSecurityColumns,
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text,
require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null,
// Already formatBoolean-coerced above, or null = inherit global (#756).
hero_logo_visible: effectiveHeroLogoVisible,
hero_logo_size: effectiveHeroLogoSize,
hero_logo_position: effectiveHeroLogoPosition,
// Banner overrides. Both were accepted by the validators above and
// then dropped here, so an API client could POST info_mode:'off' or a
// custom banner, get 201, and find the row still on 'inherit'.
// Markdown is only stored for 'custom' — same rule the PUT applies.
promo_mode: ['inherit', 'custom', 'off'].includes(promo_mode) ? promo_mode : 'inherit',
promo_markdown: promo_mode === 'custom' && typeof promo_markdown === 'string' && promo_markdown.trim()
? promo_markdown.trim() : null,
info_mode: ['inherit', 'custom', 'off'].includes(info_mode) ? info_mode : 'inherit',
info_markdown: info_mode === 'custom' && typeof info_markdown === 'string' && info_markdown.trim()
? info_markdown.trim() : null,
header_style: effectiveHeaderStyle || 'standard',
hero_divider_style: effectiveDividerStyle || 'wave',
hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null,
is_draft: formatBoolean(parseBooleanInput(is_draft, true)),
default_photo_sort: default_photo_sort || 'upload_date_desc',
// Client access (#172)
client_access_enabled: formatBoolean(client_access_enabled),
...(client_access_enabled && client_password ? {
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
client_share_token: crypto.randomBytes(32).toString('hex')
} : {}),
// Per-event opt-in for hero-photo OG share image (#474). Defaults
// false on create — admin opts in from the event detail page once
// they've picked a hero they're comfortable surfacing publicly.
og_image_share_enabled: formatBoolean(input.og_image_share_enabled === true),
};
// The gallery row and its feedback configuration commit together.
const eventId = await db.transaction(async trx => {
const result = await trx('events').insert(insertData).returning('id');
const eventId = result[0]?.id ?? result[0];
// Insert feedback settings if feedback is enabled
if (feedback_enabled) {
await trx('event_feedback_settings').insert({
event_id: eventId,
feedback_enabled: formatBoolean(feedback_enabled),
allow_ratings: formatBoolean(feedbackDefaults.allow_ratings),
allow_likes: formatBoolean(feedbackDefaults.allow_likes),
allow_comments: formatBoolean(feedbackDefaults.allow_comments),
allow_favorites: formatBoolean(feedbackDefaults.allow_favorites),
allow_reactions: formatBoolean(feedbackDefaults.allow_reactions),
allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels),
keybind_mode: feedbackDefaults.keybind_mode,
require_name_email: formatBoolean(require_name_email),
moderate_comments: formatBoolean(moderate_comments),
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
identity_mode: ['simple', 'guest', 'shared'].includes(identityModeInput)
? identityModeInput
: 'simple',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
return eventId;
});
// #1271 — the setting was read before the hashes; re-check after the write
await dropCopiesIfStorageOff(eventId);
// Apply customer-account assignments (#354). Skip when the customer
// portal flag is off — the frontend hides the picker in that case,
// but a stale tab could still POST customer_account_ids; we ignore
// them rather than 403 the entire create.
if (Array.isArray(input.customer_account_ids)) {
try {
const customerAccountsService = require('./customerAccountsService');
if (await customerAccountsService.isCustomerPortalEnabled()) {
await customerAccountsService.setAssignmentsForEvent(
eventId,
input.customer_account_ids,
actor.id
);
}
} catch (e) {
logger.error('Failed to set customer assignments on event create', {
eventId, error: e.message,
});
}
}
// Log activity
await logActivity('event_created',
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
eventId,
{ type: 'admin', id: actor.id, name: actor.username }
);
// Fire event.created webhook (#327). If the event is being published
// immediately (not a draft), event.published also fires below.
// Payload uses canonical event subject (#341) so receivers always see
// the same shape (id/slug/event_name + customer contact + share_*).
try {
const webhookService = require('./webhookService');
await webhookService.fire('event.created', {
event: {
...webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
}),
is_draft: parseBooleanInput(is_draft, true),
},
});
} catch (e) { /* webhookService.fire never throws but be defensive */ }
// Queue creation email (only if there is a recipient and event is not a draft)
// Language detection is handled by email processor
const isDraft = parseBooleanInput(is_draft, true);
if (customerEmail && !isDraft) {
// Build email data with optional client access info
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
};
// Include client access info in email when enabled (#172)
if (client_access_enabled && client_password) {
const createdEvent = await db('events').where('id', eventId).first();
// Same FRONTEND_URL-before-APP_URL order as before: APP_URL is
// passed as the override so it still outranks the general_site_url
// setting and the request origin. Chaining it after the resolver
// would make it dead code, because the resolver only returns falsy
// when NOTHING is configured (#1104).
const resolvedFrontendUrl = frontendUrl || await getFrontendBaseUrl();
emailData.client_link = `${resolvedFrontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
emailData.client_password = client_password;
}
// Best-effort, as the v1 route always was: the event, folder, activity
// log and webhook are committed by now, so a queue failure must not 500.
try {
await db('email_queue').insert({
event_id: eventId,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
// scheduled_at will use default value
});
} catch (queueError) {
logger.warn('Failed to queue gallery_created email on create', { eventId, error: queueError.message });
}
}
// WhatsApp gallery_ready notification (#640D). Fires when the event is
// created NOT as a draft, the `whatsapp` flag is on, a config exists, and
// the customer supplied a phone number. Non-fatal: a queue failure should
// never block gallery creation.
if (!isDraft && customerPhone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('./whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
customer_name: customerName || '',
event_name,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : '',
expiry_date: expires_at ? expires_at.toISOString() : null,
language: null, // resolved by processor via general_default_language
});
}
} catch (waError) {
logger.warn('Failed to queue WhatsApp notification on create', { error: waError.message });
}
}
// Fire event.published when the event is created NOT as a draft. The
// separate /publish endpoint fires it for the draft → live transition;
// this covers the "create-and-publish in one shot" path.
if (!isDraft) {
try {
const webhookService = require('./webhookService');
await webhookService.fire('event.published', {
event: webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
}),
});
} catch (e) { /* non-fatal */ }
}
if (!isDraft) {
await require('./workflows').emitWorkflowEvent('gallery.published', {
entityType: 'event', entityId: eventId,
payload: { eventId, slug, eventName: event_name, eventDate: event_date,
customerEmail, adminEmail: admin_email, galleryLink: shareUrl,
expiresAt: expires_at ? expires_at.toISOString() : null },
}).catch(error => logger.warn('Failed to emit gallery.published', { eventId, error: error.message }));
}
return {
id: eventId,
slug,
event_name,
event_type,
customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
photo_cap: photo_cap || null,
is_draft: isDraft,
share_link: shareUrl,
share_token: shareToken,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
};
}
module.exports = { createEvent };
@@ -0,0 +1,47 @@
const Joi = require('joi');
const eventTypes = require('./eventTypeService');
const { AppError } = require('../utils/errors');
const { normaliseEventTimeTriple } = require('./eventService');
const optionalText = Joi.string().allow('', null);
const schema = Joi.object({
event_name: Joi.string().trim().min(1).max(255).required(),
event_type: Joi.string().trim().min(1).max(255).required(),
event_date: Joi.string().isoDate().raw().allow('', null),
expires_at: Joi.string().isoDate().raw().allow('', null),
expiration_days: Joi.number().integer().min(1).max(365),
customer_email: Joi.string().email({ tlds: { allow: false } }).allow('', null),
admin_email: Joi.string().email({ tlds: { allow: false } }).allow('', null),
customer_name: optionalText,
customer_phone: optionalText.max(32),
password: Joi.string().max(1024).allow('', null),
client_password: Joi.string().max(1024).allow('', null),
color_theme: optionalText,
welcome_message: optionalText,
photo_cap: Joi.number().integer().min(1).allow(null),
image_quality: Joi.number().integer().min(1).max(100),
protection_level: Joi.string().valid('basic', 'standard', 'enhanced', 'maximum'),
hero_logo_size: Joi.string().valid('small', 'medium', 'large', 'xlarge').allow(null),
hero_logo_position: Joi.string().valid('top', 'center', 'bottom'),
customer_account_ids: Joi.array().items(Joi.number().integer().min(1)),
...Object.fromEntries(['is_draft', 'require_password', 'allow_downloads', 'allow_user_uploads',
'disable_right_click', 'watermark_downloads', 'enable_devtools_protection', 'use_canvas_rendering',
'feedback_enabled', 'allow_ratings', 'allow_likes', 'allow_comments', 'allow_favorites',
'allow_reactions', 'allow_color_labels', 'require_name_email', 'moderate_comments',
'show_feedback_to_guests', 'client_access_enabled', 'og_image_share_enabled']
.map(key => [key, Joi.boolean().truthy(1, '1').falsy(0, '0')])),
hero_logo_visible: Joi.boolean().truthy(1, '1').falsy(0, '0').allow(null),
}).unknown(true);
async function validateCreationInput(data) {
const { value, error } = schema.validate(data, { abortEarly: false });
if (error) {
const err = new AppError('Invalid event', 400, 'EVENT_INVALID');
// Never return Joi's submitted value/context: it can contain passwords.
err.responseBody = { errors: error.details.map(item => ({ path: item.path.join('.'), msg: item.message })) };
throw err;
}
if (!await eventTypes.isValidEventType(value.event_type)) throw new AppError('Invalid event type', 400, 'EVENT_TYPE_INVALID');
normaliseEventTimeTriple(value); // Reject before password hashing or filesystem writes.
return value;
}
module.exports = { validateCreationInput };
+7 -163
View File
@@ -10,11 +10,11 @@ const crypto = require('crypto');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const { hasColumnCached } = require('../utils/schemaCache');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('./shareLinkService');
const { getBcryptRounds } = require('../utils/passwordValidation');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('./eventTypeService');
const { AppError } = require('../utils/errors');
@@ -154,167 +154,11 @@ const createEventFolders = async (slug) => {
* @param {Object} eventData - Event data
* @returns {Promise<Object>} - Created event
*/
const createEvent = async (eventData) => {
const {
event_type,
event_name,
event_date,
customer_name,
customer_email,
admin_email,
password,
require_password = true,
welcome_message,
color_theme,
expiration_days = 30,
// Feedback settings
feedback_enabled,
allow_ratings,
allow_likes,
allow_comments,
allow_favorites,
require_name_email,
moderate_comments,
show_feedback_to_guests,
// Upload settings
allow_user_uploads,
upload_category_id,
// Photo cap
photo_cap,
// Migration 137 — calendar time fields. Defaults to full-day when
// the caller (legacy create-event form) doesn't know about them.
event_time_start,
event_time_end,
is_full_day
} = eventData;
const requirePassword = parseBooleanInput(require_password, true);
const customerColumnsAvailable = await hasCustomerContactColumns();
// Validate + normalise the calendar time triple up front so we throw
// before bcrypt + folder creation if the payload is bad.
const timeTriple = normaliseEventTimeTriple({
event_time_start, event_time_end, is_full_day,
const createEvent = async (eventData, options = {}) => {
return require('./eventCreationService').createEvent(eventData, {
...options,
actor: options.actor || (eventData.created_by ? { id: eventData.created_by } : undefined),
});
// Validate password if required
if (requirePassword) {
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
const error = new Error('Password does not meet security requirements');
error.code = 'PASSWORD_INVALID';
error.details = passwordValidation.errors;
error.score = passwordValidation.score;
error.feedback = passwordValidation.feedback;
throw error;
}
}
// Generate unique slug
const slug = await generateUniqueSlug(event_type, event_name, event_date);
// Generate share link
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password
const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date
const expires_at = new Date(event_date);
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Create folder structure
await createEventFolders(slug);
// Build insert data
const insertData = {
slug,
event_type,
event_name,
event_date,
...(customerColumnsAvailable ? { customer_name, customer_email } : {}),
host_name: customer_name,
host_email: customer_email,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at,
require_password: formatBoolean(requirePassword),
// Feedback settings
feedback_enabled: feedback_enabled !== undefined ? formatBoolean(feedback_enabled) : undefined,
allow_ratings: allow_ratings !== undefined ? formatBoolean(allow_ratings) : undefined,
allow_likes: allow_likes !== undefined ? formatBoolean(allow_likes) : undefined,
allow_comments: allow_comments !== undefined ? formatBoolean(allow_comments) : undefined,
allow_favorites: allow_favorites !== undefined ? formatBoolean(allow_favorites) : undefined,
require_name_email: require_name_email !== undefined ? formatBoolean(require_name_email) : undefined,
moderate_comments: moderate_comments !== undefined ? formatBoolean(moderate_comments) : undefined,
show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined,
// Upload settings
allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined,
upload_category_id: upload_category_id || null,
// Photo cap
photo_cap: photo_cap || null
};
// Migration 137 — calendar time fields. Guarded by hasColumnCached so
// installs that haven't applied 137 yet skip the columns silently
// (per feedback_schema_drift_guards.md / feedback_cache_hasColumn_lookups.md).
if (await hasColumnCached('events', 'is_full_day')) {
insertData.event_time_start = timeTriple.event_time_start;
insertData.event_time_end = timeTriple.event_time_end;
insertData.is_full_day = formatBoolean(timeTriple.is_full_day);
}
// Remove undefined values
Object.keys(insertData).forEach(key => {
if (insertData[key] === undefined) {
delete insertData[key];
}
});
// Insert into database
const insertResult = await db('events').insert(insertData).returning('id');
const eventId = insertResult[0]?.id || insertResult[0];
// Fire gallery.published — a gallery goes live the moment it's created (active
// + share link). Best-effort; emit is fail-closed when the workflows flag is
// off and never throws into the create path.
try {
await require('./workflows').emitWorkflowEvent('gallery.published', {
entityType: 'event',
entityId: eventId,
payload: {
eventId,
slug,
eventName: event_name,
eventDate: event_date,
customerEmail: customer_email || null,
adminEmail: admin_email || null,
galleryLink: shareUrl,
expiresAt: expires_at,
},
});
} catch (err) {
logger.warn('Failed to emit gallery.published workflow event', { eventId, error: err.message });
}
return {
id: eventId,
slug,
share_link: shareUrl,
expires_at,
require_password: requirePassword,
customer_name,
customer_email
};
};
/**
+403
View File
@@ -0,0 +1,403 @@
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { parseStringInput } = require('../utils/parsers');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (value) => {
if (['top', 'center', 'bottom'].includes(value)) return true;
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
}
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
};
// Get storage path from environment or default
const { getStoragePath } = require('../config/storage');
// Helper to get event field requirements from settings
const getEventFieldRequirements = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email',
'event_require_event_date',
'event_require_expiration'
])
.select('setting_key', 'setting_value');
const requirements = {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
value = value === 'true';
}
}
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
});
return requirements;
} catch (error) {
logger.error('Failed to get event field requirements', { error: error.message });
return {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
}
};
// Helper to read app_settings booleans by key, used to inherit per-setting
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
// so callers can fall back to a legacy default.
/**
* Decode an app_settings value into the JS value it represents.
*
* setting_value is JSON text on SQLite and may already be decoded by the
* driver on a PG json column, so one parse does not normalise both. On top
* of that, the Image Security tab used to PUT back values it had read
* undecoded, wrapping another layer of quoting around each one on every
* save the GET handler decodes now, but installs carry however many
* layers they accumulated before that.
*
* Every reader of app_settings has to agree about this, or the admin UI
* shows one thing while event creation does another.
*
* Terminates: each parse of a string is strictly shorter than its input.
*/
const decodeSettingValue = (raw) => {
let value = raw;
while (typeof value === 'string') {
let parsed;
try { parsed = JSON.parse(value); } catch { break; }
if (parsed === value) break;
value = parsed;
}
return value;
};
const readBooleanSetting = async (key) => {
try {
const setting = await db('app_settings').where('setting_key', key).first();
if (!setting) return undefined;
const value = decodeSettingValue(setting.setting_value);
return typeof value === 'boolean' ? value : undefined;
} catch (error) {
logger.error('Failed to read app setting', { key, error: error.message });
return undefined;
}
};
// Helper to read the global "enable_devtools_protection" admin setting so
// new events inherit it instead of always falling back to the DB column default
// (#317 — admin disabled it globally but new events still got it ON).
const getDownloadProtectionDefaults = async () => {
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
};
/**
* The rest of Settings Image security, as creation defaults (#1296).
*
* Four settings in that panel were written, reloaded and rendered as
* controls, and read by nothing:
*
* default_protection_level events.protection_level
* default_image_quality events.image_quality
* enable_canvas_rendering events.use_canvas_rendering
*
* Each maps onto a column migration 038 already created, and each is
* labelled "… by default", so applying them at creation is what the panel
* has always claimed to do. `enable_devtools_protection` above is the only
* one of the five that was ever wired.
*
* Creation-time only, deliberately. Applying them to EXISTING events would
* silently change live galleries on upgrade an install with
* enable_canvas_rendering already on would switch every grid to canvas
* rendering, which is memory-expensive at scale and is the profile under
* investigation in #1287. New events only; existing rows untouched.
*
* Any value that is missing or malformed comes back undefined so the caller
* falls through to the column default, exactly as before this existed.
*/
const PROTECTION_LEVELS = ['basic', 'standard', 'enhanced', 'maximum'];
// parseInt would rescue malformed settings instead of rejecting them:
// parseInt('72oops') is 72, parseInt(72.5) is 72, parseInt([72]) is 72.
// That matters because the settings PUT stores whatever JSON it is handed
// without validating the value (adminImageSecurity.js writes
// JSON.stringify(value) for any allow-listed key), so those shapes really
// can be sitting in app_settings. Accept only a genuine integer, or a
// string that is exactly one.
const toInteger = (value) => {
if (typeof value === 'number') return Number.isInteger(value) ? value : undefined;
if (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim())) return Number(value.trim());
return undefined;
};
const getImageSecurityDefaults = async (trx = null) => {
const defaults = {};
try {
// Accepts a transaction the way getAppSetting does. It matters on
// sqlite3, whose pool holds a single connection: a caller already inside
// db.transaction() that read through the global `db` would block on the
// connection its own transaction holds until the acquire timeout, and
// the catch below would then quietly swallow it and drop the defaults.
const query = trx || db;
const rows = await query('app_settings')
.whereIn('setting_key', [
'default_protection_level',
'default_image_quality',
'enable_canvas_rendering',
])
.select('setting_key', 'setting_value');
// app_settings holds JSON text on SQLite, while a PG json column comes
// back already decoded — so one parse is not enough to normalise both.
// Worse, GET /api/admin/image-security/settings returns setting_value
// without decoding it and the settings tab PUTs the whole fetched object
// straight back through JSON.stringify, so opening the tab and saving
// re-encodes every value it read as text. After one such round trip
// `true` is stored as "\"true\"" and a single parse yields the string
// 'true', which the type checks below reject — the settings would go
// quietly dead again, which is the bug this whole change exists to fix.
// The GET handler now decodes, so this stops accumulating — but installs
// that already stacked N layers have to keep working, and N is however
// many times someone opened that tab. So unwrap until it stops being a
// JSON string rather than to a fixed depth; this terminates because each
// parse of a string is strictly shorter than its input.
const read = (key) => {
const row = rows.find((r) => r.setting_key === key);
if (!row) return undefined;
return decodeSettingValue(row.setting_value);
};
const level = read('default_protection_level');
if (typeof level === 'string' && PROTECTION_LEVELS.includes(level)) {
defaults.protection_level = level;
}
// The column is an integer percentage; anything outside 1..100 is a
// misconfiguration and falls through rather than being clamped into
// something the operator did not choose.
const quality = toInteger(read('default_image_quality'));
if (quality !== undefined && quality >= 1 && quality <= 100) {
defaults.image_quality = quality;
}
const canvas = read('enable_canvas_rendering');
if (typeof canvas === 'boolean') {
defaults.use_canvas_rendering = canvas;
}
} catch (error) {
// A settings read must never block event creation; the column defaults
// are a correct fallback.
logger.error('Failed to read image-security defaults', { error: error.message });
}
return defaults;
};
/**
* Build the image-security columns for a NEW event: an explicit request
* value wins, then the global default, then the column default (the key is
* omitted entirely so the database supplies it).
*
* Shared by the admin create route and POST /api/v1/events so the configured
* security level cannot depend on which entry point created the gallery
* the same split that made #592 (devtools) a separate bug from #317.
*
* `body` values are already validated by the route's express-validator
* chain; `defaults` come from getImageSecurityDefaults(), which validates
* them itself.
*/
const resolveImageSecurityColumns = (body = {}, defaults = {}) => {
const { formatBoolean } = require('../utils/dbCompat');
const columns = {};
// express-validator runs isInt/isIn/isBoolean element-wise on arrays, so a
// single-element array like `image_quality: [72]` passes the route's chain
// and arrives here still an array. The routes reject those with
// .not().isArray(); this guard means any future caller cannot write one
// into a scalar column (a PG insert error, or `[false]` coerced to true).
const scalar = (v) => (v !== null && typeof v === 'object' ? undefined : v);
const pick = (key) => {
const fromBody = scalar(body[key]);
return fromBody !== undefined ? fromBody : defaults[key];
};
const level = pick('protection_level');
if (level !== undefined) columns.protection_level = level;
const quality = pick('image_quality');
if (quality !== undefined) columns.image_quality = quality;
const canvas = pick('use_canvas_rendering');
if (canvas !== undefined) columns.use_canvas_rendering = formatBoolean(canvas);
return columns;
};
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
//
// Note: `branding_logo_position` (header bar — left/center/right) is a
// different concept from `hero_logo_position` (hero block — top/center/
// bottom) and must NOT be mapped here. A previous version copied the
// branding value over, which wrote 'left'/'right' into per-event
// hero_logo_position columns and broke any subsequent PUT validation
// (#357). Migration 084 heals existing rows.
const getBrandingDefaults = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'branding_logo_display_hero',
'branding_logo_size'
])
.select('setting_key', 'setting_value');
const defaults = {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
}
if (s.setting_key === 'branding_logo_display_hero') {
defaults.hero_logo_visible = value !== false;
}
if (s.setting_key === 'branding_logo_size' && value) {
defaults.hero_logo_size = value;
}
});
return defaults;
} catch (error) {
logger.error('Failed to get branding defaults', { error: error.message });
return {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
}
};
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
// Whether the global "phone field" toggle (#322) is enabled. Cached for
// the request via a module-level read; drift is acceptable since this
// only governs whether to persist the field, not security boundaries.
const isPhoneFieldEnabled = async () => {
try {
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
if (!row) return false;
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return value === true;
} catch (error) {
logger.debug('Failed to read event_phone_field_enabled', { error: error.message });
return false;
}
};
const RECOVERABLE_PASSWORD_COLUMNS = ['password_recoverable', 'client_password_recoverable'];
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
customer_phone,
// Bound only to exclude the secrets from `...rest` — never read.
password_hash: _ph, client_password_hash: _cph,
...rest
} = event;
// #1271 — the encrypted copies never leave the server except via
// /:id/password. Removed by name (not destructured) so a secret scanner
// does not read the binding as a hard-coded password.
for (const column of RECOVERABLE_PASSWORD_COLUMNS) delete rest[column];
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null,
customer_phone: customer_phone ?? null
};
};
let customerColumnCache = null;
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
logger.debug('Failed to detect customer_email column', { error: error.message });
return false;
}
};
// Allowed slide transition styles (kept in sync with the SlideshowPage).
// dipwhite/dipblack = fade through highlights / lowlights between images.
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
// Allowed per-slide color filters.
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
// Allowed slideshow play orders (#202). 'chronological' = upload order,
// 'random' = client-side shuffle.
const SLIDESHOW_ORDERS = ['chronological', 'random'];
module.exports = {
RECOVERABLE_PASSWORD_COLUMNS,
validateHeroImageAnchor,
getStoragePath,
getEventFieldRequirements,
readBooleanSetting,
decodeSettingValue,
getDownloadProtectionDefaults,
getImageSecurityDefaults,
resolveImageSecurityColumns,
getBrandingDefaults,
getCustomerNameFromPayload,
getCustomerEmailFromPayload,
getCustomerPhoneFromPayload,
isPhoneFieldEnabled,
mapEventForApi,
hasCustomerContactColumns,
SLIDESHOW_ORDERS,
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
};
+5 -9
View File
@@ -1,4 +1,4 @@
const cron = require('node-cron');
const { scheduledTask } = require('./scheduledTask');
const { db } = require('../database/db');
const { archiveEvent } = require('./archiveService');
const { queueEmail, getSupportEmail } = require('./emailProcessor');
@@ -6,14 +6,9 @@ const { buildShareLinkVariants } = require('./shareLinkService');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
function startExpirationChecker() {
// Check every hour for expired events and warnings
cron.schedule('0 * * * *', async () => {
await checkExpirations();
});
logger.info('Expiration checker started');
}
const task = scheduledTask(checkExpirations, { schedule: '0 * * * *' });
function startExpirationChecker() { task.start(); }
const stopExpirationChecker = () => task.stop();
async function checkExpirations() {
try {
@@ -248,6 +243,7 @@ async function handleExpiredEvent(event, { sendLegacyEmails = true } = {}) {
}
module.exports = {
stopExpirationChecker,
startExpirationChecker,
// Reused by the workflow notify_gallery_* actions so the engine path sends the
// exact same emails as the legacy hourly checker.
+19 -4
View File
@@ -28,7 +28,21 @@ const watcherConcurrency = Number.isFinite(configuredConcurrency)
: 2;
const processLimit = pLimit(watcherConcurrency);
let watcher = null;
const pending = new Set();
const enqueue = (run) => {
const task = processLimit(run); pending.add(task);
task.finally(() => pending.delete(task)).catch(() => {});
return task;
};
async function stopFileWatcher() {
const closing = watcher; watcher = null;
if (closing) await closing.close();
await Promise.allSettled([...pending]);
}
function startFileWatcher() {
if (watcher) return watcher;
// Auto-import via filesystem watching only works with the local storage
// backend. In S3 mode there is no local directory to watch — every photo
// must enter through the admin upload API. Skip cleanly with a clear log
@@ -39,7 +53,7 @@ function startFileWatcher() {
return null;
}
const watcher = chokidar.watch(WATCH_PATH(), {
watcher = chokidar.watch(WATCH_PATH(), {
ignored: /(^|[/\\])\../, // ignore dotfiles
persistent: true,
awaitWriteFinish: {
@@ -50,17 +64,18 @@ function startFileWatcher() {
watcher
.on('add', (filePath) => {
processLimit(() => processNewPhoto(filePath)).catch((error) => {
enqueue(() => processNewPhoto(filePath)).catch((error) => {
logger.error('Error processing new photo:', error);
});
})
.on('unlink', (filePath) => {
processLimit(() => removePhoto(filePath)).catch((error) => {
enqueue(() => removePhoto(filePath)).catch((error) => {
logger.error('Error removing photo:', error);
});
});
logger.info('File watcher started');
return watcher;
}
/**
@@ -222,4 +237,4 @@ async function removePhoto(filePath) {
logger.info(`Removed photo: ${relativePath}`);
}
module.exports = { startFileWatcher, findExistingPhoto };
module.exports = { stopFileWatcher, startFileWatcher, findExistingPhoto };
@@ -0,0 +1,57 @@
const { db } = require('../database/db');
const { userHasAllPermissions } = require('../middleware/permissions');
const { canAccessEvent } = require('../middleware/ownership');
const { assertGalleryAvailable, requiresGalleryPassword } = require('../utils/galleryLifecycle');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const { AppError } = require('../utils/errors');
const sessions = require('./sessionAccessService');
// These claims identify a session for revocation; no raw JWT, IP or password
// enters a media URL. Only use grants from this service or a verified signature.
const CLAIMS = ['type', 'id', 'customerId', 'eventId', 'eventSlug', 'iat', 'exp', 'jti', 'via', 'accessLevel'];
class GalleryAccessService {
grant(event, kind, decoded) {
const session = decoded && Object.fromEntries(CLAIMS
.filter((key) => decoded[key] !== undefined).map((key) => [key, decoded[key]]));
return { kind, eventId: event.id, issuedAt: Math.floor(Date.now() / 1000), ...(session && { session }) };
}
async authorize(event, grant) {
if (!grant || !['public', 'gallery', 'admin'].includes(grant.kind)
|| !event || Number(grant.eventId) !== Number(event.id)) {
throw new AppError('Invalid gallery grant', 403, 'INVALID_GALLERY_GRANT');
}
assertGalleryAvailable(event, { adminPreview: grant.kind === 'admin' });
if (!Number.isFinite(grant.issuedAt) || await isTokenBeforeCutoff({ iat: grant.issuedAt })) {
throw new AppError('Session invalidated', 401, 'SESSION_INVALIDATED');
}
const session = grant.session;
if (grant.kind === 'admin') {
const account = await sessions.admin(session);
const principal = { id: account.id, roleName: account.role_name };
if (!canAccessEvent(principal, event)
|| !await userHasAllPermissions(account.id, ['events.view', 'photos.view'])) {
throw new AppError('Access denied', 403, 'FORBIDDEN');
}
} else if (grant.kind === 'gallery') {
await sessions.assertActive(session, 'gallery');
if (Number(session.eventId) !== Number(event.id)) {
throw new AppError('Token does not match requested gallery', 403, 'INVALID_GALLERY_GRANT');
}
if (session.via === 'customer') {
await sessions.customer(session, { derived: true });
const assignment = await db('event_customer_assignments')
.where({ event_id: event.id, customer_account_id: session.customerId }).first();
if (!assignment) {
throw new AppError('Access to this gallery has been revoked', 403, 'CUSTOMER_ASSIGNMENT_REVOKED');
}
}
} else if (requiresGalleryPassword(event)) {
throw new AppError('No token provided', 401, 'NO_TOKEN');
}
return grant;
}
}
module.exports = new GalleryAccessService();
+30
View File
@@ -0,0 +1,30 @@
// #756: a NULL per-event hero_logo_visible means "inherit the global
// branding_logo_display_hero toggle". Only an explicit true/false is a
// per-gallery override. `globalDefault` is branding_logo_display_hero
// (defaults true when unset).
function resolveHeroLogoVisible(perEvent, globalDefault) {
if (perEvent === null || perEvent === undefined) {
return globalDefault !== false;
}
return perEvent !== false && perEvent !== 0 && perEvent !== '0';
}
// Formats whose ORIGINAL bytes a browser can't render in an <img> (HEIC/HEIF,
// camera RAW/DNG). For these the lightbox must be served the generated JPEG
// preview instead of `url` (the original) — otherwise it shows a broken image.
// So we force `preview_url` for them regardless of the lightbox_preview_enabled
// toggle. Detection is by MIME first, extension as a fallback (browsers report
// these MIMEs inconsistently). EXPERIMENTAL: whether a preview actually renders
// still depends on the backend being able to decode the source (HEVC-in-HEIC on
// the prod image; exiftool for DNG) — see #821.
const NON_DISPLAYABLE_ORIGINAL_EXT = new Set(['heic', 'heif', 'dng']);
const NON_DISPLAYABLE_ORIGINAL_MIME = new Set(['image/heic', 'image/heif', 'image/x-adobe-dng']);
function originalNeedsPreview(photo) {
const mime = (photo.mime_type || '').toLowerCase();
if (NON_DISPLAYABLE_ORIGINAL_MIME.has(mime)) return true;
const name = photo.original_filename || photo.filename || '';
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
return NON_DISPLAYABLE_ORIGINAL_EXT.has(ext);
}
module.exports = { resolveHeroLogoVisible, originalNeedsPreview };
+37
View File
@@ -0,0 +1,37 @@
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { COLOR_LABELS, SHARED_COLOR_LABEL_IDENTITY } = require('../constants/colorLabels');
/** Apply the same own/shared feedback visibility before pagination and counts. */
function applyFeedbackFilter(query, { filter, event, identity, sharedColorMode, showFeedbackToGuests }) {
if (!filter) return query;
const tokens = new Set(String(filter).toLowerCase().split(',').map(x => x.trim()).filter(Boolean));
if (tokens.size === 0 || tokens.has('all')) return query;
if (tokens.has('saved') || tokens.has('favorite')) tokens.add('favorited');
const feedback = type => db('photo_feedback').where({ event_id: event.id, feedback_type: type,
is_hidden: formatBoolean(false) }).select('photo_id');
const own = q => identity.guestId ? q.where('guest_id', identity.guestId) : q.where('guest_identifier', identity.guestIdentifier);
return query.where(function () {
this.whereRaw('1 = 0');
for (const [token, type, column] of [['liked', 'like', 'like_count'], ['favorited', 'favorite', 'favorite_count'], ['rated', 'rating', 'average_rating'], ['commented', 'comment', null]]) {
if (!tokens.has(token)) continue;
this.orWhereIn('photos.id', own(feedback(type)));
if (showFeedbackToGuests) {
if (column) this.orWhere(`photos.${column}`, '>', 0);
else this.orWhereIn('photos.id', feedback(type).where('is_approved', formatBoolean(true)));
}
}
const colors = COLOR_LABELS.filter(color => tokens.has(`color:${color}`));
if (colors.length) {
const colorQuery = feedback('color_label').whereIn('color_label', colors);
if (sharedColorMode) {
this.orWhereIn('photos.id', colorQuery.where('guest_identifier', SHARED_COLOR_LABEL_IDENTITY));
} else {
this.orWhereIn('photos.id', own(colorQuery.clone()));
if (showFeedbackToGuests) this.orWhereIn('photos.id', colorQuery.where(function () {
this.whereNot('guest_identifier', SHARED_COLOR_LABEL_IDENTITY).orWhereNull('guest_identifier');
}));
}
}
});
}
module.exports = { applyFeedbackFilter };
+567
View File
@@ -0,0 +1,567 @@
const { toIso } = require('../utils/dateNormalize');
const { db } = require('../database/db');
const { parseBooleanInput } = require('../utils/parsers');
const { getAppSetting } = require('../utils/appSettings');
const { formatBoolean } = require('../utils/dbCompat');
const { SHARED_COLOR_LABEL_IDENTITY } = require('../constants/colorLabels');
const watermarkService = require('./watermarkService');
const logger = require('../utils/logger');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const { getUseOriginalFilenames } = require('./downloadFilenameService');
const { resolveEventDownloadPolicy } = require('../utils/downloadResolutions');
const { resolveHeroLogoVisible, originalNeedsPreview } = require('./galleryModel');
const { applyFeedbackFilter } = require('./galleryPhotoQuery');
async function getGalleryPhotos({ event, query = {}, identity, accessLevel, adminPreview, hiddenForGuest, slug }) {
// Get filter and sort parameters from query
// `guest_id` is deliberately NOT read from the query string: the viewer's
// own feedback is resolved from the request identity instead (see the
// filter block). The frontend still sends it; it is ignored.
const { filter, sort = 'upload_date', order = 'desc' } = query;
// Get watermark settings to generate cache-busting version for URLs
const watermarkSettings = await watermarkService.getWatermarkSettings();
const wmVersion = watermarkSettings?.enabled
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '';
// Build the query with sorting
const sortOrder = order === 'asc' ? 'asc' : 'desc';
const isClient = accessLevel === 'client';
let photosQuery = db('photos')
.where('photos.event_id', event.id)
// Guests/clients never see photos still being processed by the
// background worker — the original is on disk but the thumbnail
// / dimensions / EXIF haven't landed yet. Photos with a NULL
// processing_status are pre-async-migration rows and are treated
// as complete (the migration's column default is 'complete' so
// this is just defensive against partial migration states).
.where(function() {
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
})
.select('photos.*');
// Guests only see visible photos; clients see all
if (!isClient) {
photosQuery = photosQuery.where(function() {
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
});
}
// Live Slideshow category filter (#202). Enforced server-side so the kiosk
// viewer can't widen the set: when the event pins show_category_id, the
// slideshow only sees that category. NULL = all photos (unchanged).
if (accessLevel === 'slideshow' && event.show_category_id) {
photosQuery = photosQuery.where('photos.category_id', event.show_category_id);
}
// Apply sort option.
//
// Every branch carries photos.id as a tiebreaker (#1172). Without one the
// order within a tie is whatever the engine happens to return, and ties are
// the normal case rather than the exception: a bulk import writes hundreds
// of rows inside the same second, so uploaded_at collapses — and with
// captured_at NULL the COALESCE below collapses onto it too. The visible
// symptom is a grid that reshuffles between page loads. id is insertion
// order, so it also makes the fallback ordering meaningful rather than
// arbitrary.
if (sort === 'capture_date') {
// Sort by capture date, falling back to uploaded_at if capture date is null.
//
// On SQLite that fallback cannot be a plain COALESCE, because the two
// columns do not hold one type. photos.captured_at ends up carrying three
// different storage classes:
//
// integer managed uploads — photoProcessor.js:488 writes a Date, which
// the sqlite3 binding stores as epoch milliseconds
// text external imports and the backfill, which write ISO-8601
// ('2026-06-03T01:15:00.000Z') per the CLAUDE.md rule that
// Dates must not be handed to the binding in tests
// null no capture date, so the sort falls through to uploaded_at —
// usually text in knex's 'YYYY-MM-DD HH:MM:SS' default shape,
// but epoch milliseconds on rows written by a legacy archive
// restore (see __tests__/integration/sqliteEpochTimestamps.js),
// so that column needs the same two branches
//
// SQLite orders INTEGER before TEXT unconditionally, so every managed
// photo carrying EXIF sorted ahead of every photo that did not, whatever
// the actual dates — a 2027 capture landing before a 2020 one. Among the
// text values the 'T' separator (0x54) also outranks the space (0x20), so
// a same-day ISO 01:15 sorted after a fallback 23:00.
//
// Normalising in the ORDER BY rather than rewriting the column: the data
// fix would have to touch every existing row and every writer, which is a
// much heavier change than the sort it is meant to correct. The cost here
// is that this sort stops using idx_photos_captured_at on SQLite — an
// acceptable trade on the fallback engine, where the alternative is an
// index-assisted wrong answer.
//
// Postgres is untouched: captured_at is a real timestamp there, so
// COALESCE already compares correctly.
if (db.client.config.client === 'pg') {
photosQuery = photosQuery
.orderByRaw('COALESCE(photos.captured_at, photos.uploaded_at) ' + sortOrder);
} else {
photosQuery = photosQuery.orderByRaw(`CASE
WHEN typeof(photos.captured_at) IN ('integer', 'real') THEN datetime(photos.captured_at / 1000, 'unixepoch')
WHEN photos.captured_at IS NOT NULL THEN replace(replace(substr(photos.captured_at, 1, 19), 'T', ' '), 'Z', '')
WHEN typeof(photos.uploaded_at) IN ('integer', 'real') THEN datetime(photos.uploaded_at / 1000, 'unixepoch')
ELSE substr(photos.uploaded_at, 1, 19)
END ${sortOrder}`);
}
photosQuery = photosQuery.orderBy('photos.id', sortOrder);
} else if (sort === 'filename') {
photosQuery = photosQuery.orderBy('photos.filename', sortOrder).orderBy('photos.id', sortOrder);
} else {
// Default: sort by upload date
photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder).orderBy('photos.id', sortOrder);
}
// Reveal mode (#838): while the gallery is hidden, plain guests get
// the event shell with an empty photo/category set plus the
// hidden_until_reveal flag — the frontend renders the upload-only view
// from it. Slideshow, client access and the admin preview bypass
// (guestBlockedByReveal). Enforced here, not just in the UI.
// Check if feedback should be visible to guests. Read BEFORE the filter
// block, not after: the filters below consult it, because a filter that
// selects on other people's feedback is a way of reading that feedback.
const feedbackService = require('./feedbackService');
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
const showFeedbackToGuests = isClient || parseBooleanInput(feedbackSettings.show_feedback_to_guests, true);
// One identity-less colour tag per photo, any guest may overwrite it
// (#1197). Read in three places below: the colour filters, the per-viewer
// badge, and the "other viewers" dots that must not double-render it.
const sharedColorMode = feedbackSettings?.identity_mode === 'shared';
applyFeedbackFilter(photosQuery, { filter, event, identity, sharedColorMode, showFeedbackToGuests });
const limit = query.limit === undefined ? null : Math.min(250, Math.max(1, parseInt(query.limit, 10) || 100));
const page = Math.max(1, parseInt(query.page, 10) || 1);
const countRow = hiddenForGuest ? { total: 0 } : await photosQuery.clone().clearSelect().clearOrder().count('photos.id as total').first();
const total = Number(countRow.total);
if (limit) photosQuery.limit(limit).offset((page - 1) * limit);
const photos = hiddenForGuest ? [] : await photosQuery;
// Then get comment counts separately
const commentCounts = await db('photo_feedback')
.whereIn('photo_id', photos.map(p => p.id))
.where('feedback_type', 'comment')
.where('is_approved', formatBoolean(true))
.where('is_hidden', formatBoolean(false))
.groupBy('photo_id')
.select('photo_id', db.raw('COUNT(*) as comment_count'));
// Create a map for quick lookup
const commentMap = {};
commentCounts.forEach(c => {
commentMap[c.photo_id] = parseInt(c.comment_count);
});
// Per-viewer "is_liked" set (#590 follow-up). Hard refresh on the
// gallery grid used to reset every heart to empty because the lifted
// likedPhotoIds state started as a fresh Set on mount — even photos
// the viewer had actually liked. Surface a per-viewer flag so the
// frontend can seed correctly. Prefers identity.guestId when a verified
// guest token is present (per-person identity), falls back to the
// IP+UA hash that the original like was recorded under — same model
// the /my-feedback endpoint uses.
//
// NOT gated on showFeedbackToGuests (#1286). This query is filtered to
// the VIEWER — by guest_id or by their own identifier — so what it
// returns is their own selection, not shared aggregate data. Gating it
// emptied every heart the guest had set themselves on a gallery with
// sharing off, which reads as the gallery silently discarding their
// choices. Same reasoning the colour-label block below already applies;
// this was the one per-viewer field that disagreed with it.
const likedPhotoIds = new Set();
if (photos.length > 0) {
const likeQuery = db('photo_feedback')
// Hidden rows are not there, for the viewer's OWN feedback as much as
// anyone's (#1150). getPhotoFeedback drops them, the filter drops them
// and updatePhotoFeedbackStats does not count them — leaving the heart
// filled was the one place that disagreed, so a like the photographer
// had hidden still showed as liked on a photo whose like_count was 0.
.where({ event_id: event.id, feedback_type: 'like', is_hidden: formatBoolean(false) })
.whereIn('photo_id', photos.map(p => p.id));
if (identity.guestId) {
likeQuery.where('guest_id', identity.guestId);
} else {
likeQuery.where('guest_identifier', identity.guestIdentifier);
}
const likedRows = await likeQuery.select('photo_id');
likedRows.forEach(row => likedPhotoIds.add(row.photo_id));
}
// Per-viewer colour label (#1044), same identity resolution as the likes
// above. NOT gated on showFeedbackToGuests: a guest's own label is their
// own selection, not shared aggregate data, and hiding it would blank the
// grid badges on every refresh in a gallery with sharing switched off.
//
// In shared identity mode (#1197) there is no per-viewer label to read:
// the photo carries one tag and it belongs to everyone, so it arrives on
// this same field. The badge, the lightbox swatch and the keyboard
// shortcuts then work unchanged — they were already reading "the colour on
// this photo, from my point of view", which is precisely what the shared
// tag is.
const myColorLabelByPhoto = {};
if (photos.length > 0 && sharedColorMode) {
Object.assign(
myColorLabelByPhoto,
await feedbackService.getSharedColorLabels(event.id, photos.map(p => p.id)),
);
} else if (photos.length > 0) {
const colorQuery = db('photo_feedback')
// Same rule as the heart above (#1150).
.where({ event_id: event.id, feedback_type: 'color_label', is_hidden: formatBoolean(false) })
.whereIn('photo_id', photos.map(p => p.id));
if (identity.guestId) {
colorQuery.where('guest_id', identity.guestId);
} else {
colorQuery.where('guest_identifier', identity.guestIdentifier);
}
const colorRows = await colorQuery.select('photo_id', 'color_label');
colorRows.forEach(row => {
if (row.color_label) myColorLabelByPhoto[row.photo_id] = row.color_label;
});
}
// OTHER viewers' colour labels, per photo (#1178).
//
// The lightbox has always shown these — /photos/:id/feedback returns
// per-colour tallies across everyone — but the grid had no field carrying
// them, so a label set by one guest was visible in fullscreen and invisible
// on the tile. With sharing on, that is just a hole.
//
// DISTINCT colours, not counts: a tile has room for a couple of dots, and
// "who else marked this, and how" is a lightbox question. The viewer's own
// colour is excluded here so the badge and the dots never say the same
// thing twice — the frontend renders `my_color_label` as the badge and
// these beside it.
//
// Gated on showFeedbackToGuests, like every other aggregate: this is other
// people's feedback, unlike my_color_label above.
//
// Skipped entirely in shared mode (#1197). There are no other viewers'
// labels there — there is one tag, already delivered as my_color_label
// above. Without this the shared row would come back here too (its
// reserved identity is not the viewer's), and every tile would render the
// same colour twice: once as the badge, once as a dot beside it.
const otherColorLabelsByPhoto = {};
if (photos.length > 0 && showFeedbackToGuests && !sharedColorMode) {
const othersQuery = db('photo_feedback')
.where({ event_id: event.id, feedback_type: 'color_label', is_hidden: formatBoolean(false) })
.whereIn('photo_id', photos.map(p => p.id))
.whereNotNull('color_label')
// The other direction of the same rule (#1197): an event switched back
// out of shared mode keeps its shared tag, and it is nobody's — so
// without this it would show up as an anonymous other viewer's dot on
// every tile that still carries one.
.where(function () {
this.whereNot('guest_identifier', SHARED_COLOR_LABEL_IDENTITY).orWhereNull('guest_identifier');
});
if (identity.guestId) {
othersQuery.where(function () {
this.whereNot('guest_id', identity.guestId).orWhereNull('guest_id');
});
} else {
const mine = identity.guestIdentifier;
othersQuery.where(function () {
this.whereNot('guest_identifier', mine).orWhereNull('guest_identifier');
});
}
const otherRows = await othersQuery.distinct('photo_id', 'color_label');
otherRows.forEach(row => {
if (!otherColorLabelsByPhoto[row.photo_id]) otherColorLabelsByPhoto[row.photo_id] = [];
if (!otherColorLabelsByPhoto[row.photo_id].includes(row.color_label)) {
otherColorLabelsByPhoto[row.photo_id].push(row.color_label);
}
});
}
// People in each photo (#1074). Two independent gates: the feature must
// be on for this event AND, for a plain guest, the photographer must have
// left the strip visible. A client (PIN access) is the photographer's own
// view, so faces_visible_to_guests doesn't restrict them.
//
// `photos` is already visibility-filtered above, and this only ever asks
// about ids in that set, so it cannot widen what the caller sees.
let peopleEnabled = false;
let personIdsByPhoto = new Map();
try {
const { isEnabledForEvent, areFacesVisibleToGuests } = require('./faceSettings');
if (photos.length > 0 && await isEnabledForEvent(event)) {
peopleEnabled = isClient || areFacesVisibleToGuests(event);
if (peopleEnabled) {
const { getPersonIdsByPhoto } = require('./facePeopleService');
personIdsByPhoto = await getPersonIdsByPhoto(
event.id,
photos.map(p => p.id),
{ forAdmin: isClient }
);
}
}
} catch (err) {
// A face-feature failure must never take down the gallery payload.
logger.warn(`gallery: person_ids lookup failed for event ${event.id}`, { error: err.message });
peopleEnabled = false;
personIdsByPhoto = new Map();
}
// Get actual categories used by photos in this event
// This includes both global categories and event-specific ones
const usedCategoryIds = hiddenForGuest ? [] : await db('photos')
.where('event_id', event.id)
.whereNotNull('category_id')
.distinct('category_id')
.pluck('category_id');
// Fetch category details from photo_categories table
let categories = [];
if (usedCategoryIds.length > 0) {
// Resolved category order (#782): per-event override, else global
// default, else name — restricted to categories that have photos.
const categoryDetails = await getEventCategoriesOrdered(event.id, {
onlyIds: usedCategoryIds,
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads', 'c.is_folder'],
});
categories = categoryDetails.map(cat => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
is_global: cat.is_global,
hero_photo_id: cat.hero_photo_id || null,
// Per-category download flag (#640). false explicitly disables; the
// gallery hides the download button. Defaults true so categories
// created before migration 135 keep working.
allow_downloads: parseBooleanInput(cat.allow_downloads, true),
// Folder vs filter (#1160). true = the category CONTAINS its photos:
// they leave the root grid and only render inside the folder. Defaults
// false so categories predating migration 185 keep filtering.
is_folder: parseBooleanInput(cat.is_folder, false)
}));
}
// Build a map for quick category lookup
const categoryMap = {};
categories.forEach(cat => {
categoryMap[cat.id] = cat;
});
// Include protection settings in response
const protectionSettings = {
protection_level: event.protection_level || 'standard',
image_quality: event.image_quality || 85,
use_canvas_rendering: parseBooleanInput(event.use_canvas_rendering, false),
overlay_protection: parseBooleanInput(event.overlay_protection, true)
};
// Lightbox preview tier (#492). When the admin opts in, the
// photos response carries a preview_url alongside url/thumbnail_url
// — the lightbox uses preview_url when present and falls back to
// url when not, so existing galleries continue working before
// any preview has actually been generated.
let lightboxPreviewEnabled = false;
try {
const setting = await db('app_settings')
.where('setting_key', 'lightbox_preview_enabled')
.first();
if (setting) {
const raw = setting.setting_value;
// setting_value is JSON-stringified per migration 104; tolerate
// raw boolean/string for forward-compat.
const parsed = typeof raw === 'string' ? (() => {
try { return JSON.parse(raw); } catch { return raw; }
})() : raw;
lightboxPreviewEnabled = parsed === true || parsed === 'true' || parsed === 1;
}
} catch (e) {
// Setting missing / DB blip → fall back to off so the lightbox
// keeps working with the original. logger.debug to avoid noise.
logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message });
}
// #508: when the admin has flipped the "use original camera filenames"
// toggle (#493), the lightbox surfaces each photo's original_filename
// alongside the position counter so the photographer can map a guest's
// selection back to source files. Tied to the same toggle as downloads —
// one switch controls both surfaces.
const useOriginalFilenames = await getUseOriginalFilenames();
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
const downloadPolicy = await resolveEventDownloadPolicy(event);
return {
pagination: { page, limit: limit || total, total, has_more: !!limit && page * limit < total },
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
hero_photo_id: event.hero_photo_id,
// Defaults match /info: downloads on unless explicitly disabled,
// uploads off unless explicitly enabled (#1028).
allow_downloads: parseBooleanInput(event.allow_downloads, true),
allow_user_uploads: parseBooleanInput(event.allow_user_uploads, false),
// Download resolutions (#858). `choices` drives the picker modal and is
// empty when the picker is off, so the UI can never offer a size the
// server would reject.
download_resolution: {
standard: downloadPolicy.standard,
picker_enabled: downloadPolicy.pickerEnabled,
choices: downloadPolicy.pickerEnabled ? downloadPolicy.choices : [],
},
// Reveal mode (#838): armed flag lets an open VISIBLE gallery keep
// polling so a re-hide propagates without a manual reload.
reveal_armed: parseBooleanInput(event.reveal_mode, false),
disable_right_click: parseBooleanInput(event.disable_right_click, false),
watermark_downloads: parseBooleanInput(event.watermark_downloads, false),
watermark_text: event.watermark_text,
enable_devtools_protection: parseBooleanInput(event.enable_devtools_protection, false),
use_canvas_rendering: parseBooleanInput(event.use_canvas_rendering, false),
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
hero_logo_position: event.hero_logo_position || 'top',
hero_logo_url: event.hero_logo_url || null,
header_style: event.header_style || 'standard',
hero_divider_style: event.hero_divider_style || 'wave',
hero_image_anchor: event.hero_image_anchor || 'center',
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
// Promo banner override (#440). GalleryView has always read
// promo_mode from THIS payload, but it was never sent — so every
// per-event promo override silently resolved to 'inherit' and a
// gallery set to 'off' still showed the global banner.
promo_mode: event.promo_mode || 'inherit',
promo_markdown: event.promo_markdown || null,
// Info banner override (#932). GalleryAuthContext refreshes its cached
// event from THIS payload, so the fields have to travel here — /info
// alone isn't enough, the context stops reading it once the guest is
// authenticated.
info_mode: event.info_mode || 'inherit',
info_markdown: event.info_markdown || null,
download_zip_ready: !!(event.download_zip_path && event.download_zip_generated_at),
// Mirror of the admin-side toggle so the lightbox can decide
// whether to surface original camera filenames (#508).
use_original_filenames: useOriginalFilenames,
// "People in this gallery" (#1074). False whenever the global flag
// is off, detection is off for this event, or the photographer chose
// to keep the strip to themselves — the frontend renders no face UI
// at all in that case.
people_enabled: peopleEnabled,
...protectionSettings
},
// Reveal mode (#838): the guest UI switches to the upload-only view
// on this flag; reveal_at lets it show the scheduled time.
hidden_until_reveal: hiddenForGuest,
reveal_at: hiddenForGuest ? (event.reveal_at || null) : undefined,
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
// Watermark version (cache-busting) + admin-preview flag (#868). In
// preview mode no gallery cookie is minted, so each <img> request must
// re-assert the admin session — thread the flag onto every /api/gallery
// image URL so the browser sends it (the admin_token cookie rides along
// same-origin).
const imgQuery = [wmVersion, adminPreview ? 'admin_preview=1' : ''].filter(Boolean).join('&');
const wmQuery = imgQuery ? `?${imgQuery}` : '';
const photoUrl = useJwtUrl ?
`/api/gallery/${slug}/photo/${photo.id}${wmQuery}` :
`/api/secure-images/${slug}/secure/${photo.id}/{{token}}`;
return {
id: photo.id,
filename: photo.filename,
// Raw camera filename (or null for pre-migration-062 uploads).
// The lightbox renders it when `use_original_filenames` is on.
original_filename: photo.original_filename || null,
url: photoUrl,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${slug}/thumbnail/${photo.id}${wmQuery}` : null,
// Hero-optimized image URL (1920x1080) for full-width hero sections
hero_url: `/api/gallery/${slug}/hero/${photo.id}${wmQuery}`,
// Lightbox preview URL (#492). Only emitted when the admin
// has flipped lightbox_preview_enabled — the frontend
// lightbox reads preview_url with a fallback to url so
// installs that haven't opted in keep loading the original
// (current behaviour). Skipped for videos since they don't
// get a preview tier; lightbox will use the original .url.
preview_url: (lightboxPreviewEnabled || originalNeedsPreview(photo))
&& photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${slug}/preview/${photo.id}${wmQuery}`
: null,
// Slideshow source (#1015). Same preview tier, but emitted
// unconditionally: the slideshow has no `url` fallback worth
// taking (originals are projector-sized) and must never land on
// `hero_url`, which is cover-cropped to 16:9 — that made the
// "no crop" fit letterbox an already-cropped frame. The preview
// route generates lazily and redirects to the original on any
// failure, so this is safe even where no preview exists yet.
slideshow_url: photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${slug}/preview/${photo.id}${wmQuery}`
: null,
secure_url_template: `/api/secure-images/${slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
category_id: photo.category_id || null,
category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null,
// Per-category download permission (#640). Defaults true for photos
// without a category or for categories that pre-date migration 135.
category_allow_downloads: photo.category_id && categoryMap[photo.category_id]
? parseBooleanInput(categoryMap[photo.category_id].allow_downloads, true)
: true,
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
size: photo.size_bytes,
// toIso: on SQLite installs rows written with a raw Date (e.g.
// the pre-fix archive-restore path) hold epoch numbers — the
// Timeline layout's parseISO() crashes on those (#485 class).
uploaded_at: toIso(photo.uploaded_at),
// Image dimensions for layout calculations
width: photo.width || null,
height: photo.height || null,
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
requires_token: !useJwtUrl,
// EXIF capture date
captured_at: toIso(photo.captured_at) || null,
// Media type
media_type: photo.media_type || null,
mime_type: photo.mime_type || null,
duration: photo.duration || null,
// Feedback data (hidden when show_feedback_to_guests is disabled)
has_feedback: showFeedbackToGuests ? (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0) : false,
average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0,
comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0,
like_count: showFeedbackToGuests ? (photo.like_count || 0) : 0,
// Per-viewer flag (#590 follow-up) — true when this viewer has
// an active like row for this photo, false otherwise. Lets the
// grid seed its lifted likedPhotoIds correctly on hard refresh.
// Survives show_feedback_to_guests being off (#1286): the viewer's
// own heart is theirs, and the like_count beside it stays hidden.
is_liked: likedPhotoIds.has(photo.id),
favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0,
// Colour labels (#1044). The COUNT is aggregate data and follows
// show_feedback_to_guests like its siblings; the viewer's OWN label
// is not aggregate and must survive with sharing off, otherwise the
// grid badge disappears on refresh for the very guest who set it.
color_label_count: showFeedbackToGuests ? (photo.color_label_count || 0) : 0,
my_color_label: myColorLabelByPhoto[photo.id] || null,
// Distinct colours other viewers put on this photo (#1178), so the
// grid can show them beside the viewer's own badge. Empty with
// sharing off — it is other people's feedback.
other_color_labels: otherColorLabelsByPhoto[photo.id] || [],
// People in this photo (#1074). Empty array when the feature is
// off for this event or hidden from guests, so the frontend has
// one shape to handle. Riding along on this payload is what keeps
// face filtering client-side and instant, like the category and
// liked/rated filters.
person_ids: personIdsByPhoto.get(photo.id) || [],
// Visibility (only included for clients)
...(isClient ? { visibility: photo.visibility || 'visible' } : {})
};
})
};
}
module.exports = { getGalleryPhotos };
@@ -24,13 +24,13 @@
* `crmSchedulerService` is a future cleanup.
*/
const cron = require('node-cron');
const { scheduledTask } = require('./scheduledTask');
const invoiceService = require('./invoiceService');
const eventReminderService = require('./eventReminderService');
const quoteService = require('./quoteService');
const logger = require('../utils/logger');
let task = null;
async function runTick() {
try {
@@ -71,31 +71,8 @@ async function runTick() {
}
}
function startInvoiceScheduler() {
if (task) {
logger.info('Invoice scheduler already running');
return task;
}
// Hourly at minute 11 to spread load away from other hourly jobs.
task = cron.schedule('11 * * * *', async () => {
logger.info('Invoice scheduler: tick');
await runTick();
});
logger.info('Invoice scheduler started (hourly @ :11) — invoice + event-reminder jobs');
// Run once on boot so a missed window (server restart) gets caught
// up immediately.
runTick().catch((err) => {
logger.warn('Invoice scheduler initial tick failed', { err: err.message });
});
return task;
}
function stopInvoiceScheduler() {
if (task) {
task.stop();
task = null;
logger.info('Invoice scheduler stopped');
}
}
const task = scheduledTask(runTick, { schedule: '11 * * * *', initialDelay: 0 });
function startInvoiceScheduler() { task.start(); return task; }
const stopInvoiceScheduler = () => task.stop();
module.exports = { startInvoiceScheduler, stopInvoiceScheduler };
+5 -5
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
const rateLimit = require('express-rate-limit');
const { MemoryStore } = require('express-rate-limit');
const jwt = require('jsonwebtoken');
@@ -250,19 +251,18 @@ async function createRateLimiter(store = new MemoryStore()) {
// Enhanced logging for production analysis
logger.warn('Rate limit exceeded', {
ip: clientIp,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
method: req.method,
authenticated: isAuthenticated(req),
tokenType: req.tokenType,
userAgent: req.headers['user-agent'],
referer: req.headers['referer'],
origin: req.headers['origin'],
timestamp: new Date().toISOString(),
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip']
},
requestUrl: req.originalUrl,
requestUrl: requestLogPath(req.originalUrl || req.path),
rateLimitInfo: {
limit: req.rateLimit?.limit,
current: req.rateLimit?.current,
@@ -318,7 +318,7 @@ async function createAuthRateLimiter(store = new MemoryStore()) {
// Enhanced logging for auth failures
logger.warn('Auth rate limit exceeded', {
ip: clientIp,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
method: req.method,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
@@ -326,7 +326,7 @@ async function createAuthRateLimiter(store = new MemoryStore()) {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip']
},
requestUrl: req.originalUrl,
requestUrl: requestLogPath(req.originalUrl || req.path),
authType: req.path.includes('admin') ? 'admin' : 'gallery',
rateLimitInfo: {
limit: req.rateLimit?.limit,
+6 -6
View File
@@ -10,7 +10,7 @@
* workflow trigger so hosts can hook a notification email onto it.
*/
const cron = require('node-cron');
const { scheduledTask } = require('./scheduledTask');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
@@ -68,9 +68,9 @@ async function checkScheduledReveals() {
}
}
function startRevealScheduler() {
cron.schedule('* * * * *', checkScheduledReveals);
logger.info('Reveal scheduler started');
}
const task = scheduledTask(checkScheduledReveals, { schedule: '* * * * *' });
function startRevealScheduler() { task.start(); }
const stopRevealScheduler = () => task.stop();
module.exports = { startRevealScheduler, checkScheduledReveals };
module.exports = {
stopRevealScheduler, startRevealScheduler, checkScheduledReveals };
+11 -16
View File
@@ -31,10 +31,10 @@ const ENABLED = process.env.STORAGE_AUTO_IMPORT === 'true';
// On the next poll, any key in BOTH the previous and current snapshots is
// eligible for import. This is the eventual-consistency gate.
const previousSnapshot = new Map();
let intervalHandle = null;
let stopped = false;
async function tick() {
async function runTick() {
if (stopped) return;
const storage = getStorage();
if (storage.kind() !== 's3') return; // no-op for local fs
@@ -157,24 +157,19 @@ async function processEvent(event, storage) {
previousSnapshot.set(event.id, currentKeys);
}
const pollingTask = require('./scheduledTask').scheduledTask(runTick, {
// Run once on start so admins see import activity without waiting a full poll.
interval: POLL_INTERVAL_MS, initialDelay: 0,
});
const tick = () => runTick(); // Explicit test/manual tick does not start a timer.
function startS3AutoImporter() {
if (!ENABLED) return null;
if (intervalHandle) return intervalHandle;
if (!ENABLED) return;
stopped = false;
// Run once on startup so admins see import activity in logs without
// waiting for the first poll interval.
tick().catch((err) => logger.error(`[s3AutoImporter] initial tick error: ${err.message}`));
intervalHandle = setInterval(tick, POLL_INTERVAL_MS);
logger.info(`[s3AutoImporter] started — interval=${POLL_INTERVAL_MS}ms`);
return intervalHandle;
pollingTask.start();
}
function stopS3AutoImporter() {
async function stopS3AutoImporter() {
stopped = true;
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
}
await pollingTask.stop();
previousSnapshot.clear();
}
+29
View File
@@ -0,0 +1,29 @@
const cron = require('node-cron');
const logger = require('../utils/logger');
/** One owner for a periodic job, with no overlapping runs and a draining stop. */
function scheduledTask(run, { schedule, interval, initialDelay } = {}) {
let started = false, timer = null, first = null, running = null;
const tick = () => {
if (!started || running) return running;
running = Promise.resolve().then(run).catch(error => {
logger.error('Scheduled task failed', { error: error.message });
}).finally(() => { running = null; });
return running;
};
return {
start() {
if (started) return;
started = true;
timer = schedule ? cron.schedule(schedule, tick) : setInterval(tick, interval);
if (!schedule) timer.unref?.();
if (initialDelay !== undefined) { first = setTimeout(tick, initialDelay); first.unref?.(); }
},
async stop() {
started = false;
if (schedule) timer?.stop(); else clearInterval(timer);
clearTimeout(first); timer = null; first = null;
await running;
},
};
}
module.exports = { scheduledTask };
+34 -12
View File
@@ -9,6 +9,25 @@ class SecureImageService {
this.tokenCache = new Map();
this.sessionTokens = new Map();
this.rateLimitCache = new Map();
this.cleanupTimer = null;
}
start() {
if (this.cleanupTimer) return;
this.cleanupTimer = setInterval(() => this.cleanup(), 60_000);
this.cleanupTimer.unref();
}
stop() {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
dispose() {
this.stop();
this.tokenCache.clear();
this.sessionTokens.clear();
this.rateLimitCache.clear();
}
/**
@@ -27,9 +46,14 @@ class SecureImageService {
// Whether the minter was a PIN-client — lets the serve route keep
// delivering a photo hidden AFTER minting (TOCTOU). A guest's token
// carries false, so it stops the moment the photo is hidden.
clientBypass = false
clientBypass = false,
galleryAccess = null
} = options;
if (!Number.isFinite(Number(expiresIn)) || Number(expiresIn) <= 0 || Number(expiresIn) > 3600) {
throw new (require('../utils/errors').ValidationError)('Invalid image token lifetime');
}
const tokenData = {
photoId: parseInt(photoId),
sessionId,
@@ -40,6 +64,7 @@ class SecureImageService {
protectionLevel,
revealBypass,
clientBypass,
galleryAccess,
createdAt: Date.now()
};
@@ -56,10 +81,8 @@ class SecureImageService {
// Cache token with metadata
this.tokenCache.set(token, tokenData);
// Set cleanup timer
setTimeout(() => {
this.tokenCache.delete(token);
}, expiresIn * 1000 + 60000); // Add 1 minute buffer
// One owned timer per service, not one live handle per issued token.
this.start();
return token;
}
@@ -217,9 +240,7 @@ class SecureImageService {
}
image = image.withMetadata({
exif: {
[sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}`
}
exif: { IFD0: { ImageDescription: `Protected:${fingerprint}` } }
});
return await image.toBuffer();
@@ -261,9 +282,7 @@ class SecureImageService {
// Embed fingerprint in metadata
image = image.withMetadata({
exif: {
[sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}`
}
exif: { IFD0: { ImageDescription: `Protected:${fingerprint}` } }
});
}
@@ -430,6 +449,9 @@ class SecureImageService {
cleanup() {
// Clear expired rate limit entries
const now = Date.now();
for (const [token, data] of this.tokenCache) {
if (data.expiresAt <= now) this.tokenCache.delete(token);
}
for (const [clientId, requests] of this.rateLimitCache.entries()) {
const recent = requests.filter(timestamp => timestamp > now - 60000);
if (recent.length === 0) {
@@ -441,4 +463,4 @@ class SecureImageService {
}
}
module.exports = new SecureImageService();
module.exports = new SecureImageService();
+28
View File
@@ -0,0 +1,28 @@
const logger = require('../utils/logger');
// Resolve only services already loaded by startup. Shutdown must not construct
// unrelated singletons or start new work just to stop it.
const resources = [
['../middleware/sessionTimeout', 'dispose'], ['./chunkedUploadService', 'stop'],
['../utils/cleanupTempUploads', 'stopTempUploadCleanup'],
['../middleware/secureImageMiddleware', 'dispose'], ['../middleware/feedbackRateLimit', 'dispose'],
['./downloadZipService', 'stop'],
['./fileWatcher', 'stopFileWatcher'], ['./externalMediaWatcher', 'stopExternalMediaWatcher'],
['./expirationChecker', 'stopExpirationChecker'], ['./transferCleanupService', 'stopTransferCleanup'],
['./downloadJobCleanupService', 'stopDownloadJobCleanup'], ['./revealScheduler', 'stopRevealScheduler'],
['./invoiceSchedulerService', 'stopInvoiceScheduler'], ['./emailProcessor', 'stopEmailQueueProcessor'],
['./whatsappProcessor', 'stopWhatsAppQueueProcessor'], ['./emailIntakeService', 'stopIncomingMailPoller'],
['./webhookDeliveryWorker', 'stopWebhookDeliveryWorker'], ['./s3AutoImporter', 'stopS3AutoImporter'],
['./backupService', 'stopBackupService'], ['./databaseBackup', 'stopScheduledBackups'],
['./backgroundProcessor', 'stop'], ['./faceQueue', 'stop'], ['./secureImageService', 'dispose'],
['../utils/authSecurity', 'stopCleanupJob'], ['../utils/tokenRevocation', 'stopRevocationCleanup'],
];
async function stopServices() {
const results = await Promise.allSettled(resources.map(async ([path, method]) => {
const loaded = require.cache[require.resolve(path)];
if (typeof loaded?.exports[method] === 'function') await loaded.exports[method]();
}));
const failures = results.filter(result => result.status === 'rejected');
failures.forEach(result => logger.error('Service shutdown failed', { error: result.reason.message }));
if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'Service shutdown failed');
}
module.exports = { stopServices };
@@ -0,0 +1,74 @@
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isMissingRolesSchema } = require('../utils/dbErrors');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const { toTimestamp } = require('../utils/dateNormalize');
const { AppError } = require('../utils/errors');
/** Call only with a verified JWT payload or a signed, server-created grant. */
class SessionAccessService {
async assertActive(session, type) {
if (!session || session.type !== type) {
throw new AppError('Invalid token type', 403, 'WRONG_TOKEN_TYPE');
}
if (!Number.isFinite(session.iat)
|| (session.exp !== undefined && (!Number.isFinite(session.exp) || session.exp <= Date.now() / 1000))) {
throw new AppError('Token expired or invalid', 401, 'TOKEN_EXPIRED');
}
if (await isTokenRevoked(session)) {
throw new AppError('Token has been revoked', 401, 'TOKEN_REVOKED');
}
if (await isTokenBeforeCutoff(session)) {
throw new AppError('Session invalidated', 401, 'SESSION_INVALIDATED');
}
}
assertPasswordCurrent(account, session) {
if (account.password_changed_at == null) return;
const changed = toTimestamp(account.password_changed_at);
// Preserve the same-second login convention used by admin/customer auth.
if (!Number.isFinite(changed) || session.iat < Math.floor(changed / 1000)) {
throw new AppError('Token invalid due to password change', 401, 'PASSWORD_CHANGED');
}
}
async admin(session, { includeProfile = false } = {}) {
await this.assertActive(session, 'admin');
let account;
try {
account = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': session.id, 'admin_users.is_active': formatBoolean(true) })
.select('admin_users.id', 'admin_users.username', 'admin_users.email',
'admin_users.password_changed_at', 'roles.id as role_id', 'roles.name as role_name',
...(includeProfile ? ['admin_users.must_change_password', 'roles.display_name as role_display_name'] : []))
.first();
} catch (error) {
if (!isMissingRolesSchema(error)) throw error;
account = await db('admin_users')
.where({ id: session.id, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'password_changed_at').first();
if (account) Object.assign(account, { role_id: null, role_name: 'super_admin' });
}
if (!account) throw new AppError('Invalid token', 401, 'ADMIN_NOT_FOUND');
this.assertPasswordCurrent(account, session);
return account;
}
async customer(session, { derived = false } = {}) {
if (!derived) await this.assertActive(session, 'customer');
if (!Number.isInteger(session.customerId)) {
throw new AppError('Invalid customer session', 401, 'CUSTOMER_NOT_FOUND');
}
const account = await db('customer_accounts')
.where({ id: session.customerId, is_active: formatBoolean(true) })
.select('id', 'email', 'display_name', 'first_name', 'last_name', 'password_changed_at', 'preferred_language')
.first();
if (!account) throw new AppError('Invalid token', 401, 'CUSTOMER_NOT_FOUND');
this.assertPasswordCurrent(account, session);
return account;
}
}
module.exports = new SessionAccessService();
@@ -17,7 +17,7 @@
* are deleted, which is what the retention cap is about.)
*/
const cron = require('node-cron');
const { scheduledTask } = require('./scheduledTask');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
@@ -26,13 +26,9 @@ const transferService = require('./transferService');
const DAY_MS = 24 * 60 * 60 * 1000;
function startTransferCleanup() {
// Hourly at :15 — staggered from the gallery expiration checker (:00).
cron.schedule('15 * * * *', async () => {
await runTransferCleanup();
});
logger.info('Transfer cleanup scheduler started');
}
const task = scheduledTask(runTransferCleanup, { schedule: '15 * * * *' });
function startTransferCleanup() { task.start(); }
const stopTransferCleanup = () => task.stop();
async function runTransferCleanup() {
try {
@@ -138,6 +134,7 @@ async function deleteRetiredTransfers() {
}
module.exports = {
stopTransferCleanup,
startTransferCleanup,
// exported for tests / manual invocation
runTransferCleanup,
+11 -14
View File
@@ -2,6 +2,7 @@ const axios = require('axios');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { signPayload, renderTemplate } = require('./webhookService');
const { pinnedRequestOptions } = require('../utils/pinnedRequest');
const { validateExternalUrlAsync } = require('../utils/networkValidation');
const POLL_INTERVAL_MS = parseInt(process.env.WEBHOOK_DELIVERY_INTERVAL_MS || '5000', 10);
@@ -32,7 +33,7 @@ const BACKOFF_MS = [
12 * 60 * 60_000, // 12 h (only used when MAX_ATTEMPTS extended past 5)
];
let intervalHandle = null;
let stopped = false;
// Tracks deliveries currently being processed in this tick — guards
// against the same row being claimed twice if a tick takes longer than
@@ -95,6 +96,7 @@ async function deliverOne(row) {
// host and vets every A/AAAA record (a public-looking name that now
// resolves to an internal IP is rejected). Admin can opt out via
// WEBHOOK_ALLOW_PRIVATE_URLS=true for local-receiver dev runs.
let connectionOptions = {};
if (!allowPrivateUrls) {
const urlCheck = await validateExternalUrlAsync(webhook.url);
if (!urlCheck.valid) {
@@ -111,6 +113,7 @@ async function deliverOne(row) {
}
return;
}
connectionOptions = pinnedRequestOptions(urlCheck);
}
const envelopeBody = typeof row.payload === 'string' ? row.payload : JSON.stringify(row.payload);
@@ -139,6 +142,7 @@ async function deliverOne(row) {
let networkError;
try {
response = await axios.post(webhook.url, rawBody, {
...connectionOptions,
headers: {
'Content-Type': contentType,
[SIGNATURE_HEADER]: signature,
@@ -266,7 +270,7 @@ function stringifyBody(data) {
try { return JSON.stringify(data); } catch { return String(data); }
}
async function tick() {
async function runTick() {
if (stopped) return;
try {
const slots = Math.max(0, CONCURRENCY - inFlight.size);
@@ -286,22 +290,15 @@ async function tick() {
}
}
const pollingTask = require('./scheduledTask').scheduledTask(runTick, { interval: POLL_INTERVAL_MS });
const tick = () => runTick(); // Explicit test/manual tick does not start a timer.
function startWebhookDeliveryWorker() {
if (intervalHandle) return; // idempotent
stopped = false;
intervalHandle = setInterval(tick, POLL_INTERVAL_MS);
logger.info(
`[webhookWorker] started — interval=${POLL_INTERVAL_MS}ms, concurrency=${CONCURRENCY}, ` +
`max_attempts=${MAX_ATTEMPTS}, allow_private=${allowPrivateUrls}`
);
pollingTask.start();
}
function stopWebhookDeliveryWorker() {
async function stopWebhookDeliveryWorker() {
stopped = true;
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
}
await pollingTask.stop();
}
module.exports = {
+4 -24
View File
@@ -67,7 +67,7 @@ const POLL_INTERVAL_MS = parseInt(process.env.WHATSAPP_QUEUE_POLL_MS || '30000',
const CYCLE_BATCH_SIZE = parseInt(process.env.WHATSAPP_QUEUE_BATCH || '10', 10);
const MAX_RETRIES = 3;
let pollHandle = null;
/**
* Resolve a Meta template language code from whatever's in the message_data
@@ -301,29 +301,9 @@ async function processWhatsAppQueue() {
}
}
function startWhatsAppQueueProcessor() {
if (pollHandle) {
logger.info('WhatsApp queue processor already running — skipping start');
return;
}
// Fire once shortly after boot so the first message in a fresh install
// doesn't wait the full poll interval.
setTimeout(() => {
processWhatsAppQueue().catch((e) => logger.error('WhatsApp queue initial run failed', e));
}, 5000);
pollHandle = setInterval(() => {
processWhatsAppQueue().catch((e) => logger.error('WhatsApp queue cycle failed', e));
}, POLL_INTERVAL_MS);
logger.info(`WhatsApp queue processor started (poll every ${POLL_INTERVAL_MS}ms)`);
}
function stopWhatsAppQueueProcessor() {
if (pollHandle) {
clearInterval(pollHandle);
pollHandle = null;
logger.info('WhatsApp queue processor stopped');
}
}
const whatsappTask = require('./scheduledTask').scheduledTask(processWhatsAppQueue, { interval: POLL_INTERVAL_MS, initialDelay: 5000 });
function startWhatsAppQueueProcessor() { whatsappTask.start(); }
const stopWhatsAppQueueProcessor = () => whatsappTask.stop();
module.exports = {
queueWhatsapp,
+11 -12
View File
@@ -34,24 +34,23 @@ async function startWorkers() {
logger.info('All background workers started successfully');
} catch (error) {
logger.error('Failed to start background workers:', error);
process.exit(1);
process.exitCode = 1;
await handleShutdown('startup failure');
}
}
function handleShutdown(signal) {
if (isShuttingDown) {
logger.info('Shutdown already in progress...');
return;
}
async function handleShutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
logger.info(`Received ${signal}. Shutting down gracefully...`);
// Give time for cleanup
setTimeout(() => {
try {
await require('./serviceShutdown').stopServices();
await require('../database/db').db.destroy();
logger.info('Worker manager shutdown complete');
process.exit(0);
}, 1000);
} catch (error) {
logger.error('Worker shutdown failed', { error: error.message });
process.exitCode = 1;
}
}
// Handle shutdown signals
+4 -7
View File
@@ -342,15 +342,12 @@ async function cleanupOldAttempts() {
/**
* Initialize cleanup job
*/
function initializeCleanupJob() {
// Run cleanup every 24 hours
setInterval(cleanupOldAttempts, 24 * 60 * 60 * 1000);
// Run initial cleanup
cleanupOldAttempts();
}
const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupOldAttempts, { interval: 24 * 60 * 60 * 1000, initialDelay: 0 });
function initializeCleanupJob() { cleanupTask.start(); }
const stopCleanupJob = () => cleanupTask.stop();
module.exports = {
stopCleanupJob,
trackFailedAttempt,
trackSuccessfulLogin,
checkAccountLockout,
+6 -15
View File
@@ -60,19 +60,10 @@ async function cleanupTempUploads() {
* Start periodic cleanup of temp uploads
* Runs every hour
*/
function startTempUploadCleanup() {
// Run immediately on startup
cleanupTempUploads();
// Then run every hour
setInterval(() => {
cleanupTempUploads();
}, 60 * 60 * 1000); // 1 hour
logger.info('Temp upload cleanup service started');
}
const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupTempUploads, {
interval: 60 * 60 * 1000, initialDelay: 0
});
function startTempUploadCleanup() { cleanupTask.start(); }
function stopTempUploadCleanup() { return cleanupTask.stop(); }
module.exports = {
cleanupTempUploads,
startTempUploadCleanup
};
module.exports = { cleanupTempUploads, startTempUploadCleanup, stopTempUploadCleanup };
+12 -1
View File
@@ -33,4 +33,15 @@ function toIso(value) {
return value;
}
module.exports = { toIso };
// Shared comparison boundary for SQLite epoch values and PostgreSQL Dates.
// Invalid input stays NaN so access-control callers can fail closed.
function toTimestamp(value) {
if (value === null || value === undefined || value === '') return NaN;
try {
return new Date(toIso(value)).getTime();
} catch (_) {
return NaN;
}
}
module.exports = { toIso, toTimestamp };
+38
View File
@@ -0,0 +1,38 @@
const { toTimestamp } = require('./dateNormalize');
const { AppError } = require('./errors');
const logger = require('./logger');
const warnedExpiry = new Set();
const isTrue = (value) => value === true || value === 1 || value === '1';
function isGalleryExpired(event, now = Date.now()) {
if (event.expires_at == null || event.expires_at === '') return false;
const expiry = toTimestamp(event.expires_at);
if (!Number.isFinite(expiry)) {
// Fail closed, but name the row once so an operator can repair it.
if (!warnedExpiry.has(event.id)) {
warnedExpiry.add(event.id);
logger.warn('Unparseable events.expires_at treated as expired', { eventId: event.id, expires_at: String(event.expires_at) });
}
return true;
}
return expiry <= now;
}
function requiresGalleryPassword(event) {
return !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
}
function isGalleryAvailable(event, { adminPreview = false } = {}) {
return !!event && isTrue(event.is_active) && !isTrue(event.is_archived)
&& (adminPreview || (!isTrue(event.is_draft) && !isGalleryExpired(event)));
}
function assertGalleryAvailable(event, { adminPreview = false } = {}) {
if (!isGalleryAvailable(event, { adminPreview })) {
throw new AppError('Gallery not found or expired', 404, 'GALLERY_UNAVAILABLE');
}
}
module.exports = { isGalleryAvailable, assertGalleryAvailable, isGalleryExpired, requiresGalleryPassword };
+23 -20
View File
@@ -190,30 +190,30 @@ function validateExternalUrl(urlString) {
* literal isPrivateIP check alone can't see that. Fails closed on resolution
* failure. IP literals are decided by isPrivateIP without a lookup.
*
* Residual: a determined attacker who controls DNS can still rebind between
* this check and the client's own resolution (TOCTOU). Fully closing that
* needs pinning the connection to the vetted IP, which the underlying
* clients (nodemailer/imap/ssh/aws-sdk) don't cleanly support; these actions
* are admin-only, so resolve-and-vet is the proportionate mitigation.
* HTTP clients for admin-configured URLs (webhook delivery, the email webhook
* transport) must use the returned addresses from validateExternalUrlAsync
* with pinnedRequestOptions; a separate preflight alone cannot stop rebinding.
* The analytics tracker proxy, the tracker adapters and OIDC discovery still
* rely on the preflight only.
*
* @param {string} hostname
* @returns {Promise<boolean>} true when safe to connect
*/
async function classifyHost(hostname) {
if (!hostname || typeof hostname !== 'string') return 'invalid';
// Literal check first: IP literals, blocked names, .internal/.local/.localhost.
if (isPrivateIP(hostname)) return 'private';
// An IP literal is fully decided above — no name to resolve.
async function resolveHost(hostname) {
if (!hostname || typeof hostname !== 'string') return { reason: 'invalid' };
if (isPrivateIP(hostname)) return { reason: 'private' };
const bare = hostname.replace(/^\[|\]$/g, '');
if (net.isIP(bare)) return 'ok';
if (net.isIP(bare)) return { reason: 'ok', addresses: [{ address: bare, family: net.isIP(bare) }] };
let addresses;
try {
addresses = await dns.lookup(hostname, { all: true });
} catch {
return 'unresolved'; // transient/NXDOMAIN — caller decides retry vs reject
}
if (!addresses.length) return 'unresolved';
return addresses.every((a) => !isPrivateIP(a.address)) ? 'ok' : 'private';
try { addresses = await dns.lookup(hostname, { all: true }); }
catch { return { reason: 'unresolved' }; }
if (!addresses.length) return { reason: 'unresolved' };
if (addresses.some(a => !net.isIP(a.address) || isPrivateIP(a.address))) return { reason: 'private' };
return { reason: 'ok', addresses };
}
async function classifyHost(hostname) {
return (await resolveHost(hostname)).reason;
}
async function isHostAllowed(hostname) {
@@ -237,11 +237,14 @@ async function validateExternalUrlAsync(urlString) {
} catch {
return { valid: false, error: 'Invalid URL format', reason: 'invalid' };
}
const reason = await classifyHost(parsed.hostname);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
return { valid: false, error: 'HTTP(S) URL without credentials required', reason: 'invalid' };
}
const { reason, addresses } = await resolveHost(parsed.hostname);
if (reason !== 'ok') {
return { valid: false, error: 'URL points to a private or internal network address', reason };
}
return { valid: true, reason: 'ok' };
return { valid: true, reason: 'ok', hostname: parsed.hostname.replace(/^\[|\]$/g, ''), addresses };
}
module.exports = { isPrivateIP, validateExternalUrl, isHostAllowed, validateExternalUrlAsync, classifyHost };
+28
View File
@@ -0,0 +1,28 @@
/** Axios/Node lookup: connect only to the addresses vetted for this delivery.
* Keep the original URL for Host, TLS SNI and certificate verification.
* Disable environment proxies (which would resolve the destination themselves)
* and redirects. No reusable agent/socket can carry an old DNS decision.
*/
const http = require('http');
const https = require('https');
function pinnedRequestOptions(check) {
if (!check?.valid || !check.hostname || !check.addresses?.length) {
throw new Error('A validated destination is required');
}
const addresses = check.addresses.map(({ address, family }) => ({ address, family }));
const lookup = (hostname, options, callback) => {
if (typeof options === 'function') { callback = options; options = {}; }
if (hostname !== check.hostname) return callback(new Error('Destination hostname changed'));
const family = typeof options === 'number' ? options : options?.family;
const matches = family ? addresses.filter(a => a.family === family) : addresses;
if (!matches.length) return callback(new Error('No validated address for requested family'));
if (options?.all) return callback(null, matches);
callback(null, matches[0].address, matches[0].family);
};
return {
proxy: false, maxRedirects: 0,
httpAgent: new http.Agent({ lookup, keepAlive: false }),
httpsAgent: new https.Agent({ lookup, keepAlive: false }),
};
}
module.exports = { pinnedRequestOptions };
+5 -4
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('./requestLogPath');
/**
* Rate Limiting Security Utilities
* Provides secure rate limiting that prevents bypass attempts
@@ -42,7 +43,7 @@ function hasValidAdminToken(req) {
// Must be admin type to skip rate limiting
if (decoded.type !== 'admin') {
logger.warn('Non-admin token attempted to bypass rate limit', {
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
tokenType: decoded.type,
ip: req.ip
});
@@ -55,7 +56,7 @@ function hasValidAdminToken(req) {
if (tokenAge > maxAge) {
logger.warn('Old admin token attempted to bypass rate limit', {
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
tokenAge: Math.floor(tokenAge / 1000 / 60) + ' minutes',
ip: req.ip
});
@@ -70,7 +71,7 @@ function hasValidAdminToken(req) {
// Log attempts with invalid tokens (potential attacks)
if (error.name === 'JsonWebTokenError') {
logger.warn('Invalid token attempted to bypass rate limit', {
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
error: error.message,
ip: req.ip
});
@@ -105,7 +106,7 @@ function createSecureSkipFunction() {
function logRateLimitHit(req, res) {
logger.warn('Rate limit exceeded', {
ip: req.ip,
path: req.path,
path: requestLogPath(req.originalUrl || req.path),
userAgent: req.headers['user-agent'],
remaining: res.getHeader('X-RateLimit-Remaining'),
limit: res.getHeader('X-RateLimit-Limit')
+12
View File
@@ -0,0 +1,12 @@
/** Log the path without query values or bearer capabilities embedded in it. */
function requestLogPath(value) {
const path = String(value || '/').split(/[?#]/, 1)[0];
return path
.replace(/(\/(?:signed|verify-token|show|download-jobs|invite|accept-invite|password-reset|unsubscribe)\/)[^/]+/gi, '$1[redacted]')
.replace(/(\/api\/public\/[^/]+\/)[^/]+/gi, '$1[redacted]')
.replace(/(\/(?:secure|secure-download)\/[^/]+\/)[^/]+/gi, '$1[redacted]')
.replace(/\b(?:[a-f0-9]{32,}|eyJ[A-Za-z0-9_.-]+)\b/gi, '[redacted]')
// eslint-disable-next-line no-control-regex -- strip log injection control bytes
.replace(/[\r\n\x00-\x1f]/g, '');
}
module.exports = { requestLogPath };
+19 -14
View File
@@ -21,22 +21,27 @@ function isAllowedOrigin(origin) {
return allowedOrigins.indexOf(origin) !== -1;
}
// Origin check for multipart bodies (see the Content-Type gate below).
// Same-origin installs proxy /api through nginx and may not have FRONTEND_URL
// set, so an Origin matching the request Host is accepted alongside the CORS
// allowlist; Sec-Fetch-Site is authoritative when a browser sends it.
function multipartOriginAllowed(req) {
// Check every browser mutation, including an empty form POST. Explicitly
// configured frontend origins may be cross-site; a sibling origin alone is
// not trusted. Non-browser clients without Origin/Fetch Metadata still work.
function mutationOriginAllowed(req) {
// Fetch Metadata is set by the browser and cannot be forged cross-site, so a
// same-origin request is trusted before the Origin/Host/scheme comparison,
// which depends on trust proxy and X-Forwarded-Proto being configured.
const site = req.headers['sec-fetch-site'];
if (site) return site !== 'cross-site';
if (site === 'same-origin') return true;
const origin = req.headers.origin;
if (!origin) return true;
if (isAllowedOrigin(origin)) return true;
try {
return new URL(origin).host === req.headers.host;
} catch {
return false;
if (origin) {
if (isAllowedOrigin(origin)) return true;
try {
const parsed = new URL(origin);
return parsed.origin !== 'null' && parsed.host === req.headers.host
&& (!req.protocol || parsed.protocol === `${req.protocol}:`);
} catch { return false; }
}
return !site || site === 'none';
}
module.exports = { isAllowedOrigin, multipartOriginAllowed };
// Compatibility export for existing callers.
const multipartOriginAllowed = mutationOriginAllowed;
module.exports = { isAllowedOrigin, mutationOriginAllowed, multipartOriginAllowed };
+2 -1
View File
@@ -1,3 +1,4 @@
const { requestLogPath } = require('../utils/requestLogPath');
/**
* Route helper utilities for standardized request handling.
* Provides async error wrapping, validation, and response formatting.
@@ -93,7 +94,7 @@ const successResponse = (res, data, statusCode = 200, message = null) => {
*/
const errorResponse = (res, error, statusCode = 500, publicMessage) => {
const message = publicMessage || (error instanceof Error ? error.message : String(error));
const route = res.req ? `${res.req.method} ${res.req.originalUrl}` : null;
const route = res.req ? `${res.req.method} ${requestLogPath(res.req.originalUrl)}` : null;
logger.error(route ? `${route} - ${message}` : message, {
error: error instanceof Error ? error.message : error,
stack: error instanceof Error ? error.stack : undefined
+5 -8
View File
@@ -145,15 +145,12 @@ async function cleanupExpiredRevocations() {
/**
* Initialize cleanup job for expired revocations
*/
function initializeRevocationCleanup() {
// Run cleanup every 6 hours
setInterval(cleanupExpiredRevocations, 6 * 60 * 60 * 1000);
// Run initial cleanup
cleanupExpiredRevocations();
}
const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupExpiredRevocations, { interval: 6 * 60 * 60 * 1000, initialDelay: 0 });
function initializeRevocationCleanup() { cleanupTask.start(); }
const stopRevocationCleanup = () => cleanupTask.stop();
module.exports = {
module.exports = { buildTokenId,
stopRevocationCleanup,
revokeToken,
isTokenRevoked,
revokeAllUserTokens,