Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA

This commit is contained in:
2026-08-31 09:47:36 +10:00
commit ed77493817
32 changed files with 5799 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
import crypto from 'node:crypto';
/* ------------------------------------------------------------ passwords */
// scrypt is built into Node, so there is no native module to compile in the image.
export function hashPassword(password) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(String(password), salt, 64, { N: 16384, r: 8, p: 1 });
return `scrypt$${salt.toString('base64')}$${hash.toString('base64')}`;
}
export function verifyPassword(password, stored) {
try {
const [scheme, saltB64, hashB64] = String(stored).split('$');
if (scheme !== 'scrypt') return false;
const expected = Buffer.from(hashB64, 'base64');
const actual = crypto.scryptSync(String(password), Buffer.from(saltB64, 'base64'), expected.length, {
N: 16384,
r: 8,
p: 1,
});
return crypto.timingSafeEqual(expected, actual);
} catch {
return false;
}
}
export function passwordProblem(password) {
const value = String(password || '');
if (value.length < 12) return 'Use at least 12 characters.';
if (!/[a-z]/.test(value) || !/[A-Z]/.test(value)) return 'Mix upper and lower case.';
if (!/\d/.test(value)) return 'Include at least one number.';
return null;
}
export function randomPassword() {
// Readable enough to hand over verbally, still 60+ bits of entropy.
const words = crypto.randomBytes(9).toString('base64url').replace(/[-_]/g, '');
return `Vs${words}9`;
}
/* ----------------------------------------------------------- base32 */
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32Encode(buffer) {
let bits = 0;
let value = 0;
let output = '';
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31];
return output;
}
export function base32Decode(input) {
const clean = String(input).toUpperCase().replace(/[^A-Z2-7]/g, '');
let bits = 0;
let value = 0;
const bytes = [];
for (const char of clean) {
value = (value << 5) | ALPHABET.indexOf(char);
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/* -------------------------------------------------------------- TOTP */
export function generateTotpSecret() {
return base32Encode(crypto.randomBytes(20));
}
function hotp(secretBuffer, counter) {
const buf = Buffer.alloc(8);
buf.writeBigUInt64BE(BigInt(counter));
const digest = crypto.createHmac('sha1', secretBuffer).update(buf).digest();
const offset = digest[digest.length - 1] & 0x0f;
const code =
((digest[offset] & 0x7f) << 24) |
((digest[offset + 1] & 0xff) << 16) |
((digest[offset + 2] & 0xff) << 8) |
(digest[offset + 3] & 0xff);
return String(code % 1_000_000).padStart(6, '0');
}
export function totpCode(secret, atMs = Date.now(), stepSeconds = 30) {
return hotp(base32Decode(secret), Math.floor(atMs / 1000 / stepSeconds));
}
/** Allows one step either side, which covers a phone clock that has drifted a little. */
export function verifyTotp(secret, token, window = 1) {
const candidate = String(token || '').replace(/\D/g, '');
if (candidate.length !== 6) return false;
const counter = Math.floor(Date.now() / 1000 / 30);
const buffer = base32Decode(secret);
for (let drift = -window; drift <= window; drift += 1) {
const expected = hotp(buffer, counter + drift);
if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate))) return true;
}
return false;
}
export function otpauthUrl({ secret, email, issuer }) {
const label = encodeURIComponent(`${issuer}:${email}`);
const params = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: '30' });
return `otpauth://totp/${label}?${params}`;
}
/* --------------------------------------------------- recovery codes */
export function generateRecoveryCodes(count = 8) {
return Array.from({ length: count }, () =>
crypto.randomBytes(5).toString('hex').replace(/(.{5})(.{5})/, '$1-$2')
);
}
const digest = (code) =>
crypto.createHash('sha256').update(String(code).toLowerCase().replace(/[^a-z0-9]/g, '')).digest('hex');
export function hashRecoveryCodes(codes) {
return JSON.stringify(codes.map(digest));
}
/** Returns the remaining codes if one matched, or null. Used codes are burnt. */
export function consumeRecoveryCode(storedJson, candidate) {
try {
const hashes = JSON.parse(storedJson || '[]');
const target = digest(candidate);
const index = hashes.indexOf(target);
if (index === -1) return null;
hashes.splice(index, 1);
return JSON.stringify(hashes);
} catch {
return null;
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'dotenv/config';
import path from 'node:path';
import crypto from 'node:crypto';
function bool(value, fallback) {
if (value === undefined || value === '') return fallback;
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
}
function int(value, fallback) {
const n = Number.parseInt(value, 10);
return Number.isFinite(n) ? n : fallback;
}
const dataDir = process.env.DATA_DIR || '/data';
if (!process.env.APP_SECRET) {
console.warn(
'[config] APP_SECRET is not set. A random one is being generated for this process only.\n' +
' Sessions will drop and stored visitor PINs will become unreadable on restart.\n' +
' Set APP_SECRET in your .env before going live.'
);
}
export const config = {
port: int(process.env.PORT, 3000),
siteName: process.env.SITE_NAME || 'Visitor sign in',
timezone: process.env.TZ || 'Australia/Melbourne',
dataDir,
dbPath: process.env.DB_PATH || path.join(dataDir, 'visitors.db'),
photoDir: process.env.PHOTO_DIR || path.join(dataDir, 'photos'),
appSecret: process.env.APP_SECRET || crypto.randomBytes(32).toString('hex'),
trustProxy: bool(process.env.TRUST_PROXY, false),
secureCookies: bool(process.env.SECURE_COOKIES, false),
admin: {
// Used once, to create the first account if the user table is empty.
bootstrapEmail: (process.env.ADMIN_BOOTSTRAP_EMAIL || '').trim().toLowerCase(),
bootstrapPassword: process.env.ADMIN_BOOTSTRAP_PASSWORD || process.env.ADMIN_PASSWORD || '',
// Blank allows any address. Otherwise a comma separated list, e.g. "school.vic.edu.au".
allowedDomains: (process.env.ADMIN_ALLOWED_DOMAINS || '')
.split(',')
.map((d) => d.trim().toLowerCase().replace(/^@/, ''))
.filter(Boolean),
require2fa: bool(process.env.ADMIN_REQUIRE_2FA, true),
},
// Admins are warned this many days before a WWCC or VIT expires.
expiryWarningDays: int(process.env.EXPIRY_WARNING_DAYS, 28),
requirePhoto: bool(process.env.REQUIRE_PHOTO, true),
photoRetentionDays: int(process.env.PHOTO_RETENTION_DAYS, 90),
// Blank disables the nightly sweep. Format "HH:MM" in local time.
autoSignOutTime: process.env.AUTO_SIGNOUT_TIME || '',
https: {
enabled: bool(process.env.HTTPS_ENABLED, false),
keyPath: process.env.HTTPS_KEY || path.join(dataDir, 'certs', 'server.key'),
certPath: process.env.HTTPS_CERT || path.join(dataDir, 'certs', 'server.crt'),
},
sheets: {
enabled: bool(process.env.SHEETS_ENABLED, false),
spreadsheetId: process.env.SHEETS_SPREADSHEET_ID || '',
tabName: process.env.SHEETS_TAB_NAME || 'Visitor log',
// Either a path to the service account JSON, or the JSON itself base64 encoded.
credentialsPath: process.env.GOOGLE_CREDENTIALS_PATH || '',
credentialsB64: process.env.GOOGLE_CREDENTIALS_B64 || '',
retryIntervalMs: int(process.env.SHEETS_RETRY_INTERVAL_MS, 60000),
},
};
export default config;
+170
View File
@@ -0,0 +1,170 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import config from './config.js';
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
fs.mkdirSync(config.photoDir, { recursive: true });
export const db = new Database(config.dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
badge_enabled INTEGER NOT NULL DEFAULT 0,
badge_width_mm REAL NOT NULL DEFAULT 86,
badge_height_mm REAL NOT NULL DEFAULT 54,
badge_show_photo INTEGER NOT NULL DEFAULT 1,
badge_note TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT,
area TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS frequent_visitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE SET NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
phone TEXT NOT NULL UNIQUE,
email TEXT,
check_type TEXT NOT NULL DEFAULT 'NONE',
check_number TEXT,
check_expiry TEXT,
default_host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
pin_enc TEXT NOT NULL,
notes TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE SET NULL,
site_name TEXT,
visitor_type TEXT NOT NULL,
frequent_visitor_id INTEGER REFERENCES frequent_visitors(id) ON DELETE SET NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
phone TEXT,
email TEXT,
check_type TEXT NOT NULL DEFAULT 'NONE',
check_number TEXT,
check_expiry TEXT,
host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
host_name TEXT NOT NULL,
visit_reason TEXT,
photo_path TEXT,
signed_in_at TEXT NOT NULL,
signed_out_at TEXT,
signed_out_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_visits_open ON visits(signed_out_at, last_name);
CREATE INDEX IF NOT EXISTS idx_visits_in ON visits(signed_in_at);
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT,
password_hash TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
recovery_codes TEXT,
role TEXT NOT NULL DEFAULT 'admin',
site_id INTEGER REFERENCES sites(id) ON DELETE SET NULL,
must_change_password INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS login_attempts (
email TEXT PRIMARY KEY,
fails INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
CREATE TABLE IF NOT EXISTS sheet_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS pin_attempts (
phone TEXT PRIMARY KEY,
fails INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
`);
/* ---------------------------------------------------------- migrations */
function hasColumn(table, column) {
return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === column);
}
function addColumn(table, column, definition) {
if (!hasColumn(table, column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
console.log(`[db] added ${table}.${column}`);
}
}
// Multi-site arrived after the first release, so these run once on an existing database.
addColumn('hosts', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE CASCADE');
addColumn('visits', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE SET NULL');
addColumn('visits', 'site_name', 'TEXT');
addColumn('visits', 'check_expiry', 'TEXT');
// NULL site_id on a recurring visitor means they are welcome at every site.
addColumn('frequent_visitors', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE SET NULL');
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)');
/* -------------------------------------------------------- default site */
const siteCount = db.prepare('SELECT COUNT(*) AS n FROM sites').get().n;
if (siteCount === 0) {
db.prepare('INSERT INTO sites (name, slug) VALUES (?, ?)').run(config.siteName, 'main');
console.log(`[db] created the first site: ${config.siteName}`);
}
const firstSite = db.prepare('SELECT id, name FROM sites ORDER BY id LIMIT 1').get();
db.prepare('UPDATE hosts SET site_id = ? WHERE site_id IS NULL').run(firstSite.id);
db.prepare('UPDATE visits SET site_id = ? WHERE site_id IS NULL').run(firstSite.id);
db.prepare('UPDATE visits SET site_name = ? WHERE site_name IS NULL').run(firstSite.name);
export function getSetting(key, fallback = null) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : fallback;
}
export function setSetting(key, value) {
db.prepare(
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, String(value));
}
export default db;
+66
View File
@@ -0,0 +1,66 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import config from './config.js';
import db from './db.js';
const MAX_BYTES = 4 * 1024 * 1024;
/**
* Accepts a data URL from the kiosk camera and writes it to disk.
* Returns a path relative to config.photoDir, or null if there was no photo.
*/
export function savePhoto(dataUrl) {
if (!dataUrl) return null;
const match = /^data:image\/(jpeg|jpg|png|webp);base64,([A-Za-z0-9+/=]+)$/.exec(
String(dataUrl).trim()
);
if (!match) throw new Error('Photo could not be read. Retake it and try again.');
const ext = match[1] === 'jpg' ? 'jpeg' : match[1];
const buffer = Buffer.from(match[2], 'base64');
if (buffer.length > MAX_BYTES) throw new Error('Photo is too large.');
const now = new Date();
const folder = path.join(String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, '0'));
const dir = path.join(config.photoDir, folder);
fs.mkdirSync(dir, { recursive: true });
const name = `${now.toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(4).toString('hex')}.${ext}`;
fs.writeFileSync(path.join(dir, name), buffer);
return path.join(folder, name);
}
export function photoAbsolutePath(relative) {
if (!relative) return null;
const resolved = path.resolve(config.photoDir, relative);
if (!resolved.startsWith(path.resolve(config.photoDir))) return null;
return fs.existsSync(resolved) ? resolved : null;
}
export function deletePhoto(relative) {
const abs = photoAbsolutePath(relative);
if (abs) {
try {
fs.unlinkSync(abs);
} catch {
/* already gone */
}
}
}
/** Deletes photo files older than the retention window and clears their DB reference. */
export function purgeOldPhotos() {
if (!config.photoRetentionDays || config.photoRetentionDays <= 0) return 0;
const cutoff = new Date(Date.now() - config.photoRetentionDays * 86400000).toISOString();
const rows = db
.prepare('SELECT id, photo_path FROM visits WHERE photo_path IS NOT NULL AND signed_in_at < ?')
.all(cutoff);
const clear = db.prepare('UPDATE visits SET photo_path = NULL WHERE id = ?');
for (const row of rows) {
deletePhoto(row.photo_path);
clear.run(row.id);
}
if (rows.length) console.log(`[photos] purged ${rows.length} photo(s) past retention`);
return rows.length;
}
+54
View File
@@ -0,0 +1,54 @@
import crypto from 'node:crypto';
import config from './config.js';
// PINs are 4 digits, so a hash gives almost no protection against an attacker who
// already has the database file (10,000 candidates brute-forces instantly).
// They are stored encrypted instead, which gives the same practical protection and
// lets an admin reprint a visitor's pass without resetting their PIN.
// Brute force against the running app is handled by lockout in routes/kiosk.js.
const key = crypto.createHash('sha256').update(config.appSecret).digest();
export function encryptPin(pin) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(String(pin), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv.toString('base64'), tag.toString('base64'), enc.toString('base64')].join('.');
}
export function decryptPin(stored) {
try {
const [ivB64, tagB64, dataB64] = String(stored).split('.');
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
key,
Buffer.from(ivB64, 'base64')
);
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(dataB64, 'base64')),
decipher.final(),
]).toString('utf8');
} catch {
return null;
}
}
export function verifyPin(stored, candidate) {
const actual = decryptPin(stored);
if (actual === null) return false;
const a = Buffer.from(actual);
const b = Buffer.from(String(candidate));
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
export function generatePin() {
// Avoids the handful of PINs people will misread on a printed pass.
const banned = new Set(['0000', '1111', '1234', '4321', '9999']);
let pin;
do {
pin = String(crypto.randomInt(0, 10000)).padStart(4, '0');
} while (banned.has(pin));
return pin;
}
+942
View File
@@ -0,0 +1,942 @@
import express from 'express';
import rateLimit from 'express-rate-limit';
import QRCode from 'qrcode';
import fs from 'node:fs';
import db from '../db.js';
import config from '../config.js';
import { decryptPin, encryptPin, generatePin } from '../pins.js';
import { photoAbsolutePath, deletePhoto, purgeOldPhotos } from '../photos.js';
import * as sheets from '../sheets.js';
import * as users from '../users.js';
import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js';
import {
consumeRecoveryCode,
generateRecoveryCodes,
generateTotpSecret,
hashPassword,
hashRecoveryCodes,
otpauthUrl,
passwordProblem,
verifyPassword,
verifyTotp,
} from '../auth.js';
import {
clean,
isEmail,
isPhone,
localStamp,
normaliseEmail,
normalisePhone,
nowIso,
parseCsv,
titleCase,
toCsv,
} from '../util.js';
const router = express.Router();
const CHECK_TYPES = new Set(['WWCC', 'VIT', 'NONE']);
const loginLimiter = rateLimit({ windowMs: 15 * 60000, max: 20, standardHeaders: true });
/* ------------------------------------------------------------ sessions */
function currentUser(req) {
if (!req.session?.adminUserId) return null;
const user = users.findById(req.session.adminUserId);
return user && user.active ? user : null;
}
function requireAdmin(req, res, next) {
const user = currentUser(req);
if (!user) return res.status(401).json({ error: 'Sign in to the admin console first.' });
req.user = user;
// Someone on a temporary password can only change it or sign out.
if (user.must_change_password && !req.path.startsWith('/account/password') && req.path !== '/logout') {
return res.status(403).json({ error: 'Set a new password before continuing.', mustChangePassword: true });
}
next();
}
function requireOwner(req, res, next) {
if (req.user.role !== 'owner') {
return res.status(403).json({ error: 'Only an owner account can do that.' });
}
next();
}
/** null means "every site". Otherwise the single site this admin is limited to. */
function scopedSiteId(req) {
return req.user.site_id || null;
}
function assertSiteAllowed(req, siteId) {
const scope = scopedSiteId(req);
if (scope && Number(siteId) !== scope) {
const error = new Error('That site is outside your access.');
error.status = 403;
throw error;
}
}
/** Adds a site filter to a WHERE clause built from `where`/`params`. */
function applySiteFilter(req, where, params) {
const scope = scopedSiteId(req);
const requested = req.query.siteId && req.query.siteId !== 'all' ? Number(req.query.siteId) : null;
const siteId = scope || requested;
if (siteId) {
where.push('site_id = ?');
params.push(siteId);
}
}
/* ---------------------------------------------------------------- login */
router.post('/login', loginLimiter, (req, res) => {
const email = normaliseEmail(req.body?.email);
const password = String(req.body?.password || '');
const generic = { error: 'That email address and password do not match.' };
if (!email || !password) return res.status(400).json({ error: 'Enter your email and password.' });
const locked = users.lockState(email);
if (locked) {
return res.status(429).json({ error: 'Too many attempts. Try again in 15 minutes.' });
}
if (!users.domainAllowed(email)) {
return res.status(403).json({ error: `Sign in with an ${users.domainRuleText()} address.` });
}
const user = users.findByEmail(email);
if (!user || !user.active || !verifyPassword(password, user.password_hash)) {
users.noteFailure(email);
return res.status(401).json(generic);
}
users.clearFailures(email);
if (user.totp_enabled) {
req.session.pendingUserId = user.id;
return res.json({ status: 'twoFactorRequired' });
}
if (config.admin.require2fa) {
req.session.pendingUserId = user.id;
return startTwoFactorSetup(req, res, user);
}
return completeLogin(req, res, user);
});
function completeLogin(req, res, user) {
delete req.session.pendingUserId;
delete req.session.pendingTotpSecret;
req.session.adminUserId = user.id;
db.prepare('UPDATE admin_users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id);
res.json({
status: user.must_change_password ? 'passwordChangeRequired' : 'ok',
user: users.shape(user),
});
}
async function startTwoFactorSetup(req, res, user) {
const secret = generateTotpSecret();
req.session.pendingTotpSecret = secret;
const url = otpauthUrl({ secret, email: user.email, issuer: config.siteName });
const qr = await QRCode.toDataURL(url, { margin: 1, width: 240 });
res.json({ status: 'twoFactorSetup', secret, qr });
}
router.post('/login/2fa', loginLimiter, (req, res) => {
const user = req.session.pendingUserId ? users.findById(req.session.pendingUserId) : null;
if (!user) return res.status(401).json({ error: 'Start again from the sign in screen.' });
const code = clean(req.body?.code, 20);
// Enrolling: the secret is only saved once a real code from the app proves it works.
if (req.session.pendingTotpSecret) {
if (!verifyTotp(req.session.pendingTotpSecret, code)) {
return res.status(401).json({ error: 'That code did not match. Try the next one.' });
}
const recovery = generateRecoveryCodes();
db.prepare(
'UPDATE admin_users SET totp_secret = ?, totp_enabled = 1, recovery_codes = ? WHERE id = ?'
).run(req.session.pendingTotpSecret, hashRecoveryCodes(recovery), user.id);
delete req.session.pendingTotpSecret;
req.session.adminUserId = user.id;
delete req.session.pendingUserId;
db.prepare('UPDATE admin_users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id);
return res.json({
status: users.findById(user.id).must_change_password ? 'passwordChangeRequired' : 'ok',
recoveryCodes: recovery,
user: users.shape(users.findById(user.id)),
});
}
if (verifyTotp(user.totp_secret, code)) {
users.clearFailures(user.email);
return completeLogin(req, res, user);
}
// Recovery codes are one shot each.
const remaining = consumeRecoveryCode(user.recovery_codes, code);
if (remaining !== null) {
db.prepare('UPDATE admin_users SET recovery_codes = ? WHERE id = ?').run(remaining, user.id);
const left = JSON.parse(remaining).length;
req.session.adminUserId = user.id;
delete req.session.pendingUserId;
return res.json({
status: 'ok',
usedRecoveryCode: true,
recoveryCodesLeft: left,
user: users.shape(user),
});
}
users.noteFailure(user.email);
res.status(401).json({ error: 'That code is not right.' });
});
router.post('/logout', (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});
router.get('/session', (req, res) => {
const user = currentUser(req);
const anyUsers = users.countActive() > 0;
res.json({
admin: Boolean(user),
setupNeeded: !anyUsers,
user: user ? users.shape(user) : null,
siteName: config.siteName,
domainRule: users.domainRuleText(),
require2fa: config.admin.require2fa,
mustChangePassword: Boolean(user?.must_change_password),
});
});
router.use(requireAdmin);
/* -------------------------------------------------------------- account */
router.post('/account/password', (req, res) => {
const current = String(req.body?.currentPassword || '');
const next = String(req.body?.newPassword || '');
if (!verifyPassword(current, req.user.password_hash)) {
return res.status(401).json({ error: 'Your current password is not right.' });
}
const problem = passwordProblem(next);
if (problem) return res.status(400).json({ error: problem });
db.prepare('UPDATE admin_users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(
hashPassword(next),
req.user.id
);
res.json({ ok: true });
});
router.post('/account/2fa/start', async (req, res) => {
const secret = generateTotpSecret();
req.session.selfTotpSecret = secret;
const url = otpauthUrl({ secret, email: req.user.email, issuer: config.siteName });
res.json({ secret, qr: await QRCode.toDataURL(url, { margin: 1, width: 240 }) });
});
router.post('/account/2fa/enable', (req, res) => {
const secret = req.session.selfTotpSecret;
if (!secret) return res.status(400).json({ error: 'Start the setup again.' });
if (!verifyTotp(secret, clean(req.body?.code, 20))) {
return res.status(401).json({ error: 'That code did not match. Try the next one.' });
}
const recovery = generateRecoveryCodes();
db.prepare(
'UPDATE admin_users SET totp_secret = ?, totp_enabled = 1, recovery_codes = ? WHERE id = ?'
).run(secret, hashRecoveryCodes(recovery), req.user.id);
delete req.session.selfTotpSecret;
res.json({ ok: true, recoveryCodes: recovery });
});
router.post('/account/2fa/disable', (req, res) => {
if (config.admin.require2fa) {
return res.status(403).json({ error: 'Two factor is required for every admin on this server.' });
}
if (!verifyPassword(String(req.body?.password || ''), req.user.password_hash)) {
return res.status(401).json({ error: 'Your password is not right.' });
}
db.prepare(
'UPDATE admin_users SET totp_secret = NULL, totp_enabled = 0, recovery_codes = NULL WHERE id = ?'
).run(req.user.id);
res.json({ ok: true });
});
/* ---------------------------------------------------------------- users */
router.get('/users', requireOwner, (req, res) => {
const rows = db.prepare('SELECT * FROM admin_users ORDER BY email').all();
res.json(rows.map(users.shape));
});
router.post('/users', requireOwner, (req, res) => {
try {
const siteId = req.body?.siteId ? Number(req.body.siteId) : null;
if (siteId && !db.prepare('SELECT id FROM sites WHERE id = ?').get(siteId)) {
return res.status(400).json({ error: 'That site does not exist.' });
}
const { user, temporaryPassword } = users.createUser({
email: req.body?.email,
name: req.body?.name,
role: req.body?.role === 'owner' ? 'owner' : 'admin',
siteId,
});
res.json({ ...users.shape(user), temporaryPassword });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.patch('/users/:id', requireOwner, (req, res) => {
const target = users.findById(req.params.id);
if (!target) return res.status(404).json({ error: 'Not found.' });
const makingInactive = req.body?.active === false;
const demoting = req.body?.role && req.body.role !== 'owner' && target.role === 'owner';
if ((makingInactive || demoting) && target.id === req.user.id) {
return res.status(400).json({ error: 'You cannot lock yourself out of your own account.' });
}
const owners = db
.prepare("SELECT COUNT(*) AS n FROM admin_users WHERE role = 'owner' AND active = 1").get().n;
if ((makingInactive || demoting) && target.role === 'owner' && owners <= 1) {
return res.status(400).json({ error: 'Keep at least one active owner account.' });
}
db.prepare('UPDATE admin_users SET name = ?, role = ?, site_id = ?, active = ? WHERE id = ?').run(
req.body?.name !== undefined ? clean(req.body.name, 80) || null : target.name,
req.body?.role === 'owner' ? 'owner' : req.body?.role === 'admin' ? 'admin' : target.role,
req.body?.siteId !== undefined ? (req.body.siteId ? Number(req.body.siteId) : null) : target.site_id,
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : target.active,
target.id
);
res.json(users.shape(users.findById(target.id)));
});
router.post('/users/:id/reset-password', requireOwner, (req, res) => {
const target = users.findById(req.params.id);
if (!target) return res.status(404).json({ error: 'Not found.' });
const temporary = req.body?.password || undefined;
const problem = temporary ? passwordProblem(temporary) : null;
if (problem) return res.status(400).json({ error: problem });
const password = temporary || `Vs${Math.random().toString(36).slice(2, 10)}9A`;
db.prepare('UPDATE admin_users SET password_hash = ?, must_change_password = 1 WHERE id = ?').run(
hashPassword(password),
target.id
);
users.clearFailures(target.email);
res.json({ ok: true, temporaryPassword: password });
});
router.post('/users/:id/reset-2fa', requireOwner, (req, res) => {
const target = users.findById(req.params.id);
if (!target) return res.status(404).json({ error: 'Not found.' });
db.prepare(
'UPDATE admin_users SET totp_secret = NULL, totp_enabled = 0, recovery_codes = NULL WHERE id = ?'
).run(target.id);
res.json({ ok: true });
});
/* ---------------------------------------------------------------- sites */
router.get('/sites', (req, res) => {
const scope = scopedSiteId(req);
const rows = listSites().filter((s) => !scope || s.id === scope);
res.json(rows.map(shapeSite));
});
router.post('/sites', requireOwner, (req, res) => {
const name = clean(req.body?.name, 100);
if (!name) return res.status(400).json({ error: 'Give the site a name.' });
const slug = uniqueSlug(req.body?.slug || name);
const info = db.prepare('INSERT INTO sites (name, slug) VALUES (?, ?)').run(name, slug);
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(info.lastInsertRowid)));
});
router.patch('/sites/:id', (req, res) => {
try {
assertSiteAllowed(req, req.params.id);
} catch (err) {
return res.status(err.status || 403).json({ error: err.message });
}
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).json({ error: 'Not found.' });
const badge = req.body?.badge || {};
db.prepare(
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
badge_height_mm = ?, badge_show_photo = ?, badge_note = ? WHERE id = ?`
).run(
clean(req.body?.name ?? site.name, 100) || site.name,
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : site.active,
badge.enabled !== undefined ? (badge.enabled ? 1 : 0) : site.badge_enabled,
Math.min(200, Math.max(20, Number(badge.widthMm ?? site.badge_width_mm) || 86)),
Math.min(200, Math.max(15, Number(badge.heightMm ?? site.badge_height_mm) || 54)),
badge.showPhoto !== undefined ? (badge.showPhoto ? 1 : 0) : site.badge_show_photo,
badge.note !== undefined ? clean(badge.note, 120) || null : site.badge_note
, site.id);
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
});
router.get('/sites/:id/badge-preview', (req, res) => {
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).send('Not found.');
const sample = {
first_name: 'Sample',
last_name: 'Visitor',
host_name: 'Jess Rogerson',
check_type: 'WWCC',
check_number: 'WWC1234567E',
signed_in_at: nowIso(),
};
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(badgeHtml(sample, site, { autoPrint: false }));
});
/* ---------------------------------------------------------------- hosts */
function hostSiteId(req) {
const scope = scopedSiteId(req);
const asked = req.body?.siteId ?? req.query.siteId;
const siteId = scope || (asked && asked !== 'all' ? Number(asked) : null);
return siteId;
}
router.get('/hosts', (req, res) => {
const siteId = hostSiteId(req);
const sql = siteId
? 'SELECT * FROM hosts WHERE site_id = ? ORDER BY name COLLATE NOCASE'
: 'SELECT * FROM hosts ORDER BY name COLLATE NOCASE';
const rows = siteId ? db.prepare(sql).all(siteId) : db.prepare(sql).all();
res.json(rows);
});
router.post('/hosts', (req, res) => {
const name = titleCase(req.body?.name, 120);
const siteId = hostSiteId(req);
if (!name) return res.status(400).json({ error: 'Name is required.' });
if (!siteId) return res.status(400).json({ error: 'Choose which site this person belongs to.' });
try {
assertSiteAllowed(req, siteId);
} catch (err) {
return res.status(403).json({ error: err.message });
}
const info = db
.prepare('INSERT INTO hosts (name, email, area, site_id, active) VALUES (?, ?, ?, ?, 1)')
.run(name, normaliseEmail(req.body?.email) || null, clean(req.body?.area, 80) || null, siteId);
res.json(db.prepare('SELECT * FROM hosts WHERE id = ?').get(info.lastInsertRowid));
});
router.patch('/hosts/:id', (req, res) => {
const host = db.prepare('SELECT * FROM hosts WHERE id = ?').get(req.params.id);
if (!host) return res.status(404).json({ error: 'Not found.' });
try {
assertSiteAllowed(req, host.site_id);
} catch (err) {
return res.status(403).json({ error: err.message });
}
db.prepare('UPDATE hosts SET name = ?, email = ?, area = ?, active = ? WHERE id = ?').run(
titleCase(req.body?.name ?? host.name, 120),
req.body?.email !== undefined ? normaliseEmail(req.body.email) || null : host.email,
req.body?.area !== undefined ? clean(req.body.area, 80) || null : host.area,
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : host.active,
host.id
);
res.json(db.prepare('SELECT * FROM hosts WHERE id = ?').get(host.id));
});
router.delete('/hosts/:id', (req, res) => {
const host = db.prepare('SELECT * FROM hosts WHERE id = ?').get(req.params.id);
if (!host) return res.status(404).json({ error: 'Not found.' });
try {
assertSiteAllowed(req, host.site_id);
} catch (err) {
return res.status(403).json({ error: err.message });
}
db.prepare('UPDATE hosts SET active = 0 WHERE id = ?').run(host.id);
res.json({ ok: true });
});
/**
* CSV import, scoped to one site. Headings understood: name, email, area
* (or department / team / role). A single unnamed column is treated as the name.
*/
router.post('/hosts/import', (req, res) => {
const siteId = hostSiteId(req);
if (!siteId) return res.status(400).json({ error: 'Choose which site this list belongs to.' });
try {
assertSiteAllowed(req, siteId);
} catch (err) {
return res.status(403).json({ error: err.message });
}
const rows = parseCsv(req.body?.csv || '');
if (!rows.length) return res.status(400).json({ error: 'That CSV had no rows in it.' });
const header = rows[0].map((h) => h.trim().toLowerCase());
const looksLikeHeader = header.some((h) =>
['name', 'full name', 'staff', 'email', 'area', 'department', 'team'].includes(h)
);
const body = looksLikeHeader ? rows.slice(1) : rows;
const idx = {
name: looksLikeHeader ? header.findIndex((h) => ['name', 'full name', 'staff'].includes(h)) : 0,
email: looksLikeHeader ? header.findIndex((h) => h === 'email') : -1,
area: looksLikeHeader
? header.findIndex((h) => ['area', 'department', 'team', 'role'].includes(h))
: -1,
};
if (idx.name < 0) idx.name = 0;
const replace = Boolean(req.body?.replace);
const insert = db.prepare(
'INSERT INTO hosts (name, email, area, site_id, active) VALUES (?, ?, ?, ?, 1)'
);
const existing = db.prepare('SELECT id FROM hosts WHERE lower(name) = lower(?) AND site_id = ?');
const reactivate = db.prepare('UPDATE hosts SET active = 1, email = ?, area = ? WHERE id = ?');
let added = 0;
let updated = 0;
db.transaction(() => {
if (replace) db.prepare('UPDATE hosts SET active = 0 WHERE site_id = ?').run(siteId);
for (const row of body) {
const name = titleCase(row[idx.name], 120);
if (!name) continue;
const email = idx.email >= 0 ? normaliseEmail(row[idx.email]) || null : null;
const area = idx.area >= 0 ? clean(row[idx.area], 80) || null : null;
const found = existing.get(name, siteId);
if (found) {
reactivate.run(email, area, found.id);
updated += 1;
} else {
insert.run(name, email, area, siteId);
added += 1;
}
}
})();
const total = db
.prepare('SELECT COUNT(*) AS n FROM hosts WHERE active = 1 AND site_id = ?')
.get(siteId).n;
res.json({ added, updated, total });
});
/* ------------------------------------------------- recurring visitors */
function shapeFrequent(row, includePin = false) {
return {
id: row.id,
firstName: row.first_name,
lastName: row.last_name,
phone: row.phone,
email: row.email,
checkType: row.check_type,
checkNumber: row.check_number,
checkExpiry: row.check_expiry,
defaultHostId: row.default_host_id,
siteId: row.site_id,
notes: row.notes,
active: Boolean(row.active),
createdAt: row.created_at,
expiry: expiryState(row.check_type, row.check_expiry),
...(includePin ? { pin: decryptPin(row.pin_enc) } : {}),
};
}
/** Days until a WWCC or VIT lapses, plus a plain status an admin can act on. */
export function expiryState(checkType, checkExpiry) {
if (checkType === 'NONE' || !checkExpiry) return { status: 'none', daysLeft: null };
const due = new Date(`${checkExpiry}T23:59:59`);
if (Number.isNaN(due.getTime())) return { status: 'none', daysLeft: null };
const daysLeft = Math.ceil((due.getTime() - Date.now()) / 86400000);
if (daysLeft < 0) return { status: 'expired', daysLeft };
if (daysLeft <= config.expiryWarningDays) return { status: 'expiring', daysLeft };
return { status: 'ok', daysLeft };
}
router.get('/frequent', (req, res) => {
const scope = scopedSiteId(req);
const rows = db
.prepare(
`SELECT * FROM frequent_visitors
${scope ? 'WHERE site_id IS NULL OR site_id = ?' : ''}
ORDER BY last_name COLLATE NOCASE, first_name COLLATE NOCASE`
)
.all(...(scope ? [scope] : []));
res.json(rows.map((r) => shapeFrequent(r)));
});
router.get('/frequent/:id', (req, res) => {
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Not found.' });
res.json(shapeFrequent(row, true));
});
/** Everyone whose check lapses inside the warning window, or already has. */
router.get('/alerts', (req, res) => {
const scope = scopedSiteId(req);
const rows = db
.prepare(
`SELECT * FROM frequent_visitors
WHERE active = 1 AND check_type <> 'NONE' AND check_expiry IS NOT NULL
${scope ? 'AND (site_id IS NULL OR site_id = ?)' : ''}`
)
.all(...(scope ? [scope] : []))
.map((r) => shapeFrequent(r))
.filter((r) => r.expiry.status === 'expiring' || r.expiry.status === 'expired')
.sort((a, b) => a.expiry.daysLeft - b.expiry.daysLeft);
res.json({
warningDays: config.expiryWarningDays,
expired: rows.filter((r) => r.expiry.status === 'expired'),
expiring: rows.filter((r) => r.expiry.status === 'expiring'),
});
});
function validateFrequent(body, { existingPhone = null } = {}) {
const firstName = titleCase(body?.firstName, 60);
const lastName = titleCase(body?.lastName, 60);
const phone = normalisePhone(body?.phone);
const email = normaliseEmail(body?.email);
const checkType = clean(body?.checkType, 10).toUpperCase() || 'NONE';
if (!firstName || !lastName) throw new Error('First and last name are required.');
if (!isPhone(phone)) throw new Error('A valid mobile number is required — it is their username.');
if (email && !isEmail(email)) throw new Error('That email address is not valid.');
if (!CHECK_TYPES.has(checkType)) throw new Error('Check type must be WWCC, VIT or NONE.');
if (checkType !== 'NONE' && !clean(body?.checkNumber)) {
throw new Error(`A ${checkType} number is required.`);
}
if (phone !== existingPhone) {
const clash = db.prepare('SELECT id FROM frequent_visitors WHERE phone = ?').get(phone);
if (clash) throw new Error('Another recurring visitor already uses that mobile number.');
}
return {
firstName,
lastName,
phone,
email: email || null,
checkType,
checkNumber: clean(body?.checkNumber, 40) || null,
checkExpiry: clean(body?.checkExpiry, 20) || null,
defaultHostId: body?.defaultHostId ? Number(body.defaultHostId) : null,
siteId: body?.siteId ? Number(body.siteId) : null,
notes: clean(body?.notes, 300) || null,
};
}
router.post('/frequent', (req, res) => {
try {
const v = validateFrequent(req.body);
const scope = scopedSiteId(req);
const siteId = scope || v.siteId;
const pin = /^\d{4}$/.test(String(req.body?.pin || '')) ? String(req.body.pin) : generatePin();
const info = db
.prepare(
`INSERT INTO frequent_visitors
(first_name, last_name, phone, email, check_type, check_number, check_expiry,
default_host_id, site_id, pin_enc, notes, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
v.defaultHostId, siteId, encryptPin(pin), v.notes
);
res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid), true));
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.patch('/frequent/:id', (req, res) => {
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Not found.' });
try {
const v = validateFrequent({ ...shapeFrequent(row), ...req.body }, { existingPhone: row.phone });
const scope = scopedSiteId(req);
db.prepare(
`UPDATE frequent_visitors SET first_name = ?, last_name = ?, phone = ?, email = ?,
check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?,
notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
).run(
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
v.defaultHostId,
scope || (req.body?.siteId !== undefined ? v.siteId : row.site_id),
v.notes,
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : row.active,
row.id
);
res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(row.id), true));
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.post('/frequent/:id/pin', (req, res) => {
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Not found.' });
const pin = /^\d{4}$/.test(String(req.body?.pin || '')) ? String(req.body.pin) : generatePin();
db.prepare("UPDATE frequent_visitors SET pin_enc = ?, updated_at = datetime('now') WHERE id = ?").run(
encryptPin(pin),
row.id
);
db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone);
res.json({ ok: true, pin });
});
router.delete('/frequent/:id', (req, res) => {
db.prepare('UPDATE frequent_visitors SET active = 0 WHERE id = ?').run(req.params.id);
res.json({ ok: true });
});
/* ------------------------------------------------------------- visits */
function shapeVisit(v) {
return {
id: v.id,
siteId: v.site_id,
siteName: v.site_name,
visitorType: v.visitor_type,
firstName: v.first_name,
lastName: v.last_name,
phone: v.phone,
email: v.email,
checkType: v.check_type,
checkNumber: v.check_number,
hostName: v.host_name,
visitReason: v.visit_reason,
hasPhoto: Boolean(v.photo_path),
signedInAt: v.signed_in_at,
signedOutAt: v.signed_out_at,
signedOutBy: v.signed_out_by,
};
}
router.get('/onsite', (req, res) => {
const where = ['signed_out_at IS NULL'];
const params = [];
applySiteFilter(req, where, params);
const rows = db
.prepare(`SELECT * FROM visits WHERE ${where.join(' AND ')} ORDER BY signed_in_at`)
.all(...params);
res.json(rows.map(shapeVisit));
});
router.get('/visits', (req, res) => {
const where = [];
const params = [];
applySiteFilter(req, where, params);
const from = clean(req.query.from, 10);
const to = clean(req.query.to, 10);
const q = clean(req.query.q, 60);
if (from) {
where.push('signed_in_at >= ?');
params.push(`${from}T00:00:00.000Z`);
}
if (to) {
where.push('signed_in_at <= ?');
params.push(`${to}T23:59:59.999Z`);
}
if (q) {
where.push('(last_name LIKE ? OR first_name LIKE ? OR host_name LIKE ? OR phone LIKE ? OR email LIKE ?)');
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
}
const sql = `SELECT * FROM visits ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY signed_in_at DESC LIMIT 500`;
res.json(db.prepare(sql).all(...params).map(shapeVisit));
});
router.post('/visits/:id/signout', (req, res) => {
const visit = db
.prepare('SELECT * FROM visits WHERE id = ? AND signed_out_at IS NULL')
.get(req.params.id);
if (!visit) return res.status(404).json({ error: 'That visit is already closed.' });
try {
if (scopedSiteId(req)) assertSiteAllowed(req, visit.site_id);
} catch (err) {
return res.status(403).json({ error: err.message });
}
db.prepare('UPDATE visits SET signed_out_at = ?, signed_out_by = ? WHERE id = ?').run(
nowIso(),
'admin',
visit.id
);
sheets.mirror(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT');
res.json({ ok: true });
});
router.get('/visits.csv', (req, res) => {
const where = [];
const params = [];
applySiteFilter(req, where, params);
const rows = db
.prepare(
`SELECT * FROM visits ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY signed_in_at DESC`
)
.all(...params);
const csv = toCsv([
['Visit ID', 'Site', 'Type', 'First name', 'Last name', 'Phone', 'Email', 'Check type', 'Check number', 'Visiting', 'Reason', 'Signed in', 'Signed out', 'Closed by', 'Photo'],
...rows.map((v) => [
v.id, v.site_name, v.visitor_type, v.first_name, v.last_name, v.phone, v.email,
v.check_type, v.check_number, v.host_name, v.visit_reason,
localStamp(v.signed_in_at), localStamp(v.signed_out_at), v.signed_out_by,
v.photo_path ? 'yes' : 'no',
]),
]);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="visits-${new Date().toISOString().slice(0, 10)}.csv"`);
res.send(csv);
});
router.get('/photo/:id', (req, res) => {
const visit = db.prepare('SELECT photo_path FROM visits WHERE id = ?').get(req.params.id);
const abs = visit && photoAbsolutePath(visit.photo_path);
if (!abs) return res.status(404).send('No photo on file.');
res.setHeader('Cache-Control', 'private, max-age=300');
res.sendFile(abs);
});
router.delete('/photo/:id', (req, res) => {
const visit = db.prepare('SELECT photo_path FROM visits WHERE id = ?').get(req.params.id);
if (visit?.photo_path) {
deletePhoto(visit.photo_path);
db.prepare('UPDATE visits SET photo_path = NULL WHERE id = ?').run(req.params.id);
}
res.json({ ok: true });
});
/** Reprint a badge for someone already on site. */
router.get('/badge/:id', (req, res) => {
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id);
if (!visit) return res.status(404).send('Not found.');
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(visit.site_id);
if (!site) return res.status(404).send('That site no longer exists.');
let photoUrl = null;
const abs = photoAbsolutePath(visit.photo_path);
if (abs && site.badge_show_photo) {
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 }));
});
/* --------------------------------------------------- printable PIN card */
router.get('/pass/:id', (req, res) => {
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).send('Not found.');
const pin = decryptPin(row.pin_enc) || '????';
const host = row.default_host_id
? db.prepare('SELECT name FROM hosts WHERE id = ?').get(row.default_host_id)
: null;
const site = row.site_id ? db.prepare('SELECT name FROM sites WHERE id = ?').get(row.site_id) : null;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sign in card — ${esc(row.first_name)} ${esc(row.last_name)}</title>
<style>
:root { --ink:#16202b; --muted:#5d6b7a; --rule:#c9d3dc; --deep:#0b4f4a; }
* { box-sizing: border-box; }
body { margin:0; padding:24px; background:#eef1f4; color:var(--ink);
font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
.card { width: 105mm; min-height: 74mm; margin: 0 auto; background:#fff; padding: 12mm 11mm;
border:1px solid var(--rule); }
h1 { margin:0; font-size: 21px; letter-spacing:-0.01em; }
.site { margin:0 0 14px; font-size:12px; color:var(--muted); }
.pin { margin: 14px 0 4px; font-size: 46px; font-weight: 700; letter-spacing: 0.22em;
font-variant-numeric: tabular-nums; color: var(--deep); }
.pin-label { margin:0 0 16px; font-size:12px; color:var(--muted); }
dl { display:grid; grid-template-columns: 34mm 1fr; gap:5px 10px; margin:0;
font-size:12.5px; border-top:1px solid var(--rule); padding-top:10px; }
dt { color: var(--muted); }
dd { margin:0; }
.how { margin-top:12px; font-size:11.5px; color:var(--muted); line-height:1.5; }
.no-print { text-align:center; margin: 18px 0; }
button { font:inherit; padding:10px 20px; border:1px solid var(--deep); background:var(--deep);
color:#fff; border-radius:2px; cursor:pointer; }
@media print {
body { background:#fff; padding:0; }
.card { border:none; }
.no-print { display:none; }
}
</style>
</head>
<body>
<div class="no-print"><button onclick="window.print()">Print this card</button></div>
<div class="card">
<p class="site">${esc(site ? site.name : config.siteName)}</p>
<h1>${esc(row.first_name)} ${esc(row.last_name)}</h1>
<p class="pin">${esc(pin)}</p>
<p class="pin-label">Your PIN. Keep this card, it is not sent to you again.</p>
<dl>
<dt>Mobile (your username)</dt><dd>${esc(row.phone)}</dd>
<dt>Check on file</dt><dd>${row.check_type === 'NONE' ? 'None recorded' : `${esc(row.check_type)} ${esc(row.check_number || '')}`}</dd>
${row.check_expiry ? `<dt>Expires</dt><dd>${esc(row.check_expiry)}</dd>` : ''}
${site ? `<dt>Site</dt><dd>${esc(site.name)}</dd>` : '<dt>Site</dt><dd>Any site</dd>'}
${host ? `<dt>Usually visiting</dt><dd>${esc(host.name)}</dd>` : ''}
<dt>Issued</dt><dd>${esc(localStamp(nowIso()))}</dd>
</dl>
<p class="how">At the kiosk, tap <strong>I have a PIN</strong>, enter your mobile number
and this PIN, pick who you are visiting, and take a photo. Sign out with your last name and
mobile number on the way out.</p>
</div>
</body>
</html>`);
});
/* ------------------------------------------------------------- system */
router.get('/status', (req, res) => {
const scope = scopedSiteId(req);
res.json({
siteName: config.siteName,
timezone: config.timezone,
requirePhoto: config.requirePhoto,
photoRetentionDays: config.photoRetentionDays,
autoSignOutTime: config.autoSignOutTime || null,
expiryWarningDays: config.expiryWarningDays,
require2fa: config.admin.require2fa,
domainRule: users.domainRuleText(),
siteCount: listSites({ activeOnly: true }).length,
onSite: scope
? db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL AND site_id = ?').get(scope).n
: db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL').get().n,
sheets: {
enabled: sheets.isEnabled(),
queued: sheets.queueDepth(),
lastOk: sheets.status.lastOk,
lastError: sheets.status.lastError,
},
});
});
router.post('/sheets/test', async (req, res) => {
try {
res.json({ ok: true, ...(await sheets.testConnection()) });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.post('/sheets/flush', async (req, res) => {
try {
res.json(await sheets.flushQueue());
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.post('/photos/purge', (req, res) => {
res.json({ purged: purgeOldPhotos() });
});
export default router;
+343
View File
@@ -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;
+111
View File
@@ -0,0 +1,111 @@
import express from 'express';
import session from 'express-session';
import fs from 'node:fs';
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 { 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 });
});
app.use(express.static(publicDir, { extensions: ['html'] }));
app.get('/admin', (req, res) => res.sendFile(path.join(publicDir, 'admin.html')));
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(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT (AUTO)');
}
if (open.length) console.log(`[auto] signed out ${open.length} visitor(s) still on site`);
}, 60000).unref();
}
/* ------------------------------------------------------------- listen */
function start() {
if (config.https.enabled) {
if (!fs.existsSync(config.https.keyPath) || !fs.existsSync(config.https.certPath)) {
console.error(
`[https] certificate not found at ${config.https.certPath}. Run scripts/gen-cert.sh first.`
);
process.exit(1);
}
https
.createServer(
{ key: fs.readFileSync(config.https.keyPath), cert: fs.readFileSync(config.https.certPath) },
app
)
.listen(config.port, () => {
console.log(`[server] ${config.siteName} listening on https://0.0.0.0:${config.port}`);
});
} else {
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');
});
}
}
start();
+174
View File
@@ -0,0 +1,174 @@
import fs from 'node:fs';
import { google } from 'googleapis';
import config from './config.js';
import db from './db.js';
import { localStamp } from './util.js';
const HEADER = [
'Timestamp',
'Site',
'Action',
'Visitor type',
'First name',
'Last name',
'Phone',
'Email',
'Check type',
'Check number',
'Visiting',
'Signed in',
'Signed out',
'Photo on file',
'Visit ID',
];
let client = null;
let headerChecked = false;
export const status = { configured: false, lastOk: null, lastError: null };
function loadCredentials() {
if (config.sheets.credentialsB64) {
return JSON.parse(Buffer.from(config.sheets.credentialsB64, 'base64').toString('utf8'));
}
if (config.sheets.credentialsPath && fs.existsSync(config.sheets.credentialsPath)) {
return JSON.parse(fs.readFileSync(config.sheets.credentialsPath, 'utf8'));
}
throw new Error('No Google service account credentials found.');
}
function getClient() {
if (client) return client;
const creds = loadCredentials();
const auth = new google.auth.JWT({
email: creds.client_email,
key: creds.private_key,
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
client = google.sheets({ version: 'v4', auth });
return client;
}
export function isEnabled() {
return Boolean(config.sheets.enabled && config.sheets.spreadsheetId);
}
async function ensureHeader(sheets) {
if (headerChecked) return;
const range = `${config.sheets.tabName}!A1:O1`;
const res = await sheets.spreadsheets.values.get({
spreadsheetId: config.sheets.spreadsheetId,
range,
});
if (!res.data.values || res.data.values.length === 0) {
await sheets.spreadsheets.values.update({
spreadsheetId: config.sheets.spreadsheetId,
range,
valueInputOption: 'RAW',
requestBody: { values: [HEADER] },
});
}
headerChecked = true;
}
/** Builds the row that gets mirrored to the sheet for one sign in or sign out event. */
export function rowForVisit(visit, action) {
return [
localStamp(new Date().toISOString()),
visit.site_name || '',
action,
visit.visitor_type === 'frequent' ? 'Recurring' : 'Guest',
visit.first_name,
visit.last_name,
visit.phone || '',
visit.email || '',
visit.check_type === 'NONE' ? 'None' : visit.check_type,
visit.check_number || '',
visit.host_name,
localStamp(visit.signed_in_at),
visit.signed_out_at ? localStamp(visit.signed_out_at) : '',
visit.photo_path ? 'Yes' : 'No',
String(visit.id),
];
}
async function append(row) {
const sheets = getClient();
await ensureHeader(sheets);
await sheets.spreadsheets.values.append({
spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.tabName}!A:O`,
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
requestBody: { values: [row] },
});
}
function enqueue(row) {
db.prepare('INSERT INTO sheet_queue (payload) VALUES (?)').run(JSON.stringify(row));
}
/** Fire and forget: never let a Sheets outage block someone at the front desk. */
export function mirror(visit, action) {
if (!isEnabled()) return;
const row = rowForVisit(visit, action);
append(row)
.then(() => {
status.lastOk = new Date().toISOString();
status.lastError = null;
})
.catch((err) => {
status.lastError = err.message;
console.error('[sheets] append failed, queued for retry:', err.message);
enqueue(row);
});
}
export async function flushQueue() {
if (!isEnabled()) return { sent: 0, remaining: 0 };
const rows = db.prepare('SELECT * FROM sheet_queue ORDER BY id LIMIT 50').all();
let sent = 0;
for (const item of rows) {
try {
await append(JSON.parse(item.payload));
db.prepare('DELETE FROM sheet_queue WHERE id = ?').run(item.id);
sent += 1;
status.lastOk = new Date().toISOString();
status.lastError = null;
} catch (err) {
db.prepare('UPDATE sheet_queue SET attempts = attempts + 1, last_error = ? WHERE id = ?').run(
err.message,
item.id
);
status.lastError = err.message;
break; // Sheets is still unhappy; try again on the next tick.
}
}
const remaining = db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n;
return { sent, remaining };
}
export async function testConnection() {
if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.');
const sheets = getClient();
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
await ensureHeader(sheets);
status.lastOk = new Date().toISOString();
status.lastError = null;
return { title: meta.data.properties.title };
}
export function queueDepth() {
return db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n;
}
export function startWorker() {
if (!isEnabled()) {
console.log('[sheets] mirroring disabled');
return;
}
status.configured = true;
setInterval(() => {
flushQueue().catch((err) => console.error('[sheets] flush error:', err.message));
}, config.sheets.retryIntervalMs).unref();
console.log('[sheets] mirroring enabled ->', config.sheets.spreadsheetId);
}
+163
View File
@@ -0,0 +1,163 @@
import db from './db.js';
import { clean, localStamp } from './util.js';
export function listSites({ activeOnly = false } = {}) {
const sql = `SELECT * FROM sites ${activeOnly ? 'WHERE active = 1' : ''} ORDER BY name COLLATE NOCASE`;
return db.prepare(sql).all();
}
export function getSite(idOrSlug) {
if (idOrSlug === undefined || idOrSlug === null || idOrSlug === '') return null;
const asNumber = Number(idOrSlug);
if (Number.isInteger(asNumber) && String(asNumber) === String(idOrSlug)) {
return db.prepare('SELECT * FROM sites WHERE id = ?').get(asNumber) || null;
}
return db.prepare('SELECT * FROM sites WHERE slug = ?').get(String(idOrSlug).toLowerCase()) || null;
}
/** Falls back to the only active site, which keeps single-site installs simple. */
export function resolveSite(idOrSlug) {
const found = getSite(idOrSlug);
if (found && found.active) return found;
const active = listSites({ activeOnly: true });
return active.length === 1 ? active[0] : found || null;
}
export function slugify(value) {
return clean(value, 60)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40);
}
export function uniqueSlug(base, excludeId = null) {
let slug = slugify(base) || 'site';
let n = 2;
while (true) {
const clash = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
if (!clash || clash.id === excludeId) return slug;
slug = `${slugify(base)}-${n}`;
n += 1;
}
}
export function shapeSite(site) {
return {
id: site.id,
name: site.name,
slug: site.slug,
active: Boolean(site.active),
badge: {
enabled: Boolean(site.badge_enabled),
widthMm: site.badge_width_mm,
heightMm: site.badge_height_mm,
showPhoto: Boolean(site.badge_show_photo),
note: site.badge_note,
},
};
}
/* --------------------------------------------------------------- badge */
const esc = (value) =>
String(value ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
/**
* A self-contained print page sized to the site's label stock. It calls print()
* on load so a kiosk can drop it into a hidden iframe and get one badge out.
*/
export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {}) {
const width = Number(site.badge_width_mm) || 86;
const height = Number(site.badge_height_mm) || 54;
const showPhoto = Boolean(site.badge_show_photo) && Boolean(photoUrl);
// Scale the type with the smaller dimension so tiny labels stay legible.
const unit = Math.min(width, height);
const nameSize = Math.max(3.4, unit * 0.115);
const bodySize = Math.max(2.1, unit * 0.062);
const timeIn = new Date(visit.signed_in_at);
return `<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<title>Badge — ${esc(visit.first_name)} ${esc(visit.last_name)}</title>
<style>
@page { size: ${width}mm ${height}mm; margin: 0; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: #fff; }
.badge {
width: ${width}mm;
height: ${height}mm;
padding: ${unit * 0.075}mm ${unit * 0.09}mm;
display: flex;
gap: ${unit * 0.07}mm;
align-items: stretch;
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
color: #000;
overflow: hidden;
}
.photo {
width: ${unit * 0.42}mm;
flex: 0 0 auto;
object-fit: cover;
border: 0.3mm solid #000;
}
.body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.site {
font-size: ${bodySize * 0.85}mm;
letter-spacing: 0.02em;
border-bottom: 0.35mm solid #000;
padding-bottom: ${unit * 0.025}mm;
margin-bottom: ${unit * 0.045}mm;
}
.name {
font-size: ${nameSize}mm;
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.rows { margin-top: auto; font-size: ${bodySize}mm; line-height: 1.35; }
.rows b { font-weight: 700; }
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.03}mm; }
.flag {
display: inline-block;
padding: 0 ${unit * 0.03}mm;
border: 0.3mm solid #000;
font-size: ${bodySize * 0.85}mm;
}
@media screen {
body { background: #e7ecf0; padding: 12mm; }
.badge { background: #fff; box-shadow: 0 2mm 6mm rgba(0,0,0,.2); margin: 0 auto; }
}
</style>
</head>
<body>
<div class="badge">
${showPhoto ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : ''}
<div class="body">
<div class="site">${esc(site.name)} &middot; VISITOR</div>
<div class="name">${esc(visit.first_name)} ${esc(visit.last_name)}</div>
<div class="rows">
<div>Visiting <b>${esc(visit.host_name)}</b></div>
<div>In at <b>${esc(
timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })
)}</b> on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}</div>
<div>${
visit.check_type === 'NONE'
? '<span class="flag">No WWCC / VIT</span>'
: `${esc(visit.check_type)} ${esc(visit.check_number || '')}`
}</div>
${site.badge_note ? `<div class="note">${esc(site.badge_note)}</div>` : ''}
</div>
</div>
</div>
${autoPrint ? '<script>window.addEventListener("load", () => window.print());</script>' : ''}
</body>
</html>`;
}
export { esc as escapeHtml, localStamp };
+121
View File
@@ -0,0 +1,121 @@
import db from './db.js';
import config from './config.js';
import { hashPassword, randomPassword } from './auth.js';
import { nowIso } from './util.js';
const LOCKOUT_FAILS = 6;
const LOCKOUT_MINUTES = 15;
export function domainAllowed(email) {
const allowed = config.admin.allowedDomains;
if (!allowed.length) return true;
const domain = String(email).split('@')[1]?.toLowerCase() || '';
return allowed.some((d) => domain === d || domain.endsWith(`.${d}`));
}
export function domainRuleText() {
const allowed = config.admin.allowedDomains;
if (!allowed.length) return null;
return allowed.map((d) => `@${d}`).join(' or ');
}
export function findByEmail(email) {
return db
.prepare('SELECT * FROM admin_users WHERE email = ?')
.get(String(email).trim().toLowerCase());
}
export function findById(id) {
return db.prepare('SELECT * FROM admin_users WHERE id = ?').get(id);
}
export function countActive() {
return db.prepare('SELECT COUNT(*) AS n FROM admin_users WHERE active = 1').get().n;
}
export function shape(user) {
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
siteId: user.site_id,
twoFactorOn: Boolean(user.totp_enabled),
mustChangePassword: Boolean(user.must_change_password),
active: Boolean(user.active),
lastLoginAt: user.last_login_at,
createdAt: user.created_at,
};
}
export function createUser({ email, name, password, role = 'admin', siteId = null, mustChange = true }) {
const clean = String(email || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean)) throw new Error('Enter a valid email address.');
if (!domainAllowed(clean)) {
throw new Error(`Admin accounts must use an ${domainRuleText()} address.`);
}
if (findByEmail(clean)) throw new Error('An account already uses that email address.');
const temp = password || randomPassword();
const info = db
.prepare(
`INSERT INTO admin_users (email, name, password_hash, role, site_id, must_change_password)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(clean, String(name || '').trim() || null, hashPassword(temp), role, siteId, mustChange ? 1 : 0);
return { user: findById(info.lastInsertRowid), temporaryPassword: password ? null : temp };
}
/* ------------------------------------------------------------- lockout */
export function lockState(email) {
const row = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(email);
if (row?.locked_until && row.locked_until > nowIso()) return row;
return null;
}
export function noteFailure(email) {
const row = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(email);
const fails = (row?.fails || 0) + 1;
const lockedUntil =
fails >= LOCKOUT_FAILS ? new Date(Date.now() + LOCKOUT_MINUTES * 60000).toISOString() : null;
db.prepare(
`INSERT INTO login_attempts (email, fails, locked_until) VALUES (?, ?, ?)
ON CONFLICT(email) DO UPDATE SET fails = excluded.fails, locked_until = excluded.locked_until`
).run(email, fails, lockedUntil);
return { fails, lockedUntil };
}
export function clearFailures(email) {
db.prepare('DELETE FROM login_attempts WHERE email = ?').run(email);
}
/* ----------------------------------------------------------- bootstrap */
/** Creates the very first admin account from the environment, once. */
export function bootstrap() {
if (countActive() > 0) return;
const { bootstrapEmail, bootstrapPassword } = config.admin;
if (!bootstrapEmail || !bootstrapPassword) {
console.warn(
'[users] No admin accounts exist yet. Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD\n' +
' in .env and restart to create the first one.'
);
return;
}
try {
createUser({
email: bootstrapEmail,
name: 'First admin',
password: bootstrapPassword,
role: 'owner',
mustChange: true,
});
console.log(`[users] created the first admin account: ${bootstrapEmail}`);
console.log('[users] you will be asked to set a new password at first sign in');
} catch (err) {
console.error('[users] could not create the first admin account:', err.message);
}
}
+113
View File
@@ -0,0 +1,113 @@
import config from './config.js';
export function normalisePhone(input) {
if (!input) return '';
let digits = String(input).replace(/[^\d+]/g, '');
if (digits.startsWith('+61')) digits = '0' + digits.slice(3);
else if (digits.startsWith('61') && digits.length === 11) digits = '0' + digits.slice(2);
return digits.replace(/\+/g, '');
}
export function normaliseEmail(input) {
return String(input || '').trim().toLowerCase();
}
export function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || '').trim());
}
export function isPhone(value) {
const d = normalisePhone(value);
return d.length >= 8 && d.length <= 15;
}
export function clean(value, max = 200) {
return String(value ?? '').trim().slice(0, max);
}
export function titleCase(value, max = 200) {
return clean(value, max).replace(/\b\p{L}/gu, (c) => c.toUpperCase());
}
const dateFormatter = new Intl.DateTimeFormat('en-AU', {
timeZone: config.timezone,
dateStyle: 'short',
timeStyle: 'medium',
hour12: false,
});
export function localStamp(isoString) {
if (!isoString) return '';
return dateFormatter.format(new Date(isoString));
}
export function localHm(date = new Date()) {
const parts = new Intl.DateTimeFormat('en-AU', {
timeZone: config.timezone,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const hour = parts.find((p) => p.type === 'hour').value;
const minute = parts.find((p) => p.type === 'minute').value;
return `${hour}:${minute}`;
}
export function nowIso() {
return new Date().toISOString();
}
/** Minimal RFC4180-ish CSV parser: handles quoted fields, embedded commas and newlines. */
export function parseCsv(text) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
const src = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
for (let i = 0; i < src.length; i += 1) {
const ch = src[i];
if (inQuotes) {
if (ch === '"') {
if (src[i + 1] === '"') {
field += '"';
i += 1;
} else {
inQuotes = false;
}
} else {
field += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
row.push(field);
field = '';
} else if (ch === '\n') {
row.push(field);
rows.push(row);
row = [];
field = '';
} else {
field += ch;
}
}
if (field.length || row.length) {
row.push(field);
rows.push(row);
}
return rows.filter((r) => r.some((c) => c.trim() !== ''));
}
export function toCsv(rows) {
return rows
.map((row) =>
row
.map((cell) => {
const value = cell === null || cell === undefined ? '' : String(cell);
return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
})
.join(',')
)
.join('\r\n');
}