import express from 'express'; import session from 'express-session'; import http from 'node:http'; import https from 'node:https'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import config from './config.js'; import db from './db.js'; import kioskRoutes from './routes/kiosk.js'; import adminRoutes from './routes/admin.js'; import * as sheets from './sheets.js'; import * as users from './users.js'; import * as tls from './tls.js'; import { purgeOldPhotos } from './photos.js'; import { localHm, nowIso } from './util.js'; users.bootstrap(); const here = path.dirname(fileURLToPath(import.meta.url)); const publicDir = path.join(here, '..', 'public'); const app = express(); if (config.trustProxy) app.set('trust proxy', 1); app.disable('x-powered-by'); // Photos arrive as base64 data URLs in the sign-in payload. app.use(express.json({ limit: '8mb' })); app.use( session({ secret: config.appSecret, resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: 'lax', secure: config.secureCookies, maxAge: 8 * 60 * 60 * 1000, }, }) ); app.use('/api', kioskRoutes); app.use('/admin/api', adminRoutes); app.get('/healthz', (req, res) => { res.json({ ok: true, onSite: db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL').get().n }); }); /* ------------------------------------------------------------ admin pages */ // The console and the sign in screen are separate documents, so these must be // declared before express.static or it would serve them itself and skip the // redirect that keeps an unauthenticated browser off the console. function sessionUser(req) { if (!req.session?.adminUserId) return null; const user = users.findById(req.session.adminUserId); return user && user.active ? user : null; } app.get('/admin', (req, res) => { const user = sessionUser(req); if (!user || user.must_change_password) return res.redirect('/admin/login'); res.sendFile(path.join(publicDir, 'admin.html')); }); app.get('/admin/login', (req, res) => { const user = sessionUser(req); if (user && !user.must_change_password) return res.redirect('/admin'); res.sendFile(path.join(publicDir, 'login.html')); }); // Nobody should land on the raw filenames; keep one address per page. app.get(['/admin.html', '/login.html'], (req, res) => res.redirect('/admin')); // no-cache still allows a 304 on an unchanged file, but forces the browser to ask // first. Without it a cached admin.js survives an upgrade and the console keeps // running yesterday's code against today's server, which is impossible to diagnose // from the outside. app.use( express.static(publicDir, { extensions: ['html'], index: false, setHeaders: (res, filePath) => { if (/\.(html|js|css)$/.test(filePath)) res.setHeader('Cache-Control', 'no-cache'); }, }) ); app.get('/favicon.ico', (req, res) => res.redirect(301, '/favicon.svg')); app.use((req, res) => res.status(404).sendFile(path.join(publicDir, 'index.html'))); app.use((err, req, res, next) => { console.error('[error]', err); res.status(500).json({ error: 'Something went wrong on the server.' }); }); /* ------------------------------------------------------- background jobs */ sheets.startWorker(); setInterval(purgeOldPhotos, 24 * 60 * 60 * 1000).unref(); purgeOldPhotos(); if (config.autoSignOutTime) { let lastRunDay = ''; setInterval(() => { const today = new Date().toISOString().slice(0, 10); if (lastRunDay === today) return; if (localHm() < config.autoSignOutTime) return; lastRunDay = today; const open = db.prepare('SELECT * FROM visits WHERE signed_out_at IS NULL').all(); for (const visit of open) { db.prepare('UPDATE visits SET signed_out_at = ?, signed_out_by = ? WHERE id = ?').run( nowIso(), 'auto', visit.id ); sheets.mirror(); } if (open.length) console.log(`[auto] signed out ${open.length} visitor(s) still on site`); }, 60000).unref(); } /* ------------------------------------------------------------- listen */ /** * A plain http listener that does two jobs: hands out the CA certificate (so a new * tablet can fetch it without first trusting the very certificate it is missing), * and pushes everything else to https. */ function startRedirectServer() { const port = config.https.redirectPort; if (!port) return; http .createServer((req, res) => { // A zip, because browsers block bare certificate downloads. if (req.url === '/ca.zip') { try { const zip = tls.caBundleZip(); if (!zip) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('No certificate authority has been generated yet.'); } res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="visitor-signin-certificates.zip"', 'Content-Length': zip.length, }); return res.end(zip); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); return res.end(`Could not build the bundle: ${err.message}`); } } // DER for Apple tooling, PEM for everything else. if (req.url === '/ca.cer' || req.url === '/ca.der') { try { const der = tls.caCertificateDer(); if (!der) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('No certificate authority has been generated yet.'); } res.writeHead(200, { 'Content-Type': 'application/pkix-cert', 'Content-Disposition': 'attachment; filename="visitor-signin-ca.cer"', }); return res.end(der); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); return res.end(`Could not convert the certificate: ${err.message}`); } } if (req.url === '/ca.crt' || req.url === '/ca.pem') { const ca = tls.caCertificate(); if (!ca) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('No certificate authority has been generated yet.'); } res.writeHead(200, { 'Content-Type': 'application/x-x509-ca-cert', 'Content-Disposition': 'attachment; filename="visitor-signin-ca.crt"', }); return res.end(ca); } const host = String(req.headers.host || '').split(':')[0]; const target = `https://${host}:${config.https.publicPort}${req.url}`; res.writeHead(302, { Location: target }); res.end(`Moved to ${target}`); }) .listen(port, () => { console.log(`[server] http helper on port ${port} — serves /ca.crt, redirects to https`); }); } function start() { if (!config.https.enabled) { http.createServer(app).listen(config.port, () => { console.log(`[server] ${config.siteName} listening on http://0.0.0.0:${config.port}`); console.log('[server] camera capture needs HTTPS or localhost — see README before rolling out'); }); return; } let material; try { material = tls.ensureCertificates(); } catch (err) { console.error(`[tls] ${err.message}`); process.exit(1); } let server = https.createServer({ key: material.key, cert: material.cert }, app); server.listen(config.port, () => { const names = material.info.server?.names?.join(', ') || 'this host'; console.log(`[server] ${config.siteName} listening on https://0.0.0.0:${config.port}`); console.log(`[tls] certificate valid for ${names}`); console.log(`[tls] expires ${material.info.server?.validTo} (${material.info.server?.daysLeft} days)`); console.log('[tls] install the CA on each kiosk device — see README'); }); // Swap the certificate in without dropping the listener when it renews. tls.scheduleRenewal(() => { const fresh = tls.ensureCertificates(); server.setSecureContext({ key: fresh.key, cert: fresh.cert }); console.log('[tls] certificate renewed and reloaded without a restart'); }); startRedirectServer(); } start();