const express = require('express');
const http = require('http');
const https = require('https');
const { Server } = require('socket.io');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const config = require('./config');
const VERSION = require('./version');
// #114: last-resort crash safety net. better-sqlite3 is SYNCHRONOUS, so a constraint
// violation (e.g. a FK write) inside a socket.io handler with no local try/catch
// propagates to uncaughtException; Node's default then prints a bare message and exits
// with NO stack — which is exactly why #114's "FOREIGN KEY constraint failed" couldn't
// be root-caused. This handler logs the FULL STACK (the file:line of the offending
// write) then exits(1) so systemd restarts a fresh process. It is NOT catch-and-
// continue: after an uncaught throw the process state is undefined, so we never keep
// serving. Registered before everything else so it's in place during startup too.
// (Verified: uncaughtException does catch a synchronous socket.io-handler throw.)
function logFatalAndExit(kind, err) {
try {
const e = err instanceof Error ? err : new Error('Non-error thrown: ' + require('util').inspect(err));
process.stderr.write(`\n[FATAL ${kind}] ${new Date().toISOString()}\n${e.stack || e.message}\n`);
} catch (_) { /* the death handler must never throw */ }
try { require('./db/database').db.close(); } catch (_) { /* best-effort WAL flush */ }
process.exit(1);
}
process.on('uncaughtException', (err) => logFatalAndExit('uncaughtException', err));
process.on('unhandledRejection', (reason) => logFatalAndExit('unhandledRejection', reason));
// Ensure upload directories exist
[config.contentDir, config.screenshotsDir].forEach(dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
const app = express();
const { trustedProxies } = require('./config/cloudflareIps');
const { getClientIp } = require('./services/activity');
// Trust loopback / link-local / unique-local (local dev, LAN reverse proxies)
// and Cloudflare's published edge ranges. With this list, req.ip resolves to
// the original client when fronted by Cloudflare; X-Forwarded-For from any
// non-trusted source is ignored, so the value can't be spoofed.
app.set('trust proxy', trustedProxies);
// Determine if SSL certs are available
const hasSsl = fs.existsSync(config.sslCert) && fs.existsSync(config.sslKey);
let server;
if (hasSsl) {
const sslOptions = {
cert: fs.readFileSync(config.sslCert),
key: fs.readFileSync(config.sslKey),
};
server = https.createServer(sslOptions, app);
} else {
server = http.createServer(app);
}
// Socket.IO CORS is checked via the same corsOriginCheck function defined below
// (after config is loaded). Hoisted into a closure so we can reference it before
// the function is defined — at first connection time, corsOriginCheck exists.
const io = new Server(server, {
cors: {
origin: (origin, cb) => corsOriginCheck(origin, cb),
credentials: true,
},
maxHttpBufferSize: 10 * 1024 * 1024, // 10MB for screenshot uploads
pingInterval: config.pingInterval,
pingTimeout: config.pingTimeout,
});
// Middleware
const helmet = require('helmet');
// CSP applies to the dashboard / app pages only. Widget and kiosk renders are
// publicly accessed by devices and intentionally use inline scripts/styles —
// they're served from /api/widgets/:id/render and /api/kiosk/:id/render and
// skip the CSP layer below via path-based opt-out.
//
// scriptSrc 'self' blocks \n';
// Inject right before the debug-overlay.js script tag. If for any reason
// the tag isn't present (e.g. file edited out), fall back to injecting
// before so the flag still lands.
let modified;
if (html.indexOf('