Files
visitor-signin/src/routes/kiosk.js
T

374 lines
13 KiB
JavaScript

import express from 'express';
import rateLimit from 'express-rate-limit';
import db from '../db.js';
import config from '../config.js';
import { savePhoto, photoAbsolutePath, copyStoredPhoto } from '../photos.js';
import { mirror } from '../sheets.js';
import { verifyPin } from '../pins.js';
import { listSites, resolveSite, badgeHtml } from '../sites.js';
import { themeFor, bannerAbsolutePath } from '../branding.js';
import fs from 'node:fs';
import {
clean,
isEmail,
isPhone,
normaliseEmail,
normalisePhone,
nowIso,
titleCase,
} from '../util.js';
const router = express.Router();
const CHECK_TYPES = new Set(['WWCC', 'VIT', 'NONE']);
const LOCKOUT_FAILS = 5;
const LOCKOUT_MINUTES = 15;
const BADGE_WINDOW_MS = 10 * 60 * 1000;
const signInLimiter = rateLimit({ windowMs: 60000, max: 20, standardHeaders: true });
const pinLimiter = rateLimit({ windowMs: 60000, max: 12, standardHeaders: true });
/** Every kiosk request carries a site, either as ?site=slug or in the body. */
function siteFrom(req) {
return resolveSite(req.query.site ?? req.body?.site ?? req.body?.siteId);
}
router.get('/sites', (req, res) => {
res.json(listSites({ activeOnly: true }).map((s) => ({ id: s.id, name: s.name, slug: s.slug })));
});
router.get('/config', (req, res) => {
const sites = listSites({ activeOnly: true });
const site = siteFrom(req);
res.json({
multiSite: sites.length > 1,
siteChosen: Boolean(site),
site: site
? { id: site.id, name: site.name, slug: site.slug, badgeEnabled: Boolean(site.badge_enabled) }
: null,
siteName: site ? site.name : config.siteName,
requirePhoto: config.requirePhoto,
// Branding for this kiosk: colours are applied as CSS variables and the
// banner replaces the site name in the top bar.
theme: themeFor(site),
banner: site?.banner_path
? { url: `/api/branding/${site.id}/banner`, height: site.banner_height || 64 }
: null,
// Applies to the site name too, so the header looks the same either way.
headerAlign: site?.banner_align || 'left',
});
});
/** The site banner. Public, because the kiosk shows it before anyone signs in. */
router.get('/branding/:id/banner', (req, res) => {
const site = db.prepare('SELECT banner_path FROM sites WHERE id = ?').get(req.params.id);
const abs = site && bannerAbsolutePath(site.banner_path);
if (!abs) return res.status(404).send('No banner set.');
res.setHeader('Cache-Control', 'public, max-age=300');
res.sendFile(abs);
});
router.get('/hosts', (req, res) => {
const site = siteFrom(req);
if (!site) return res.json([]);
res.json(
db
.prepare(
'SELECT id, name, area FROM hosts WHERE active = 1 AND site_id = ? ORDER BY name COLLATE NOCASE'
)
.all(site.id)
);
});
function contactOk(phone, email) {
return (phone && isPhone(phone)) || (email && isEmail(email));
}
/** "Already here" is judged on contact details, whatever name was typed this time. */
function openVisitByContact(siteId, phone, email) {
return db
.prepare(
`SELECT * FROM visits
WHERE signed_out_at IS NULL AND site_id = ?
AND ((? <> '' AND phone = ?) OR (? <> '' AND lower(email) = ?))`
)
.all(siteId, phone, phone, email, email);
}
function openVisitFor(siteId, lastName, phone, email) {
return db
.prepare(
`SELECT * FROM visits
WHERE signed_out_at IS NULL AND site_id = ?
AND lower(last_name) = lower(?)
AND ((? <> '' AND phone = ?) OR (? <> '' AND lower(email) = ?))
ORDER BY signed_in_at DESC`
)
.all(siteId, lastName, phone, phone, email, email);
}
/* -------------------------------------------------------------- sign in */
router.post('/signin', signInLimiter, (req, res) => {
try {
const body = req.body || {};
const site = siteFrom(req);
if (!site) return res.status(400).json({ error: 'This kiosk has no site selected.' });
const isFrequent = body.mode === 'frequent';
let frequent = null;
if (isFrequent) {
frequent = db
.prepare('SELECT * FROM frequent_visitors WHERE id = ? AND active = 1')
.get(body.frequentVisitorId);
if (!frequent) {
return res.status(400).json({ error: 'That recurring visitor record is no longer active.' });
}
if (frequent.site_id && frequent.site_id !== site.id) {
return res.status(403).json({ error: 'Your record is not set up for this site.' });
}
// The kiosk must prove it just passed the PIN check for this person.
if (req.session.frequentVisitorId !== frequent.id) {
return res.status(401).json({ error: 'Enter your PIN again to continue.' });
}
}
const firstName = titleCase(isFrequent ? frequent.first_name : body.firstName, 60);
const lastName = titleCase(isFrequent ? frequent.last_name : body.lastName, 60);
const phone = normalisePhone(isFrequent ? frequent.phone : body.phone);
const email = normaliseEmail(isFrequent ? frequent.email : body.email);
const checkType = isFrequent ? frequent.check_type : clean(body.checkType, 10).toUpperCase();
const checkNumber = clean(isFrequent ? frequent.check_number : body.checkNumber, 40);
const checkExpiry = isFrequent ? frequent.check_expiry : clean(body.checkExpiry, 20);
// Optional: plenty of visitors are not from anywhere in particular.
const company = clean(isFrequent ? frequent.company : body.company, 80);
const visitReason = clean(body.visitReason, 120);
if (!firstName) return res.status(400).json({ error: 'First name is required.' });
if (!lastName) return res.status(400).json({ error: 'Last name is required.' });
if (!CHECK_TYPES.has(checkType)) {
return res.status(400).json({ error: 'Choose WWCC, VIT, or "I don\'t have one".' });
}
if (checkType !== 'NONE' && !checkNumber) {
return res.status(400).json({ error: `Enter your ${checkType} number.` });
}
if (!contactOk(phone, email)) {
return res
.status(400)
.json({ error: 'Add a mobile number or an email address so we can reach you.' });
}
const host = db
.prepare('SELECT * FROM hosts WHERE id = ? AND active = 1 AND site_id = ?')
.get(body.hostId, site.id);
if (!host) return res.status(400).json({ error: 'Choose the person you are visiting.' });
if (openVisitByContact(site.id, phone, email).length) {
return res.status(409).json({
error: `${firstName}, you are already signed in. See the front desk if that looks wrong.`,
});
}
// A recurring visitor with a photo on file is not asked to pose again; the
// stored photo is copied onto this visit as its own snapshot.
let photoPath = null;
if (body.photo) {
photoPath = savePhoto(body.photo);
} else if (isFrequent && frequent.photo_path) {
photoPath = copyStoredPhoto(frequent.photo_path);
}
if (!photoPath && config.requirePhoto) {
return res.status(400).json({ error: 'A photo is required to sign in.' });
}
const signedInAt = nowIso();
const info = db
.prepare(
`INSERT INTO visits
(site_id, site_name, visitor_type, frequent_visitor_id, first_name, last_name, company,
phone, email, check_type, check_number, check_expiry, host_id, host_name, visit_reason,
photo_path, signed_in_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
site.id,
site.name,
isFrequent ? 'frequent' : 'guest',
isFrequent ? frequent.id : null,
firstName,
lastName,
company || null,
phone || null,
email || null,
checkType,
checkNumber || null,
checkExpiry || null,
host.id,
host.name,
visitReason || null,
photoPath,
signedInAt
);
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(info.lastInsertRowid);
mirror();
delete req.session.frequentVisitorId;
// Lets this kiosk session fetch the badge for the visit it just created.
req.session.badgeVisitId = visit.id;
req.session.badgeIssuedAt = Date.now();
res.json({
ok: true,
firstName,
hostName: host.name,
signedInAt,
visitId: visit.id,
badgeUrl: site.badge_enabled ? `/api/badge/${visit.id}` : null,
});
} catch (err) {
console.error('[signin]', err);
res.status(400).json({ error: err.message || 'Sign in could not be completed.' });
}
});
/* --------------------------------------------------------------- badge */
router.get('/badge/:id', (req, res) => {
const visitId = Number(req.params.id);
const fresh =
req.session.badgeVisitId === visitId &&
Date.now() - (req.session.badgeIssuedAt || 0) < BADGE_WINDOW_MS;
if (!fresh) return res.status(403).send('That badge is no longer available at this kiosk.');
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(visitId);
if (!visit) return res.status(404).send('Not found.');
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(visit.site_id);
if (!site || !site.badge_enabled) return res.status(404).send('Badges are off for this site.');
let photoUrl = null;
const abs = photoAbsolutePath(visit.photo_path);
if (abs && site.badge_show_photo) {
// Inlined so the badge prints even if the image request is slow or blocked.
photoUrl = `data:image/jpeg;base64,${fs.readFileSync(abs).toString('base64')}`;
}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(badgeHtml(visit, site, { autoPrint: true, photoUrl }));
});
/* ------------------------------------------------------------- sign out */
router.post('/signout/lookup', signInLimiter, (req, res) => {
const site = siteFrom(req);
if (!site) return res.status(400).json({ error: 'This kiosk has no site selected.' });
const lastName = clean(req.body?.lastName, 60);
const contactRaw = clean(req.body?.contact, 120);
if (!lastName) return res.status(400).json({ error: 'Enter your last name.' });
if (!contactRaw) return res.status(400).json({ error: 'Enter your mobile number or email.' });
const phone = isPhone(contactRaw) ? normalisePhone(contactRaw) : '';
const email = isEmail(contactRaw) ? normaliseEmail(contactRaw) : '';
if (!phone && !email) {
return res.status(400).json({ error: 'That does not look like a mobile number or email.' });
}
const rows = openVisitFor(site.id, lastName, phone, email);
if (!rows.length) {
return res.status(404).json({
error: 'No open visit matches those details. Check the spelling, or ask the front desk.',
});
}
res.json(
rows.map((v) => ({
id: v.id,
firstName: v.first_name,
lastName: v.last_name,
hostName: v.host_name,
signedInAt: v.signed_in_at,
}))
);
});
router.post('/signout', signInLimiter, (req, res) => {
const visit = db
.prepare('SELECT * FROM visits WHERE id = ? AND signed_out_at IS NULL')
.get(req.body?.visitId);
if (!visit) return res.status(404).json({ error: 'That visit is already closed.' });
const signedOutAt = nowIso();
db.prepare('UPDATE visits SET signed_out_at = ?, signed_out_by = ? WHERE id = ?').run(
signedOutAt,
'visitor',
visit.id
);
mirror();
res.json({ ok: true, firstName: visit.first_name, signedOutAt });
});
/* ---------------------------------------------------- recurring visitor */
router.post('/frequent/auth', pinLimiter, (req, res) => {
const site = siteFrom(req);
if (!site) return res.status(400).json({ error: 'This kiosk has no site selected.' });
const phone = normalisePhone(req.body?.phone);
const pin = clean(req.body?.pin, 8);
if (!phone || !/^\d{4}$/.test(pin)) {
return res.status(400).json({ error: 'Enter your mobile number and 4 digit PIN.' });
}
const attempt = db.prepare('SELECT * FROM pin_attempts WHERE phone = ?').get(phone);
if (attempt?.locked_until && attempt.locked_until > nowIso()) {
return res
.status(429)
.json({ error: 'Too many wrong PINs. Wait 15 minutes or see the front desk.' });
}
const person = db
.prepare('SELECT * FROM frequent_visitors WHERE phone = ? AND active = 1')
.get(phone);
if (!person || !verifyPin(person.pin_enc, pin)) {
const fails = (attempt?.fails || 0) + 1;
const lockedUntil =
fails >= LOCKOUT_FAILS ? new Date(Date.now() + LOCKOUT_MINUTES * 60000).toISOString() : null;
db.prepare(
`INSERT INTO pin_attempts (phone, fails, locked_until) VALUES (?, ?, ?)
ON CONFLICT(phone) DO UPDATE SET fails = excluded.fails, locked_until = excluded.locked_until`
).run(phone, fails, lockedUntil);
return res.status(401).json({ error: 'That mobile number and PIN do not match.' });
}
if (person.site_id && person.site_id !== site.id) {
return res.status(403).json({ error: 'Your record is not set up for this site.' });
}
db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(phone);
req.session.frequentVisitorId = person.id;
const open = db
.prepare(
'SELECT id, host_name, signed_in_at FROM visits WHERE frequent_visitor_id = ? AND signed_out_at IS NULL'
)
.get(person.id);
res.json({
id: person.id,
firstName: person.first_name,
lastName: person.last_name,
checkType: person.check_type,
checkNumber: person.check_number,
company: person.company,
defaultHostId: person.default_host_id,
// Tells the kiosk it can skip the camera step entirely.
hasPhoto: Boolean(person.photo_path),
openVisit: open || null,
});
});
export default router;