Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
+183
@@ -0,0 +1,183 @@
|
||||
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'));
|
||||
|
||||
app.use(express.static(publicDir, { extensions: ['html'], index: false }));
|
||||
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) => {
|
||||
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();
|
||||
Reference in New Issue
Block a user