Files
visitor-signin/src/server.js
T
2026-09-16 10:05:57 +10:00

235 lines
8.0 KiB
JavaScript

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();