Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
import express from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import db from '../db.js';
|
||||
import config from '../config.js';
|
||||
import { savePhoto, photoAbsolutePath } from '../photos.js';
|
||||
import { mirror } from '../sheets.js';
|
||||
import { verifyPin } from '../pins.js';
|
||||
import { listSites, resolveSite, badgeHtml } from '../sites.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,
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
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.`,
|
||||
});
|
||||
}
|
||||
|
||||
let photoPath = null;
|
||||
if (body.photo) {
|
||||
photoPath = savePhoto(body.photo);
|
||||
} else if (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, 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,
|
||||
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(visit, 'SIGN IN');
|
||||
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(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT');
|
||||
|
||||
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,
|
||||
defaultHostId: person.default_host_id,
|
||||
openVisit: open || null,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user