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('