`;
}
async function handlePublicSiteRequest(req, res, next) {
try {
const payload = await getPublicSitePayload();
if (!payload.enabled) {
res.redirect(302, '/admin/login');
return;
}
if (payload.etag && req.headers['if-none-match'] === payload.etag) {
res.status(304).end();
return;
}
// Inject SEO meta settings into payload
try {
const seoRows = await db('app_settings')
.where('setting_type', 'seo')
.whereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai'])
.select('setting_key', 'setting_value');
const seoSettings = {};
for (const row of seoRows) {
let val = row.setting_value;
if (typeof val === 'string') { try { val = JSON.parse(val); } catch {} }
seoSettings[row.setting_key] = val;
}
payload.seoSettings = seoSettings;
} catch {}
const document = buildPublicSiteDocument(payload);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=30, must-revalidate');
res.setHeader('ETag', payload.etag);
res.setHeader('Vary', 'Accept-Encoding');
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https: data:; object-src 'none'; script-src 'self'; form-action 'self'");
res.status(200).send(document);
} catch (error) {
logger.error('Failed to render public site', { error: error.message });
next();
}
}
// Function to initialize rate limiters
async function initializeRateLimiters() {
generalRateLimiter = await createRateLimiter();
authRateLimiter = await createAuthRateLimiter();
// Apply rate limiting
app.use('/api/', generalRateLimiter);
app.use('/api/auth', authRateLimiter);
app.use('/api/gallery/:slug/verify', authRateLimiter);
app.use('/api/admin/auth/login', authRateLimiter);
app.use('/api/setup/admin', authRateLimiter);
app.use('/api/setup/verify-token', authRateLimiter);
}
// Note: Rate limiters will be initialized after database connection
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// CSRF protection: require JSON Content-Type on mutating API requests
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
app.use('/api', (req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const contentType = req.headers['content-type'] || '';
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
// Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
}
}
next();
});
// Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => {
try {
const started = Date.now();
const ts = new Date().toISOString();
logger.info(`[${ts}] ${req.method} ${req.originalUrl}`);
res.on('finish', () => {
const ms = Date.now() - started;
const tsDone = new Date().toISOString();
logger.info(`[${tsDone}] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
});
} catch (_) {}
next();
};
app.use('/api', apiRequestLogger);
// Maintenance mode middleware - add after body parsing but before routes
app.use(maintenanceMiddleware);
// Session timeout middleware for admin routes
app.use('/api/admin', sessionTimeoutMiddleware);
// Middleware to set CORS headers for static files
const setCorsHeaders = (req, res, next) => {
const origin = req.headers.origin;
const staticAllowedOrigins = [
process.env.FRONTEND_URL || 'http://localhost:3005',
process.env.ADMIN_URL || 'http://localhost:3005'
];
if (process.env.NODE_ENV === 'development') {
staticAllowedOrigins.push(
'http://localhost:5173',
'http://localhost:3002',
'http://localhost:3001',
'http://localhost:3000'
);
}
if (origin && staticAllowedOrigins.indexOf(origin) !== -1) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
}
res.header('Cross-Origin-Resource-Policy', 'cross-origin');
next();
};
// Import secure static middleware
const secureStatic = require('./src/middleware/secureStatic');
// Get storage path from environment or use default
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
process.env.EXTERNAL_MEDIA_ROOT = process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
// Static file serving for photos (protected)
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
// Static file serving for thumbnails (protected)
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'thumbnails')));
// Static file serving for uploads (public - logos, favicons)
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
// Static file serving for self-hosted webfonts (public — gallery visitors
// load these via @font-face). Replaces the previous Google Fonts CDN
// dependency, which leaked visitor IPs to a third party (LG München 2022
// GDPR ruling).
//
// Two mounts in priority order:
// 1. STORAGE_PATH/fonts/ — runtime user additions (drop a folder, restart)
// 2. backend/assets/fonts/ — bundled defaults baked into the image
// Express evaluates handlers in order, so user-supplied files win on overlap.
//
// We deliberately do NOT set `immutable` on these responses. The filenames
// are stable (e.g. Inter/400.woff2), so an admin replacing the file on disk
// must be able to roll out the change to clients. With max-age + Last-Modified
// (set by express.static from file mtime), browsers send If-Modified-Since
// after expiry and pick up the new version automatically. See docs/fonts.md
// "Replacing an existing font" for the documented rollout strategy.
const fontStaticOpts = { maxAge: '7d' };
app.use(
'/fonts',
setCorsHeaders,
secureStatic(path.join(storagePath, 'fonts'), fontStaticOpts)
);
app.use(
'/fonts',
setCorsHeaders,
secureStatic(path.resolve(__dirname, 'assets/fonts'), fontStaticOpts)
);
// Debug endpoint to check IP detection (only in development)
if (process.env.NODE_ENV === 'development') {
app.get('/api/debug/ip', (req, res) => {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
res.json({
detectedIp: clientIp,
reqIp: req.ip,
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip'],
'x-forwarded-proto': req.headers['x-forwarded-proto'],
'x-forwarded-host': req.headers['x-forwarded-host']
},
trustProxy: app.get('trust proxy')
});
});
}
// OG/Twitter-card preview endpoint for gallery share URLs. Crawlers (WhatsApp,
// Slack, Facebook, etc.) don't execute JS, so the SPA's client-side meta tags
// never reach them. nginx routes UA-detected crawlers from /gallery/:slug to
// here; humans still get the SPA via try_files.
const {
isSocialCrawler,
handleGalleryOgRequest,
handleGalleryOgCover,
} = require('./src/services/galleryOgService');
app.get('/og/gallery/:slug', handleGalleryOgRequest);
// Public hero-photo cover served as og:image when the admin has flipped
// events.og_image_share_enabled (#474). Unauthenticated by design;
// returns 404 unless the opt-in is on AND a hero_photo_id is set.
app.get('/og/gallery/:slug/cover', handleGalleryOgCover);
// Branded URL shortener (#699). /s/ is bot-UA aware:
// - Social crawler → server-render OG for the target event so the
// SHORT URL itself is what scrapes cache against. The og:url canonical
// in the rendered HTML points back at /s/, not the underlying
// gallery URL — so a re-share of the same short URL keeps the cache
// warm even if the underlying gallery slug rotates.
// - Browser → 302 to the stored target_path. The target_path was
// captured at create time from the event's slug + share_token + the
// global "Use short gallery URLs" setting, so it doesn't silently
// change later.
// - Soft-deleted → 410 Gone so the admin can tell their delete worked
// vs. a typo'd unknown slug (which returns 404).
const galleryShortUrlService = require('./src/services/galleryShortUrlService');
const { buildOgMetadata, renderOgHtml } = require('./src/services/galleryOgService');
app.get('/s/:shortSlug', async (req, res) => {
try {
const row = await galleryShortUrlService.findByShortSlug(req.params.shortSlug);
if (!row) {
return res.status(404).type('text/plain').send('Short URL not found');
}
if (row.deleted_at) {
return res.status(410).type('text/plain').send('Short URL has been removed');
}
// Bot UA → render OG metadata for the target event. We look up the
// event via the short URL's event_id rather than re-parsing the
// target_path so a future migration that adds new target shapes
// (slideshow, client-access) doesn't need to rewrite the URL parser.
if (isSocialCrawler(req.get('user-agent'))) {
const event = await require('./src/database/db').db('events')
.where({ id: row.event_id })
.first('slug');
if (event?.slug) {
const meta = await buildOgMetadata(event.slug, req.originalUrl);
// Override the canonical to point at the SHORT URL itself —
// social platforms cache OG by URL, and the short URL is the
// one operators actually share, so that's the cache key we
// want them to stick with.
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
meta.url = `${base}/s/${row.short_slug}`;
res.set('Cache-Control', 'public, max-age=300');
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(renderOgHtml(meta));
// Hit accounting is fire-and-forget — don't block the bot.
galleryShortUrlService.recordHit(row.id).catch(() => {});
return;
}
// Event disappeared (FK CASCADE in flight, or admin hard-deleted
// outside the normal soft-delete path) — fall through to 410 so
// the scraper sees a clean signal.
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
}
// Browser path: redirect. Hit accounting is fire-and-forget.
galleryShortUrlService.recordHit(row.id).catch(() => {});
return res.redirect(302, row.target_path);
} catch (err) {
logger.error('Short URL resolver failed', { slug: req.params.shortSlug, error: err.message });
return res.status(500).type('text/plain').send('Internal server error');
}
});
// robots.txt endpoint (dynamic, served from DB settings)
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
app.get('/robots.txt', async (req, res) => {
try {
const robotsTxt = await generateRobotsTxt();
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.status(200).send(robotsTxt);
} catch (error) {
logger.error('Failed to generate robots.txt', { error: error.message });
// Safe default for a private photo platform
res.setHeader('Content-Type', 'text/plain');
res.status(200).send('User-agent: *\nDisallow: /\n');
}
});
// Dynamic favicon endpoints. Browsers — notably Safari — request
// /favicon.ico and /apple-touch-icon*.png directly at the site root and are
// unreliable about honouring JS-injected tags. Serving the
// admin's configured branding favicon here makes it work without client-side
// JS (and survive aggressive favicon caches). Falls back to the bundled asset
// shipped with the frontend build when no custom favicon is set.
app.get(
['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png'],
async (req, res) => {
try {
const { getAppSetting } = require('./src/utils/appSettings');
const raw = await getAppSetting('branding_favicon_url', null);
const url = (raw && String(raw).trim()) || null;
if (url) {
// External URL — can't stream the bytes, so redirect (best effort).
if (/^https?:\/\//i.test(url)) return res.redirect(302, url);
// Local upload → stream the file bytes DIRECTLY rather than 302'ing.
// Safari does NOT reliably follow a redirect for favicon requests
// (it falls back to the HTML , i.e. the bundled default),
// whereas Firefox/Chrome do — so a 302 worked everywhere except
// Safari. sendFile sets the right content-type from the extension.
const rel = String(url).replace(/^\/+/, '').replace(/^uploads\//, '');
const uploadsRoot = path.resolve(path.join(storagePath, 'uploads'));
const resolved = path.resolve(path.join(uploadsRoot, rel));
// Path containment — never serve outside the uploads dir.
if (resolved.startsWith(uploadsRoot + path.sep) && fs.existsSync(resolved)) {
// This route streams the file directly, bypassing the secureStatic
// middleware — so re-apply its SVG hardening here. An admin-uploaded
// SVG favicon could contain