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 `${displayName}
${tagline}${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 `