require('dotenv').config(); // Validate critical environment variables before proceeding const { validateEnvironment } = require('./src/config/validateEnv'); validateEnvironment(); // Resolve which database engine this process should use, BEFORE anything // requires knexfile/db (#1038). wait-for-db.sh normally does this and exports // DATABASE_CLIENT, but a Kubernetes manifest that sets `command`/`args`, or a // plain `docker run … node server.js`, bypasses the entrypoint entirely — and // those are exactly the deployments this fix is for. Without this, such an // install would resolve to Postgres (NODE_ENV is baked into the image now) and // come up against an empty database while its SQLite data sat there unseen. // // spawnSync because the decision needs an async Postgres probe and this must // happen before the first `require` of knexfile. It short-circuits without // probing when DATABASE_CLIENT is already set, so the entrypoint path pays // nothing. // Also run it when a migration pin exists: an explicit DATABASE_CLIENT=pg // would otherwise skip the check and start against a half-migrated Postgres // while SQLite is still the database of record. if (!process.env.DATABASE_CLIENT || require('./src/utils/databaseEngine').hasMigrationInProgress()) { const { spawnSync } = require('child_process'); const probe = spawnSync( process.execPath, [require('path').join(__dirname, 'scripts', 'resolve-db-engine.js')], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] } ); // Exit 3: two populated databases and no record of which is authoritative. // The resolver has printed the comparison and the two ways to resolve it; // starting either engine would hide the other's data. if (probe.status === 3) { process.exit(1); } const resolved = (probe.stdout || '').trim(); if (probe.status === 0 && resolved) { process.env.DATABASE_CLIENT = resolved; // Pin the CONNECTION too, not just the client. knexfile's development block // defaults Postgres to localhost/postgres/photo_sharing and production to // db/picpeak/picpeak, so naming only the client can point this process at a // different database than the resolver probed — with SQLite already retired. if (resolved === 'pg') { const conn = require('./src/utils/databaseEngine').pgConnectionFromEnv(); process.env.DB_HOST = String(conn.host); process.env.DB_PORT = String(conn.port); process.env.DB_USER = String(conn.user); process.env.DB_NAME = String(conn.database); } } } // Initialize logger early to capture startup logs const logger = require('./src/utils/logger'); logger.info('Server starting up', { nodeVersion: process.version, environment: process.env.NODE_ENV || 'development', // Which database this process actually talks to (#1038). Nothing logged this // before, so an install silently running on SQLite with Postgres configured // had no way to notice. database: require('./src/utils/databaseEngine').describeEngine(require('./knexfile')), timestamp: new Date().toISOString() }); const fs = require('fs'); const express = require('express'); const helmet = require('helmet'); const compression = require('compression'); const cors = require('cors'); const path = require('path'); const { initializeDatabase, db } = require('./src/database/db'); const { getFrontendBaseUrlSync, getAbsoluteFrontendUrl, primeSiteUrlCache, } = require('./src/utils/frontendUrl'); const { startFileWatcher } = require('./src/services/fileWatcher'); const { startExpirationChecker } = require('./src/services/expirationChecker'); const { startTransferCleanup } = require('./src/services/transferCleanupService'); const { startDownloadJobCleanup } = require('./src/services/downloadJobCleanupService'); const { startRevealScheduler } = require('./src/services/revealScheduler'); const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService'); const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor'); const emailWebhookTransport = require('./src/services/emailWebhookTransport'); const { startBackupService } = require('./src/services/backupService'); const { startScheduledBackups } = require('./src/services/databaseBackup'); const backgroundProcessor = require('./src/services/backgroundProcessor'); const { maintenanceMiddleware } = require('./src/middleware/maintenance'); const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout'); const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler'); const rateLimitService = require('./src/services/rateLimitService'); const { createApiRateLimitGate } = require('./src/middleware/apiRateLimitGate'); const { createAuthRateLimitGate } = require('./src/middleware/authRateLimitGate'); const { getPublicSitePayload } = require('./src/services/publicSiteService'); const cookieParser = require('cookie-parser'); const { getAdminTokenFromRequest, getGalleryTokenFromRequest, } = require('./src/utils/tokenUtils'); // Import routes const authRoutes = require('./src/routes/auth'); const galleryRoutes = require('./src/routes/gallery'); const adminRoutes = require('./src/routes/admin'); const adminAuthRoutes = require('./src/routes/adminAuth'); const secureImagesRoutes = require('./src/routes/secureImages'); const setupRoutes = require('./src/routes/setup'); const app = express(); const PORT = process.env.PORT || 3000; // Trust proxy headers (required for Traefik/nginx). // // `req.ip` is computed by Express by walking X-Forwarded-For from // right-to-left and stopping at the first hop NOT in this list, so // the value picpeak audits (signing IPs, payment-check actions, // rate-limit keys) is the originating client IP behind any number // of trusted reverse proxies. // // Default: 'loopback, linklocal, uniquelocal' — covers localhost, // link-local (169.254.0.0/16), and unique-local IPv6 (fc00::/7). // Standard for nginx-in-front-of-Node deployments on the same host // and for Docker bridge networks. Operators with unusual topologies // (load balancer in a public subnet, multi-hop NAT) override via // TRUST_PROXY env, accepting any value Express accepts: a number, // 'loopback', 'linklocal', 'uniquelocal', a CIDR, a comma list, or // 'true' (trust ALL proxies — only safe behind a fully-controlled // reverse-proxy chain). // // NEVER read req.headers['x-forwarded-for'] directly in audit paths // — see utils/clientIp.js for the rationale. const trustProxySetting = process.env.TRUST_PROXY || 'loopback, linklocal, uniquelocal'; app.set('trust proxy', trustProxySetting === 'true' ? true : trustProxySetting); // Security middleware with custom CSP // In native HTTP installs, do NOT force HTTPS for subresources. const enableHsts = process.env.ENABLE_HSTS === 'true'; const cspDirectives = { defaultSrc: ["'self'"], scriptSrc: [ "'self'", 'https://www.google.com', 'https://www.gstatic.com' ], styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images connectSrc: ["'self'", 'https://www.google.com', 'https://www.gstatic.com'], // API connections fontSrc: ["'self'", "https:", "data:"], // Web fonts objectSrc: ["'none'"], // Disable plugins mediaSrc: ["'self'"], // Audio/video frameSrc: ["'self'", 'https://www.google.com'], }; // Only upgrade insecure requests when HSTS explicitly enabled (HTTPS deployment) if (enableHsts) { // In helmet, an empty array enables the directive cspDirectives.upgradeInsecureRequests = []; } app.use(cookieParser()); app.use((req, res, next) => { if (req.headers.authorization) { return next(); } const path = req.path || ''; const slugMatch = path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/); const slug = slugMatch ? slugMatch[1] : req.requestedSlug; const adminToken = getAdminTokenFromRequest(req); const galleryToken = getGalleryTokenFromRequest(req, slug); const isAdminRequest = path.startsWith('/api/admin') || path.startsWith('/admin'); const isGalleryRequest = Boolean(slugMatch) || path.startsWith('/api/gallery') || path.startsWith('/gallery') || path.startsWith('/api/secure-images'); // Prefer admin credentials on admin routes so gallery sessions cannot override them. if (isAdminRequest) { if (adminToken) { req.headers.authorization = `Bearer ${adminToken}`; } } else if (isGalleryRequest) { if (galleryToken) { req.headers.authorization = `Bearer ${galleryToken}`; } else if (adminToken) { req.headers.authorization = `Bearer ${adminToken}`; } } else if (adminToken) { req.headers.authorization = `Bearer ${adminToken}`; } else if (galleryToken) { req.headers.authorization = `Bearer ${galleryToken}`; } next(); }); app.use(helmet({ contentSecurityPolicy: { // Avoid helmet adding defaults like upgrade-insecure-requests when not desired useDefaults: false, directives: cspDirectives, }, hsts: enableHsts ? { maxAge: 31536000, // 1 year includeSubDomains: true, preload: true } : false, permittedCrossDomainPolicies: false, referrerPolicy: { policy: "strict-origin-when-cross-origin" } })); // Additional security headers app.use((req, res, next) => { // Permissions Policy (controls browser features) res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()'); next(); }); // CORS configuration (apply only to API routes) const { isAllowedOrigin } = require('./src/utils/requestOrigin'); const corsOptions = { origin: function (origin, callback) { // getFrontendBaseUrlSync() resolves FRONTEND_URL, else the configured // general_site_url (#705) — without it, an install that leaves the // environment untouched and answers the setup wizard instead would have // its own public origin missing from the allowlist. // Allow requests with no origin (like curl) and allow-listed origins if (!origin || isAllowedOrigin(origin)) { callback(null, true); } else { // Do not error globally; just omit CORS headers on disallowed origins callback(null, false); } }, credentials: true, // Expose Content-Disposition so split (cross-origin) frontend // deployments can read the server's chosen download filename. Used // by the gallery/admin download flows to honour the #493 "original // camera filename" toggle on individual photo downloads (#507). // // Retry-After is not CORS-safelisted either. AuthenticatedImage reads it // off a 429 to wait out the rate-limit window before retrying a thumbnail // fetch; without it a split-origin deployment would spend its retry budget // inside the window and leave the tile blank after the limit had lifted. exposedHeaders: ['Content-Disposition', 'Retry-After'], }; // Only attach CORS to API endpoints, not static assets app.use('/api', cors(corsOptions)); // Handle preflight explicitly for API paths app.options('/api/*', cors(corsOptions)); // Same-origin proxy for the configured analytics tracker. Mounted HERE, ahead // of the body parsers, so the tracker's beacon payload reaches the proxy as a // raw buffer (express.json would consume it, and the CSRF Content-Type gate // below would 415 a navigator.sendBeacon `text/plain` POST). It carries no // PicPeak state and reads no PicPeak credentials — see the route file for the // SSRF/path-allowlist model. app.use('/api/analytics/tracker', require('./src/routes/analyticsTrackerProxy')); // Health check endpoint. `pid` + `uptime` let monitors (and the local E2E // watchdog) detect a silent process restart between two checks. // // Served at BOTH paths: /health is the canonical one, /api/health exists // because probes reasonably assume the API lives under /api and otherwise // produce a "route not found" warning on every poll. Same handler, same body, // same (public) exposure — the alias adds no information. // // Mounted HERE, ahead of the API middleware chain, so a probe running every // couple of seconds does not pay for — or pollute — the body parsers, the // request logger, the maintenance gate (/health is on its skip list anyway) // or the rate limiter's per-IP budget. app.get(['/health', '/api/health'], async (req, res) => { try { await db.raw('SELECT 1'); res.json({ status: 'ok', timestamp: new Date().toISOString(), pid: process.pid, uptime: process.uptime() }); } catch (error) { logger.error('Health check failed:', error); res.status(503).json({ status: 'error', timestamp: new Date().toISOString(), pid: process.pid, uptime: process.uptime() }); } }); // Initialize rate limiters (they will be created dynamically) function composeInlineStyles(payload) { const { branding } = payload; const cssSegments = []; cssSegments.push(`:root { --brand-primary: ${branding.colors.primary}; --brand-accent: ${branding.colors.accent}; --brand-background: ${branding.colors.background}; --brand-text: ${branding.colors.text}; --brand-surface: ${branding.colors.surface || '#ffffff'}; --brand-elevated: ${branding.colors.elevated || '#f5f5f5'}; --brand-border: ${branding.colors.border || '#e5e5e5'}; --brand-muted-text: ${branding.colors.mutedText || '#737373'}; }`); if (payload.baseCss) { cssSegments.push(payload.baseCss); } if (payload.css) { cssSegments.push(`/* Custom styles */\n${payload.css}`); } return cssSegments.join('\n\n'); } function escapeHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function renderBrandHeader(branding) { const displayName = escapeHtml(branding.companyName || 'PicPeak'); const logoSrc = encodeURI(branding.logoUrl || '/picpeak-logo-transparent.png'); const logo = ``; const tagline = branding.companyTagline ? `

${escapeHtml(branding.companyTagline)}

` : ''; return ``; } function renderBrandFooter(branding) { const displayName = escapeHtml(branding.companyName || 'PicPeak'); const footerNote = branding.footerText ? `

${escapeHtml(branding.footerText)}

` : '

Powered by PicPeak to keep every celebration beautifully organised.

'; const supportEmail = escapeHtml(branding.supportEmail || ''); const supportLink = supportEmail ? `Support` : ''; const legalLinks = ` Privacy Policy Impressum ${supportLink} `; return ``; } function buildSeoMetaTags(seoSettings) { const tags = []; const robotsDirectives = []; if (seoSettings.seo_meta_noindex) robotsDirectives.push('noindex'); if (seoSettings.seo_meta_nofollow) robotsDirectives.push('nofollow'); if (robotsDirectives.length > 0) { tags.push(``); } if (seoSettings.seo_meta_noai) { tags.push(''); } return tags.join('\n '); } function buildPublicSiteDocument(payload) { const inlineStyles = composeInlineStyles(payload); const header = renderBrandHeader(payload.branding); const footer = renderBrandFooter(payload.branding); const seoMeta = payload.seoSettings ? buildSeoMetaTags(payload.seoSettings) : ''; return ` ${escapeHtml(payload.title)} ${seoMeta}
${header}
${payload.html}
${footer}
`; } 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() { // The instances live in rateLimitService so the settings route can // rebuild them when the window changes (#1337); the gates below read // them per request through the service's getters. await rateLimitService.initializeRateLimiters(); // Neither limiter is registered here — an app.use() at this point runs after // the routers, the /api 404 handler and the error handler are already on the // stack, so it can never see a request. Both are reached through their gates // below, which are registered ahead of the routers and read these variables // per request. // // The five prefix registrations of authRateLimiter that used to live here // were inert for that reason, and could not simply be moved up either: the // widest of them mounted on the whole /api/auth router, so the 5-per-window // auth budget would have covered GET /api/auth/session and POST // /api/auth/password-strength, which the frontend calls far more often than // five times per window. authRateLimitGate matches exact method + path // instead, and counts only failed attempts. } // Rate limiting for /api. Registered HERE — above the routers — because // Express dispatches in registration order; see apiRateLimitGate for the full // story. The gate is a no-op until initializeRateLimiters() resolves, and it // must stay unmounted (no path argument) so req.path keeps its /api prefix. // /health and /api/health are mounted above this point and so are never // counted, which matters because monitors poll them every couple of seconds. app.use(createApiRateLimitGate(rateLimitService.getGeneralLimiter)); // Per-IP limit for credential-verification endpoints only, on its own bucket. // Registered after the general gate so that an IP already over the /api budget // is rejected there first; see authRateLimitGate for the exact endpoint table // and why it must stay unmounted. app.use(createAuthRateLimitGate(rateLimitService.getAuthLimiter)); // Body limits. 50mb is only needed by the authenticated admin and API-token // surfaces (restore manifests, CMS and email templates, bulk operations); // applied globally it let any unauthenticated caller hand JSON.parse a 50mb // body and block the event loop. express.json skips a request whose body // is already parsed, so the scoped parser must run first. app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' })); app.use(express.json({ limit: '2mb' })); app.use(express.urlencoded({ extended: true, limit: '2mb' })); // Validate the origin independently of body length/content type. app.use('/api', require('./src/middleware/csrf')); app.use('/api', require('./src/middleware/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 = [ getFrontendBaseUrlSync() || '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'; // The /photos and /thumbnails static mounts are gone. // // They served the raw originals tree and the thumbnail tree behind photoAuth // alone, which authorises on a slug match. A static file server cannot apply // the rules the gallery API applies per photo, so everything the API decides // was simply absent here: allow_downloads, per-category allow_downloads, // watermarking, the resolution cap, reveal-mode windows, visibility='hidden', // download logging, and the customer-assignment re-check that lets an admin // revoke access immediately. The filenames needed to exercise it are handed to // every guest in the photos listing. // // Nothing builds these URLs: no reference in frontend/src, none in the email // templates, and the only backend mentions are the /api/admin/photos/... API // routes and a maintenance-mode prefix list. nginx still proxies /photos and // /thumbnails; those locations now 404, which is the intended outcome. // // Serving these safely would mean reimplementing per-photo authorisation and // image processing inside a static handler -- i.e. the gallery API, which // already exists at /api/gallery/:slug/photo/:id and /thumbnail/:id. // Static file serving for uploads. // // Narrowed to the two public asset trees. The mount used to expose the whole // uploads/ root with no auth middleware at all, and that root also holds // signed contract PDFs (uploads/contracts/signed) and client transfer files // (uploads/transfers/) -- both reachable by anyone who learned or guessed // a filename. Those are served by their own authorised routes. app.use('/uploads/logos', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads/logos'))); app.use('/uploads/favicons', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads/favicons'))); // 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 https://docs.picpeak.app/guides/custom-fonts // "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 = await getAbsoluteFrontendUrl(req); 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\//, ''); // Containment is the two public asset trees, not the whole uploads/ // root: that root also holds signed contracts and client transfer // files, and the favicon URL is an admin-writable setting, so the // wider check let `/uploads/contracts/signed/` be served here // unauthenticated with a day of cache. const uploadsRoot = path.resolve(path.join(storagePath, 'uploads')); const resolved = path.resolve(path.join(uploadsRoot, rel)); const servableRoots = ['favicons', 'logos'].map((d) => path.join(uploadsRoot, d) + path.sep); if (servableRoots.some((root) => resolved.startsWith(root)) && 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