Public Access
235 lines
8.0 KiB
JavaScript
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();
|