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

This commit is contained in:
2026-09-04 14:55:59 +10:00
commit 3278890491
41 changed files with 8920 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;
}
}
+166
View File
@@ -0,0 +1,166 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import config from './config.js';
/**
* Per-site branding: an uploaded banner and a small set of colours.
*
* Only three colours are settable, and the rest of the palette is derived from
* them. Exposing every colour would let someone produce an unreadable kiosk, and
* the one that matters most — the text on a coloured bar — is chosen by contrast
* rather than left to chance.
*/
export const DEFAULT_THEME = {
brand: '#0b4f4a',
signout: '#2c4a6b',
page: '#e7ecf0',
text: '#16202b',
};
const BANNER_DIR = path.join(config.dataDir, 'branding');
const MAX_BANNER_BYTES = 2 * 1024 * 1024;
fs.mkdirSync(BANNER_DIR, { recursive: true });
/* -------------------------------------------------------------- colour */
export function isHexColour(value) {
return /^#[0-9a-f]{6}$/i.test(String(value || '').trim());
}
export function normaliseColour(value, fallback) {
return isHexColour(value) ? String(value).trim().toLowerCase() : fallback;
}
function toRgb(hex) {
return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
}
/** Relative luminance, per WCAG, used to pick readable text over a colour. */
function luminance(hex) {
const [r, g, b] = toRgb(hex).map((channel) => {
const c = channel / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/** White or near-black, whichever is easier to read on the given background. */
export function readableOn(hex) {
return luminance(hex) > 0.45 ? '#16202b' : '#ffffff';
}
/** WCAG contrast ratio between two colours, from 1 (identical) to 21. */
export function contrastRatio(a, b) {
const la = luminance(a);
const lb = luminance(b);
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
return (hi + 0.05) / (lo + 0.05);
}
function mix(a, b, amount) {
const [ar, ag, ab] = toRgb(a);
const [br, bg, bb] = toRgb(b);
const channel = (x, y) => Math.round(x + (y - x) * amount);
return `#${[channel(ar, br), channel(ag, bg), channel(ab, bb)]
.map((c) => c.toString(16).padStart(2, '0'))
.join('')}`;
}
/**
* A softer version of the body text for labels and hints. It is mixed towards the
* background only as far as it can go while still clearing WCAG AA at 4.5:1 —
* a fixed grey looks fine on the default background and disappears on a custom one.
*/
export function mutedFor(text, page) {
for (const amount of [0.45, 0.38, 0.3, 0.22, 0.14]) {
const candidate = mix(text, page, amount);
if (contrastRatio(candidate, page) >= 4.5) return candidate;
}
return text;
}
/** Shifts a colour towards black (negative) or white (positive). */
export function shade(hex, amount) {
const channels = toRgb(hex).map((channel) => {
const target = amount < 0 ? 0 : 255;
const value = Math.round(channel + (target - channel) * Math.abs(amount));
return Math.max(0, Math.min(255, value));
});
return `#${channels.map((c) => c.toString(16).padStart(2, '0')).join('')}`;
}
/** The full palette the kiosk needs, derived from what an admin actually set. */
export function themeFor(site) {
const brand = normaliseColour(site?.colour_brand, DEFAULT_THEME.brand);
const signout = normaliseColour(site?.colour_signout, DEFAULT_THEME.signout);
const page = normaliseColour(site?.colour_page, DEFAULT_THEME.page);
// Body text: whatever was chosen, or readable-by-default against the page.
const ink = normaliseColour(site?.colour_text, readableOn(page) === '#ffffff' ? '#f2f5f7' : '#16202b');
return {
brand,
brandDark: shade(brand, -0.25),
onBrand: readableOn(brand),
signout,
signoutDark: shade(signout, -0.25),
onSignout: readableOn(signout),
page,
// A card needs to lift off the page whether the page is light or dark.
card: luminance(page) > 0.5 ? '#ffffff' : shade(page, 0.12),
ink,
muted: mutedFor(ink, page),
rule: luminance(page) > 0.5 ? shade(page, -0.12) : shade(page, 0.2),
// Surfaced so the admin console can warn about an unreadable combination.
textContrast: Number(contrastRatio(ink, page).toFixed(2)),
};
}
/* -------------------------------------------------------------- banner */
const ALIGNMENTS = new Set(['left', 'center']);
export function normaliseAlign(value, fallback = 'left') {
const clean = String(value || '').trim().toLowerCase();
return ALIGNMENTS.has(clean) ? clean : fallback;
}
/** Accepts a data URL from the admin console and writes it to disk. */
export function saveBanner(siteId, dataUrl) {
const match = /^data:image\/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)$/.exec(
String(dataUrl || '').trim()
);
if (!match) {
// SVG is deliberately not accepted: it can carry script, and this file is
// served to every kiosk.
throw new Error('Use a PNG, JPEG or WebP image. PNG keeps transparency.');
}
const buffer = Buffer.from(match[2], 'base64');
if (buffer.length > MAX_BANNER_BYTES) throw new Error('That image is over 2 MB. Use a smaller one.');
const ext = match[1] === 'jpg' ? 'jpeg' : match[1];
const name = `site-${siteId}-${crypto.randomBytes(4).toString('hex')}.${ext}`;
fs.mkdirSync(BANNER_DIR, { recursive: true });
fs.writeFileSync(path.join(BANNER_DIR, name), buffer);
return name;
}
export function bannerAbsolutePath(name) {
if (!name) return null;
const resolved = path.resolve(BANNER_DIR, name);
if (!resolved.startsWith(path.resolve(BANNER_DIR))) return null;
return fs.existsSync(resolved) ? resolved : null;
}
export function deleteBanner(name) {
const abs = bannerAbsolutePath(name);
if (abs) {
try {
fs.unlinkSync(abs);
} catch {
/* already gone */
}
}
}
+96
View File
@@ -0,0 +1,96 @@
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),
},
printing: {
// brother_ql drives the label printer over the network. Overridable so a
// wrapper or a different binary can be swapped in.
command: process.env.PRINT_COMMAND || 'brother_ql',
timeoutMs: int(process.env.PRINT_TIMEOUT_MS, 15000),
// How long a sign in waits for the badge before falling back to the browser.
signInWaitMs: int(process.env.PRINT_SIGNIN_WAIT_MS, 9000),
},
// 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'),
// Names and addresses staff will actually type. Baked into the certificate.
hostnames: (process.env.HTTPS_HOSTNAMES || 'visitors.local')
.split(',')
.map((h) => h.trim())
.filter(Boolean),
// A plain http listener that serves the CA certificate and redirects
// everything else to https. 0 turns it off.
redirectPort: int(process.env.HTTP_REDIRECT_PORT, 3001),
// The https port as published on the docker host, used when redirecting.
publicPort: int(process.env.HTTPS_PUBLIC_PORT, 8443),
},
sheets: {
enabled: bool(process.env.SHEETS_ENABLED, false),
spreadsheetId: process.env.SHEETS_SPREADSHEET_ID || '',
// Append-only history of every sign in and sign out.
logTab: process.env.SHEETS_LOG_TAB || process.env.SHEETS_TAB_NAME || 'Visitor log',
// Rewritten on every change: just the people currently on site.
onSiteTab: process.env.SHEETS_ONSITE_TAB || 'On site now',
// 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;
+294
View File
@@ -0,0 +1,294 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import config from './config.js';
import { decryptPin, pinLookup } from './pins.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 62,
badge_height_mm REAL NOT NULL DEFAULT 100,
badge_show_photo INTEGER NOT NULL DEFAULT 1,
badge_accent INTEGER NOT NULL DEFAULT 0,
badge_note TEXT,
printer_enabled INTEGER NOT NULL DEFAULT 0,
printer_host TEXT,
printer_port INTEGER NOT NULL DEFAULT 9100,
printer_model TEXT NOT NULL DEFAULT 'QL-820NWB',
printer_rotate INTEGER NOT NULL DEFAULT 0,
banner_path TEXT,
banner_height INTEGER NOT NULL DEFAULT 64,
banner_align TEXT NOT NULL DEFAULT 'left',
colour_brand TEXT,
colour_signout TEXT,
colour_page TEXT,
colour_text 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,
company TEXT,
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,
pin_lookup TEXT,
photo_path TEXT,
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,
company TEXT,
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');
addColumn('visits', 'photo_path', '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');
// A photo kept on file, so a regular visitor is not asked to pose every visit.
addColumn('frequent_visitors', 'photo_path', 'TEXT');
// PINs are stored encrypted with a random IV, so the same PIN encrypts differently
// every time and cannot be compared. This deterministic digest makes the uniqueness
// check and the index possible.
addColumn('frequent_visitors', 'pin_lookup', 'TEXT');
// Two-colour printing, for rolls like the Brother DK-22251.
addColumn('sites', 'badge_accent', 'INTEGER NOT NULL DEFAULT 0');
// Per-site branding on the kiosk.
addColumn('sites', 'banner_path', 'TEXT');
addColumn('sites', 'banner_height', 'INTEGER NOT NULL DEFAULT 64');
addColumn('sites', 'banner_align', "TEXT NOT NULL DEFAULT 'left'");
addColumn('sites', 'colour_brand', 'TEXT');
addColumn('sites', 'colour_signout', 'TEXT');
addColumn('sites', 'colour_page', 'TEXT');
addColumn('sites', 'colour_text', 'TEXT');
// Optional "who are you from", handy for contractors and visiting staff.
addColumn('visits', 'company', 'TEXT');
// Server-side printing, so a kiosk needs no printer driver of its own.
addColumn('sites', 'printer_enabled', 'INTEGER NOT NULL DEFAULT 0');
addColumn('sites', 'printer_host', 'TEXT');
addColumn('sites', 'printer_port', 'INTEGER NOT NULL DEFAULT 9100');
addColumn('sites', 'printer_model', "TEXT NOT NULL DEFAULT 'QL-820NWB'");
addColumn('sites', 'printer_rotate', 'INTEGER NOT NULL DEFAULT 0');
addColumn('frequent_visitors', 'company', 'TEXT');
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);
/* -------------------------------------------------- one record per person */
/**
* Existing records predate the PIN digest, so fill it in once. Without this,
* a legacy visitor's PIN would be invisible to the uniqueness check and could be
* handed out to somebody else.
*/
const needingLookup = db
.prepare('SELECT id, pin_enc FROM frequent_visitors WHERE pin_lookup IS NULL')
.all();
if (needingLookup.length) {
const setLookup = db.prepare('UPDATE frequent_visitors SET pin_lookup = ? WHERE id = ?');
let filled = 0;
for (const row of needingLookup) {
const pin = decryptPin(row.pin_enc);
if (pin) {
setLookup.run(pinLookup(pin), row.id);
filled += 1;
}
}
console.log(`[db] indexed ${filled} existing PIN(s) for the uniqueness check`);
}
/** Names any records that already collide, so an admin knows who to fix. */
function reportDuplicates(column, label) {
const rows = db
.prepare(
`SELECT ${column} AS value, GROUP_CONCAT(first_name || ' ' || last_name, ', ') AS people
FROM frequent_visitors
WHERE ${column} IS NOT NULL AND ${column} <> ''
GROUP BY ${column} HAVING COUNT(*) > 1`
)
.all();
for (const row of rows) {
console.warn(`[db] duplicate ${label} shared by: ${row.people}`);
}
return rows.length;
}
const duplicates =
reportDuplicates('lower(email)', 'email address') + reportDuplicates('pin_lookup', 'PIN');
if (duplicates) {
console.warn(
'[db] Fix the records above in Admin -> Recurring visitors. Until then the database ' +
'cannot enforce uniqueness, though new and edited records are still checked.'
);
}
/**
* Saved people must be unique on mobile number, email address and PIN. The phone
* column has carried a UNIQUE constraint from the start; these add the other two.
* Existing data may already contain duplicates, so a failure here is reported
* rather than thrown — the application-level checks still refuse new collisions.
*/
function addUniqueIndex(name, sql, what) {
try {
db.exec(sql);
} catch (err) {
console.warn(
`[db] could not enforce unique ${what}: ${err.message}\n` +
` Existing records collide. Fix them in Admin -> Recurring visitors; ` +
`new and edited records are still checked.`
);
}
}
addUniqueIndex(
'idx_freq_email',
`CREATE UNIQUE INDEX IF NOT EXISTS idx_freq_email
ON frequent_visitors(lower(email)) WHERE email IS NOT NULL AND email <> ''`,
'email addresses'
);
addUniqueIndex(
'idx_freq_pin',
`CREATE UNIQUE INDEX IF NOT EXISTS idx_freq_pin
ON frequent_visitors(pin_lookup) WHERE pin_lookup IS NOT NULL`,
'PINs'
);
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;
+89
View File
@@ -0,0 +1,89 @@
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);
}
/**
* Copies a recurring visitor's stored photo into a new file for one visit.
*
* A copy rather than a shared reference on purpose: the visit record is a snapshot
* of who was in the building that day, so replacing someone's profile photo later
* must not retroactively change what every past visit shows. It also keeps photo
* retention simple — purging old visits can never delete a live profile photo.
*/
export function copyStoredPhoto(relative) {
const source = photoAbsolutePath(relative);
if (!source) return null;
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 ext = path.extname(source) || '.jpeg';
const name = `${now.toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(4).toString('hex')}${ext}`;
fs.copyFileSync(source, path.join(dir, name));
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;
}
+70
View File
@@ -0,0 +1,70 @@
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);
}
// PINs people will misread on a printed pass, or guess first.
const BANNED_PINS = new Set(['0000', '1111', '1234', '4321', '9999', '1122', '2580']);
/**
* A deterministic digest of a PIN, so two records can be compared without either
* being decrypted. Keyed with APP_SECRET, so the database alone does not let
* anyone build a lookup table of all 10,000 possibilities.
*/
export function pinLookup(pin) {
return crypto.createHmac('sha256', key).update(String(pin)).digest('hex');
}
/**
* A PIN nobody else holds. `isTaken` is passed in by the caller so this module
* stays free of database knowledge.
*/
export function generatePin(isTaken = () => false) {
for (let attempt = 0; attempt < 200; attempt += 1) {
const pin = String(crypto.randomInt(0, 10000)).padStart(4, '0');
if (!BANNED_PINS.has(pin) && !isTaken(pin)) return pin;
}
throw new Error(
'No unused 4 digit PIN could be found. Deactivate some old recurring visitors first.'
);
}
+360
View File
@@ -0,0 +1,360 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas';
import config from './config.js';
import { photoAbsolutePath } from './photos.js';
/**
* Printing happens on the server, not in the kiosk browser.
*
* The badge is drawn to a bitmap here and pushed straight to the printer over the
* network, so the tablet at the door needs no printer driver, no default printer
* and no print dialog — and a second kiosk can be added without configuring
* anything on it.
*
* The QL-820NWB prints 696 dots across a 62 mm roll at 300 dpi. That figure is
* fixed by the printer, so the bitmap is always 696 wide however the badge is
* laid out; rotation is applied to the finished image, not to the layout.
*/
const DPI = 300;
const DOTS_ACROSS_62MM = 696;
const FONT = 'Liberation Sans, DejaVu Sans, Arial, sans-serif';
const mm = (value) => Math.round((value / 25.4) * DPI);
/** Per-site outcome of the last print, surfaced in the admin console. */
const lastResult = new Map();
export function printerStatus(siteId) {
return lastResult.get(Number(siteId)) || null;
}
function note(siteId, ok, message) {
lastResult.set(Number(siteId), { ok, message, at: new Date().toISOString() });
}
export function isConfigured(site) {
return Boolean(site?.printer_enabled && site?.printer_host);
}
/** brother_ql's label id. The two-colour roll is a different label to the plain one. */
export function labelFor(site) {
return site?.badge_accent ? '62red' : '62';
}
/* ------------------------------------------------------------ rendering */
function wrapText(ctx, text, maxWidth, maxLines) {
const words = String(text || '').split(/\s+/).filter(Boolean);
const lines = [];
let line = '';
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (ctx.measureText(candidate).width <= maxWidth || !line) {
line = candidate;
} else {
lines.push(line);
line = word;
if (lines.length === maxLines - 1) break;
}
}
if (line) lines.push(line);
return lines.slice(0, maxLines);
}
/**
* Draws the badge at its designed size in dots. Mirrors the browser badge so the
* preview and the printed label agree.
*/
async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY = null) {
const unit = Math.min(widthDots, heightDots);
const pad = Math.round(unit * 0.07);
const black = '#000000';
const red = accent ? '#ff0000' : '#000000';
if (startY !== null) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, widthDots, heightDots);
}
const portrait = heightDots >= widthDots * 1.2;
const nameSize = Math.max(mm(3.2), Math.round(unit * (portrait ? 0.105 : 0.115)));
const bodySize = Math.max(mm(2.0), Math.round(unit * (portrait ? 0.055 : 0.062)));
let photo = null;
const abs = site.badge_show_photo ? photoAbsolutePath(visit.photo_path) : null;
if (abs) {
try {
photo = await loadImage(abs);
} catch {
photo = null;
}
}
const photoSize = photo ? Math.round(unit * (portrait ? 0.52 : 0.5)) : 0;
let cursorY = startY === null ? pad : startY;
let textLeft = pad;
let textWidth = widthDots - pad * 2;
const paint = startY !== null;
if (photo) {
if (portrait) {
const x = Math.round((widthDots - photoSize) / 2);
if (paint) ctx.drawImage(photo, x, cursorY, photoSize, photoSize);
if (paint) {
ctx.strokeStyle = black;
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
ctx.strokeRect(x, cursorY, photoSize, photoSize);
}
cursorY += photoSize + Math.round(unit * 0.05);
} else {
const y = Math.round((heightDots - photoSize) / 2);
if (paint) {
ctx.drawImage(photo, pad, y, photoSize, photoSize);
ctx.strokeStyle = black;
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
ctx.strokeRect(pad, y, photoSize, photoSize);
}
textLeft = pad + photoSize + Math.round(unit * 0.05);
textWidth = widthDots - textLeft - pad;
}
}
ctx.textBaseline = 'top';
ctx.textAlign = portrait ? 'center' : 'left';
const centreX = portrait ? widthDots / 2 : textLeft;
// Site name, with a rule under it.
ctx.fillStyle = red;
ctx.font = `${Math.round(bodySize * 0.8)}px ${FONT}`;
if (paint) ctx.fillText(`${site.name.toUpperCase()} · VISITOR`, centreX, cursorY, textWidth);
cursorY += Math.round(bodySize * 0.8 * 1.3);
if (paint) ctx.fillRect(textLeft, cursorY, textWidth, Math.max(2, Math.round(mm(0.35))));
cursorY += Math.round(unit * 0.04);
// Name, wrapped to at most two lines.
ctx.fillStyle = black;
ctx.font = `bold ${nameSize}px ${FONT}`;
const nameLines = wrapText(ctx, `${visit.first_name} ${visit.last_name}`, textWidth, 2);
for (const line of nameLines) {
if (paint) ctx.fillText(line, centreX, cursorY, textWidth);
cursorY += Math.round(nameSize * 1.05);
}
cursorY += Math.round(unit * 0.04);
// Detail rows.
const timeIn = new Date(visit.signed_in_at);
const rows = [
`Visiting ${visit.host_name}`,
`In at ${timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })} on ${timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' })}`,
];
ctx.font = `${bodySize}px ${FONT}`;
ctx.fillStyle = black;
for (const row of rows) {
for (const line of wrapText(ctx, row, textWidth, 2)) {
if (paint) ctx.fillText(line, centreX, cursorY, textWidth);
cursorY += Math.round(bodySize * 1.3);
}
}
// Check status: boxed and in the accent colour when they hold nothing.
if (visit.check_type === 'NONE') {
const label = 'No WWCC / VIT';
ctx.font = `bold ${Math.round(bodySize * 0.95)}px ${FONT}`;
const w = ctx.measureText(label).width + bodySize;
const x = portrait ? Math.round((widthDots - w) / 2) : textLeft;
const h = Math.round(bodySize * 1.5);
if (paint) {
ctx.strokeStyle = red;
ctx.lineWidth = Math.max(2, Math.round(mm(0.35)));
ctx.strokeRect(x, cursorY, w, h);
ctx.fillStyle = red;
ctx.textAlign = 'center';
ctx.fillText(label, x + w / 2, cursorY + Math.round(bodySize * 0.25));
ctx.textAlign = portrait ? 'center' : 'left';
}
cursorY += h + Math.round(bodySize * 0.3);
} else {
ctx.fillStyle = black;
ctx.font = `${bodySize}px ${FONT}`;
if (paint) {
ctx.fillText(`${visit.check_type} ${visit.check_number || ''}`.trim(), centreX, cursorY, textWidth);
}
cursorY += Math.round(bodySize * 1.3);
}
if (site.badge_note) {
ctx.fillStyle = black;
ctx.font = `${Math.round(bodySize * 0.85)}px ${FONT}`;
for (const line of wrapText(ctx, site.badge_note, textWidth, 2)) {
if (paint) ctx.fillText(line, centreX, cursorY, textWidth);
cursorY += Math.round(bodySize * 1.1);
}
}
return cursorY - (startY === null ? pad : startY);
}
/**
* Produces the PNG that gets sent to the printer.
*
* The bitmap is always 696 dots across, because that is the printer's fixed head
* width on a 62 mm roll. With rotation the badge is laid out along the length of
* the label instead and the finished image is turned, so the content still lands
* within those 696 dots.
*/
export async function renderBadgePng(visit, site) {
const rotate = Number(site.printer_rotate) || 0;
const lengthMm = Number(site.badge_height_mm) || 90;
const turned = rotate === 90 || rotate === 270;
const acrossDots = DOTS_ACROSS_62MM;
const alongDots = mm(lengthMm);
// Design canvas: swapped when the badge is laid out along the label.
const designW = turned ? alongDots : acrossDots;
const designH = turned ? acrossDots : alongDots;
const design = createCanvas(designW, designH);
const ctx = design.getContext('2d');
const accent = Boolean(site.badge_accent);
// Measure first, then draw the block centred down the label. Without this the
// content hugs the top and leaves a wide blank strip at the bottom of every badge.
const used = await drawBadge(ctx, designW, designH, visit, site, accent, null);
const pad = Math.round(Math.min(designW, designH) * 0.07);
const startY = Math.max(pad, Math.round((designH - used) / 2));
await drawBadge(ctx, designW, designH, visit, site, accent, startY);
if (!rotate) return design.toBuffer('image/png');
const out = createCanvas(turned ? acrossDots : designW, turned ? alongDots : designH);
const outCtx = out.getContext('2d');
outCtx.fillStyle = '#ffffff';
outCtx.fillRect(0, 0, out.width, out.height);
outCtx.translate(out.width / 2, out.height / 2);
outCtx.rotate((rotate * Math.PI) / 180);
outCtx.drawImage(design, -designW / 2, -designH / 2);
return out.toBuffer('image/png');
}
/* -------------------------------------------------------------- sending */
/**
* brother_ql reports failures as a Python traceback. Nobody at a front desk can
* act on that, so the useful last line is pulled out and the common network
* failures are rewritten as something with a next step.
*/
function explainPrintError(output, host) {
const lines = String(output || '')
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !/^deprecation warning/i.test(l));
const last = lines[lines.length - 1] || '';
if (/Connection refused/i.test(last)) {
return `${host} refused the connection. Check the printer is switched on and that port 9100 is the right one.`;
}
if (/timed out|timeout/i.test(last)) {
return `${host} did not answer. Check the IP address and that the printer is on the same network as the server.`;
}
if (/No route to host|Network is unreachable/i.test(last)) {
return `${host} cannot be reached from the server. Check the address and any firewall between them.`;
}
if (/Name or service not known|getaddrinfo/i.test(last)) {
return `${host} could not be resolved. Use the printer's IP address rather than a name.`;
}
if (/Unknown label|label/i.test(last) && /identifier/i.test(last)) {
return 'The printer rejected the label size. Check the roll loaded matches the badge settings.';
}
return last || 'The printer did not accept the job.';
}
function runBrotherQl(args, timeoutMs) {
return new Promise((resolve, reject) => {
execFile(
config.printing.command,
args,
{ timeout: timeoutMs, env: { ...process.env, BROTHER_QL_PRINTER: '', BROTHER_QL_MODEL: '' } },
(err, stdout, stderr) => {
const output = `${stdout || ''}${stderr || ''}`.trim();
if (err) {
if (err.code === 'ENOENT') {
return reject(
new Error(
`${config.printing.command} is not installed in the container. Rebuild the image, or set PRINT_COMMAND.`
)
);
}
if (err.killed) return reject(new Error('The printer did not respond in time.'));
return reject(new Error(output || err.message));
}
resolve(output);
}
);
});
}
/**
* Renders and prints one badge. Resolves with a short description on success and
* rejects with something an admin can act on.
*/
export async function printBadge(visit, site) {
if (!isConfigured(site)) throw new Error('Server printing is not turned on for this site.');
const png = await renderBadgePng(visit, site);
const file = path.join(os.tmpdir(), `badge-${crypto.randomBytes(6).toString('hex')}.png`);
fs.writeFileSync(file, png);
const port = Number(site.printer_port) || 9100;
const target = `tcp://${site.printer_host}:${port}`;
try {
await runBrotherQl(
[
'--backend', 'network',
'--model', site.printer_model || 'QL-820NWB',
'--printer', target,
'print',
'--label', labelFor(site),
file,
],
config.printing.timeoutMs
);
note(site.id, true, `Printed to ${site.printer_host}`);
return { ok: true, target };
} catch (err) {
const friendly = explainPrintError(err.message, site.printer_host);
note(site.id, false, friendly);
throw new Error(friendly);
} finally {
fs.rm(file, { force: true }, () => {});
}
}
/** A sample badge, for checking the printer and the layout without a real visit. */
export function sampleVisit(site) {
return {
id: 0,
first_name: 'Sample',
last_name: 'Visitor',
host_name: 'Jess Rogerson',
check_type: 'NONE',
check_number: null,
photo_path: null,
signed_in_at: new Date().toISOString(),
site_name: site.name,
};
}
export function available() {
return new Promise((resolve) => {
execFile(config.printing.command, ['--version'], (err) => resolve(!err));
});
}
+1245
View File
File diff suppressed because it is too large Load Diff
+398
View File
@@ -0,0 +1,398 @@
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 * as printer from '../printer.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 */
/** Rejects rather than hanging the front desk on a printer that never answers. */
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Printing timed out.')), ms)),
]);
}
router.post('/signin', signInLimiter, async (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;
// With a networked printer the server does the printing, so the tablet needs
// no driver and no default printer. It is awaited briefly rather than fired
// and forgotten: if the printer is unreachable the kiosk falls back to its
// own print dialog instead of the visitor walking off without a badge.
let serverPrinted = false;
if (site.badge_enabled && printer.isConfigured(site)) {
try {
await withTimeout(printer.printBadge(visit, site), config.printing.signInWaitMs);
serverPrinted = true;
} catch (err) {
console.error('[print] badge failed, kiosk will fall back:', err.message);
}
}
// 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,
serverPrinted,
// Only offered when the server did not already print it.
badgeUrl: site.badge_enabled && !serverPrinted ? `/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;
+183
View File
@@ -0,0 +1,183 @@
import express from 'express';
import session from 'express-session';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import config from './config.js';
import db from './db.js';
import kioskRoutes from './routes/kiosk.js';
import adminRoutes from './routes/admin.js';
import * as sheets from './sheets.js';
import * as users from './users.js';
import * as tls from './tls.js';
import { purgeOldPhotos } from './photos.js';
import { localHm, nowIso } from './util.js';
users.bootstrap();
const here = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.join(here, '..', 'public');
const app = express();
if (config.trustProxy) app.set('trust proxy', 1);
app.disable('x-powered-by');
// Photos arrive as base64 data URLs in the sign-in payload.
app.use(express.json({ limit: '8mb' }));
app.use(
session({
secret: config.appSecret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: config.secureCookies,
maxAge: 8 * 60 * 60 * 1000,
},
})
);
app.use('/api', kioskRoutes);
app.use('/admin/api', adminRoutes);
app.get('/healthz', (req, res) => {
res.json({ ok: true, onSite: db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL').get().n });
});
/* ------------------------------------------------------------ admin pages */
// The console and the sign in screen are separate documents, so these must be
// declared before express.static or it would serve them itself and skip the
// redirect that keeps an unauthenticated browser off the console.
function sessionUser(req) {
if (!req.session?.adminUserId) return null;
const user = users.findById(req.session.adminUserId);
return user && user.active ? user : null;
}
app.get('/admin', (req, res) => {
const user = sessionUser(req);
if (!user || user.must_change_password) return res.redirect('/admin/login');
res.sendFile(path.join(publicDir, 'admin.html'));
});
app.get('/admin/login', (req, res) => {
const user = sessionUser(req);
if (user && !user.must_change_password) return res.redirect('/admin');
res.sendFile(path.join(publicDir, 'login.html'));
});
// Nobody should land on the raw filenames; keep one address per page.
app.get(['/admin.html', '/login.html'], (req, res) => res.redirect('/admin'));
app.use(express.static(publicDir, { extensions: ['html'], index: false }));
app.get('/favicon.ico', (req, res) => res.redirect(301, '/favicon.svg'));
app.use((req, res) => res.status(404).sendFile(path.join(publicDir, 'index.html')));
app.use((err, req, res, next) => {
console.error('[error]', err);
res.status(500).json({ error: 'Something went wrong on the server.' });
});
/* ------------------------------------------------------- background jobs */
sheets.startWorker();
setInterval(purgeOldPhotos, 24 * 60 * 60 * 1000).unref();
purgeOldPhotos();
if (config.autoSignOutTime) {
let lastRunDay = '';
setInterval(() => {
const today = new Date().toISOString().slice(0, 10);
if (lastRunDay === today) return;
if (localHm() < config.autoSignOutTime) return;
lastRunDay = today;
const open = db.prepare('SELECT * FROM visits WHERE signed_out_at IS NULL').all();
for (const visit of open) {
db.prepare('UPDATE visits SET signed_out_at = ?, signed_out_by = ? WHERE id = ?').run(
nowIso(),
'auto',
visit.id
);
sheets.mirror();
}
if (open.length) console.log(`[auto] signed out ${open.length} visitor(s) still on site`);
}, 60000).unref();
}
/* ------------------------------------------------------------- listen */
/**
* A plain http listener that does two jobs: hands out the CA certificate (so a new
* tablet can fetch it without first trusting the very certificate it is missing),
* and pushes everything else to https.
*/
function startRedirectServer() {
const port = config.https.redirectPort;
if (!port) return;
http
.createServer((req, res) => {
if (req.url === '/ca.crt' || req.url === '/ca.pem') {
const ca = tls.caCertificate();
if (!ca) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('No certificate authority has been generated yet.');
}
res.writeHead(200, {
'Content-Type': 'application/x-x509-ca-cert',
'Content-Disposition': 'attachment; filename="visitor-signin-ca.crt"',
});
return res.end(ca);
}
const host = String(req.headers.host || '').split(':')[0];
const target = `https://${host}:${config.https.publicPort}${req.url}`;
res.writeHead(302, { Location: target });
res.end(`Moved to ${target}`);
})
.listen(port, () => {
console.log(`[server] http helper on port ${port} — serves /ca.crt, redirects to https`);
});
}
function start() {
if (!config.https.enabled) {
http.createServer(app).listen(config.port, () => {
console.log(`[server] ${config.siteName} listening on http://0.0.0.0:${config.port}`);
console.log('[server] camera capture needs HTTPS or localhost — see README before rolling out');
});
return;
}
let material;
try {
material = tls.ensureCertificates();
} catch (err) {
console.error(`[tls] ${err.message}`);
process.exit(1);
}
let server = https.createServer({ key: material.key, cert: material.cert }, app);
server.listen(config.port, () => {
const names = material.info.server?.names?.join(', ') || 'this host';
console.log(`[server] ${config.siteName} listening on https://0.0.0.0:${config.port}`);
console.log(`[tls] certificate valid for ${names}`);
console.log(`[tls] expires ${material.info.server?.validTo} (${material.info.server?.daysLeft} days)`);
console.log('[tls] install the CA on each kiosk device — see README');
});
// Swap the certificate in without dropping the listener when it renews.
tls.scheduleRenewal(() => {
const fresh = tls.ensureCertificates();
server.setSecureContext({ key: fresh.key, cert: fresh.cert });
console.log('[tls] certificate renewed and reloaded without a restart');
});
startRedirectServer();
}
start();
+286
View File
@@ -0,0 +1,286 @@
import fs from 'node:fs';
import { google } from 'googleapis';
import config from './config.js';
import db from './db.js';
import { localStamp } from './util.js';
/**
* The spreadsheet is an evacuation list and nothing else.
*
* One tab, rewritten in full whenever anyone signs in or out, holding only the
* people currently in the building. It is never appended to, so there is no
* history to scroll past while standing in a car park counting heads.
*
* The full visit history stays in the application's own database, where it is
* searchable in the admin console and exportable as CSV.
*/
const HEADER = [
'Site',
'First name',
'Last name',
'Company',
'Visiting',
'Phone',
'Email',
'Check',
'Signed in',
'On site for',
'Visit ID',
];
const MAX_ROWS = 1000;
let client = null;
let tabPromise = null;
let dirty = false;
let syncing = false;
export const status = {
lastOk: null,
lastError: null,
onSiteCount: null,
};
/* ----------------------------------------------------------- connection */
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;
}
/**
* The service account's own address. Nothing works until the spreadsheet is
* shared with it, and it is buried in a JSON key file nobody wants to open on a
* server, so the admin console shows it.
*/
export function serviceAccountEmail() {
try {
return loadCredentials().client_email || null;
} catch {
return null;
}
}
/**
* Google's own wording for these failures says what went wrong but never what to
* do about it, so the common ones are rewritten with the actual next step.
*/
function explain(err) {
const code = err?.code || err?.response?.status;
const raw = String(err?.message || '');
const email = serviceAccountEmail();
if (code === 403 && /caller does not have permission|permission/i.test(raw)) {
return (
`The service account cannot open this spreadsheet. Share the sheet with ` +
`${email || 'the service account address'} and give it Editor access.`
);
}
if (code === 403 && /has not been used|accessNotConfigured|disabled/i.test(raw)) {
return 'The Google Sheets API is not enabled on that Google Cloud project. Enable it, then wait a minute and retry.';
}
if (code === 404) {
return 'No spreadsheet was found with that ID. Check SHEETS_SPREADSHEET_ID against the sheet URL.';
}
if (code === 400 && /Unable to parse range/i.test(raw)) {
return `The tab "${config.sheets.onSiteTab}" could not be addressed. Check SHEETS_ONSITE_TAB matches the tab name exactly.`;
}
if (/invalid_grant|Invalid JWT|clock/i.test(raw)) {
return "Google rejected the credentials. Check the server's clock is correct and the service account key has not been deleted.";
}
return raw || 'Unknown error talking to Google Sheets.';
}
export function isEnabled() {
return Boolean(config.sheets.enabled && config.sheets.spreadsheetId);
}
/**
* Creates the tab if it is missing. Memoised as a promise rather than a boolean:
* two syncs starting at once would otherwise both decide it was missing.
*/
function ensureTab(sheets, { force = false } = {}) {
if (force) tabPromise = null;
if (!tabPromise) {
tabPromise = doEnsureTab(sheets).catch((err) => {
tabPromise = null;
throw err;
});
}
return tabPromise;
}
async function doEnsureTab(sheets) {
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
const titles = meta.data.sheets.map((s) => s.properties.title);
if (!titles.includes(config.sheets.onSiteTab)) {
await sheets.spreadsheets.batchUpdate({
spreadsheetId: config.sheets.spreadsheetId,
requestBody: {
requests: [{ addSheet: { properties: { title: config.sheets.onSiteTab } } }],
},
});
console.log(`[sheets] created tab "${config.sheets.onSiteTab}"`);
}
}
/* --------------------------------------------------------- who is here */
function humanDuration(fromIso) {
const minutes = Math.max(0, Math.round((Date.now() - new Date(fromIso).getTime()) / 60000));
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`;
}
function onSiteRows() {
return db
.prepare('SELECT * FROM visits WHERE signed_out_at IS NULL ORDER BY site_name, signed_in_at')
.all()
.slice(0, MAX_ROWS)
.map((v) => [
v.site_name || '',
v.first_name,
v.last_name,
v.company || '',
v.host_name,
v.phone || '',
v.email || '',
v.check_type === 'NONE' ? 'None' : `${v.check_type} ${v.check_number || ''}`.trim(),
localStamp(v.signed_in_at),
humanDuration(v.signed_in_at),
String(v.id),
]);
}
/**
* Replaces the whole tab with the current state. Rewriting rather than patching
* means a missed update can never leave a stale name on the evacuation list:
* whatever is on the tab is what the database says right now.
*/
export async function syncOnSite() {
if (!isEnabled()) return { skipped: true };
if (syncing) {
dirty = true;
return { skipped: true };
}
syncing = true;
try {
const sheets = getClient();
await ensureTab(sheets);
const rows = onSiteRows();
const banner = `On site now — ${rows.length} ${rows.length === 1 ? 'person' : 'people'} — updated ${localStamp(new Date().toISOString())}`;
await sheets.spreadsheets.values.clear({
spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.onSiteTab}!A1:K${MAX_ROWS + 10}`,
});
await sheets.spreadsheets.values.update({
spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.onSiteTab}!A1`,
valueInputOption: 'RAW',
requestBody: { values: [[banner], HEADER, ...rows] },
});
dirty = false;
status.lastOk = new Date().toISOString();
status.lastError = null;
status.onSiteCount = rows.length;
return { rows: rows.length };
} catch (err) {
dirty = true;
status.lastError = explain(err);
const wrapped = new Error(status.lastError);
wrapped.cause = err;
throw wrapped;
} finally {
syncing = false;
}
}
/**
* Called after every sign in and sign out. Fire and forget: a Sheets outage must
* never hold up someone standing at the front desk. A failure leaves the tab
* marked stale and the worker retries.
*/
export function mirror() {
if (!isEnabled()) return;
dirty = true;
syncOnSite().catch((err) => console.error('[sheets] sync failed, will retry:', err.message));
}
export async function testConnection() {
if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.');
try {
const sheets = getClient();
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
await ensureTab(sheets, { force: true });
await syncOnSite();
status.lastError = null;
return { title: meta.data.properties.title, tab: config.sheets.onSiteTab };
} catch (err) {
status.lastError = explain(err);
throw new Error(status.lastError);
}
}
export function tabName() {
return config.sheets.onSiteTab;
}
export function isStale() {
return dirty;
}
export function startWorker() {
if (!isEnabled()) {
console.log('[sheets] mirroring disabled');
return;
}
console.log(
`[sheets] mirroring who is on site to ${config.sheets.spreadsheetId} ("${config.sheets.onSiteTab}")`
);
// Rows left over from the older append-only log are no longer sent anywhere.
const stale = db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n;
if (stale) {
db.prepare('DELETE FROM sheet_queue').run();
console.log(
`[sheets] discarded ${stale} queued history row(s): the sheet now holds only who is on site. ` +
'The full history is still in the visit log.'
);
}
// Retry anything that failed, and keep the "on site for" column honest.
setInterval(() => {
if (dirty) {
syncOnSite().catch((err) => console.error('[sheets] retry failed:', err.message));
}
}, config.sheets.retryIntervalMs).unref();
setInterval(() => {
syncOnSite().catch(() => {
/* the retry above will pick it up */
});
}, 15 * 60 * 1000).unref();
syncOnSite().catch((err) => console.error('[sheets] initial sync failed:', err.message));
}
+227
View File
@@ -0,0 +1,227 @@
import db from './db.js';
import { clean, localStamp } from './util.js';
import { themeFor } from './branding.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),
branding: {
hasBanner: Boolean(site.banner_path),
bannerHeight: site.banner_height || 64,
bannerAlign: site.banner_align || 'left',
brand: site.colour_brand,
signout: site.colour_signout,
page: site.colour_page,
text: site.colour_text,
theme: themeFor(site),
},
printer: {
enabled: Boolean(site.printer_enabled),
host: site.printer_host,
port: site.printer_port || 9100,
model: site.printer_model || 'QL-820NWB',
rotate: site.printer_rotate || 0,
},
badge: {
enabled: Boolean(site.badge_enabled),
widthMm: site.badge_width_mm,
heightMm: site.badge_height_mm,
showPhoto: Boolean(site.badge_show_photo),
accent: Boolean(site.badge_accent),
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) || 62;
const height = Number(site.badge_height_mm) || 100;
const showPhoto = Boolean(site.badge_show_photo) && Boolean(photoUrl);
// A label noticeably taller than it is wide gets a stacked layout. That is the
// normal case on a 62mm roll printer like the Brother QL-820NWB, where the roll
// fixes the width and the length runs down the badge.
const portrait = height >= width * 1.2;
// Type scales with the dimension that constrains it: the width on a portrait
// badge, the shorter side on a wide one. Keeps small stock legible.
const unit = portrait ? width : Math.min(width, height);
const pad = unit * 0.07;
const nameSize = Math.max(3.2, unit * (portrait ? 0.105 : 0.115));
const bodySize = Math.max(2.0, unit * (portrait ? 0.055 : 0.062));
// Square, matching the crop taken at the kiosk.
const photoWidth = portrait ? unit * 0.52 : unit * 0.5;
// Red only appears on a two-colour roll (DK-22251 on the QL-820NWB). Anywhere
// else it prints as grey, so it is off unless the site opts in.
const accent = site.badge_accent ? '#d00019' : '#000';
const timeIn = new Date(visit.signed_in_at);
const noCheck = visit.check_type === 'NONE';
const photo = showPhoto ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : '';
const details = `
<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>${
noCheck
? '<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>`;
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: ${pad}mm;
display: flex;
flex-direction: ${portrait ? 'column' : 'row'};
align-items: center;
${portrait ? 'justify-content: center;' : ''}
text-align: ${portrait ? 'center' : 'left'};
gap: ${unit * 0.05}mm;
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
color: #000;
overflow: hidden;
}
.photo {
width: ${photoWidth}mm;
height: ${photoWidth}mm;
flex: 0 0 auto;
align-self: center;
object-fit: cover;
border: 0.3mm solid #000;
}
.body {
flex: ${portrait ? '0 1 auto' : '1 1 auto'};
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
${portrait ? 'align-items: center;' : ''}
}
.site {
width: 100%;
font-size: ${bodySize * 0.8}mm;
letter-spacing: 0.03em;
text-transform: uppercase;
color: ${accent};
border-bottom: 0.35mm solid ${accent};
padding-bottom: ${unit * 0.02}mm;
margin-bottom: ${unit * 0.035}mm;
}
.name {
font-size: ${nameSize}mm;
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.rows {
/* Portrait badges centre the whole block; wider ones push the detail rows to
the bottom edge, which is where the eye expects them beside a photo. */
margin-top: ${portrait ? `${unit * 0.05}mm` : 'auto'};
padding-top: ${unit * 0.04}mm;
font-size: ${bodySize}mm;
/* 1.3 rather than 1.35: on a 62 x 90 mm label a two-line name plus a custom
footer line leaves very little room, and the difference is not visible. */
line-height: 1.3;
}
.rows b { font-weight: 700; }
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.025}mm; }
.flag {
display: inline-block;
padding: 0 ${unit * 0.03}mm;
border: 0.35mm solid ${accent};
color: ${accent};
font-weight: 700;
font-size: ${bodySize * 0.9}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">
${photo}
<div class="body">
<div class="site">${esc(site.name)} &middot; Visitor</div>
<div class="name">${esc(visit.first_name)} ${esc(visit.last_name)}</div>
${details}
</div>
</div>
${autoPrint ? '<script>window.addEventListener("load", () => window.print());</script>' : ''}
</body>
</html>`;
}
export { esc as escapeHtml, localStamp };
+245
View File
@@ -0,0 +1,245 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import config from './config.js';
/**
* Certificates for an internal-only kiosk.
*
* Two certificates, not one. A long lived CA that you install on each kiosk tablet
* once, and a short lived server certificate signed by it. Renewing the server
* certificate then never means touching the tablets again — which matters, because
* Apple and Chrome reject server certificates valid for much more than a year, so a
* single self-signed certificate would have to be reinstalled everywhere annually.
*/
const CA_DAYS = 3650;
const SERVER_DAYS = 398;
const RENEW_WITHIN_DAYS = 30;
function certDir() {
return path.dirname(config.https.certPath);
}
function paths() {
const dir = certDir();
return {
dir,
caKey: path.join(dir, 'ca.key'),
caCert: path.join(dir, 'ca.crt'),
key: config.https.keyPath,
cert: config.https.certPath,
// Records which configured names the current certificate was issued for.
names: path.join(dir, '.hostnames.json'),
};
}
function openssl(args, options = {}) {
return execFileSync('openssl', args, { stdio: ['ignore', 'pipe', 'pipe'], ...options });
}
export function opensslAvailable() {
try {
openssl(['version']);
return true;
} catch {
return false;
}
}
/** Every name and address a browser might use to reach this kiosk. */
export function subjectAltNames() {
const dns = new Set(['localhost']);
const ips = new Set(['127.0.0.1']);
for (const entry of config.https.hostnames) {
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(entry)) ips.add(entry);
else dns.add(entry.toLowerCase());
}
// The container's own addresses, so hitting it directly still validates.
for (const list of Object.values(os.networkInterfaces())) {
for (const iface of list || []) {
if (iface.family === 'IPv4' && !iface.internal) ips.add(iface.address);
}
}
return [
...[...dns].map((d) => `DNS:${d}`),
...[...ips].map((i) => `IP:${i}`),
];
}
function readCert(file) {
try {
return new crypto.X509Certificate(fs.readFileSync(file));
} catch {
return null;
}
}
function daysUntil(date) {
return Math.floor((new Date(date).getTime() - Date.now()) / 86400000);
}
/** The SANs actually baked into a certificate, normalised for comparison. */
function certSans(cert) {
if (!cert?.subjectAltName) return [];
return cert.subjectAltName
.split(',')
.map((s) => s.trim().replace(/^IP Address:/, 'IP:'))
.sort();
}
function createCa(p) {
fs.mkdirSync(p.dir, { recursive: true });
openssl([
'req', '-x509', '-nodes', '-newkey', 'rsa:2048',
'-days', String(CA_DAYS),
'-keyout', p.caKey,
'-out', p.caCert,
'-subj', `/C=AU/O=${config.siteName}/CN=${config.siteName} Local CA`,
'-addext', 'basicConstraints=critical,CA:TRUE,pathlen:0',
'-addext', 'keyUsage=critical,keyCertSign,cRLSign',
]);
fs.chmodSync(p.caKey, 0o600);
console.log(`[tls] created a local certificate authority at ${p.caCert}`);
}
function createServerCert(p, sans) {
const primary = config.https.hostnames[0] || os.hostname() || 'visitors.local';
const csr = path.join(p.dir, 'server.csr');
const ext = path.join(p.dir, 'server.ext');
fs.writeFileSync(
ext,
[
`subjectAltName=${sans.join(',')}`,
'basicConstraints=CA:FALSE',
'keyUsage=critical,digitalSignature,keyEncipherment',
'extendedKeyUsage=serverAuth',
].join('\n')
);
openssl([
'req', '-nodes', '-newkey', 'rsa:2048',
'-keyout', p.key,
'-out', csr,
'-subj', `/C=AU/O=${config.siteName}/CN=${primary}`,
]);
openssl([
'x509', '-req',
'-in', csr,
'-CA', p.caCert,
'-CAkey', p.caKey,
'-CAcreateserial',
'-out', p.cert,
'-days', String(SERVER_DAYS),
'-sha256',
'-extfile', ext,
]);
fs.chmodSync(p.key, 0o600);
fs.rmSync(csr, { force: true });
fs.rmSync(ext, { force: true });
console.log(`[tls] issued a server certificate for ${sans.join(', ')}`);
}
/**
* Makes sure a usable certificate is on disk, creating or renewing as needed.
* Returns the material for https.createServer plus a summary for the admin console.
*/
export function ensureCertificates({ force = false } = {}) {
const p = paths();
if (!opensslAvailable()) {
throw new Error(
'openssl is not available, so a certificate cannot be generated. Supply your own ' +
'certificate at HTTPS_CERT and HTTPS_KEY, or terminate TLS at a reverse proxy.'
);
}
fs.mkdirSync(p.dir, { recursive: true });
if (force || !fs.existsSync(p.caCert) || !fs.existsSync(p.caKey)) {
createCa(p);
}
const wanted = subjectAltNames().sort();
const existing = readCert(p.cert);
// Compare against the configured names only. The container's own IP is in the
// certificate too, and Docker hands out a different one on most restarts, so
// comparing the full SAN list would reissue the certificate on every boot.
const configuredNow = [...config.https.hostnames].sort().join(',');
let configuredBefore = null;
try {
configuredBefore = JSON.parse(fs.readFileSync(p.names, 'utf8')).sort().join(',');
} catch {
configuredBefore = null;
}
let reason = null;
if (force) reason = 'asked to regenerate';
else if (!existing || !fs.existsSync(p.key)) reason = 'no certificate on disk';
else if (daysUntil(existing.validTo) < RENEW_WITHIN_DAYS) reason = 'certificate is close to expiry';
else if (configuredBefore !== configuredNow) reason = 'HTTPS_HOSTNAMES changed';
if (reason) {
console.log(`[tls] renewing the server certificate: ${reason}`);
createServerCert(p, wanted);
fs.writeFileSync(p.names, JSON.stringify(config.https.hostnames));
}
return {
key: fs.readFileSync(p.key),
cert: fs.readFileSync(p.cert),
caPath: p.caCert,
info: describe(),
};
}
export function describe() {
const p = paths();
const server = readCert(p.cert);
const ca = readCert(p.caCert);
return {
enabled: config.https.enabled,
server: server && {
validFrom: server.validFrom,
validTo: server.validTo,
daysLeft: daysUntil(server.validTo),
names: certSans(server),
fingerprint: server.fingerprint256,
},
ca: ca && {
validTo: ca.validTo,
daysLeft: daysUntil(ca.validTo),
fingerprint: ca.fingerprint256,
subject: ca.subject,
},
caPath: fs.existsSync(p.caCert) ? p.caCert : null,
};
}
export function caCertificate() {
const p = paths();
return fs.existsSync(p.caCert) ? fs.readFileSync(p.caCert) : null;
}
/** Renewal is cheap, so check daily rather than only at boot. */
export function scheduleRenewal(onRenewed) {
setInterval(() => {
try {
const before = describe().server?.validTo;
ensureCertificates();
const after = describe().server?.validTo;
if (before !== after) onRenewed?.();
} catch (err) {
console.error('[tls] renewal check failed:', err.message);
}
}, 24 * 60 * 60 * 1000).unref();
}
+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');
}