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;