From 9da7e88eb3ba1b2093d6f95d5d25abd711e4b9ca Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 1 Sep 2026 15:14:46 +1000 Subject: [PATCH] Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA --- README.md | 78 ++++++++++++---- public/css/admin.css | 45 ++++++++++ public/index.html | 5 +- public/js/admin.js | 205 ++++++++++++++++++++++++++++++++++++++++--- public/js/kiosk.js | 24 +++-- src/db.js | 97 +++++++++++++++++++- src/photos.js | 23 +++++ src/pins.js | 32 +++++-- src/routes/admin.js | 101 +++++++++++++++++---- src/routes/kiosk.js | 11 ++- src/sites.js | 107 +++++++++++++++------- 11 files changed, 629 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 562e44a..3c0fb89 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ Runs in one Docker container, stores everything locally in SQLite, and mirrors e and sign out to a Google Sheet so someone outside the building can see who is on site during an evacuation. -- **Kiosk** at `/` — a stepped sign in, a PIN sign in for regulars, and sign out. +- **Kiosk** at `/` — a stepped sign in, a PIN sign in for regulars, and sign out. No link to + the admin console: a kiosk is a public terminal and administration does not belong on it. + Reach the console from a staff machine at `/admin`. - **Admin** at `/admin` — who's on site now, the visit log, recurring visitors, the people a visitor can ask for, sites, admin accounts, and system status. @@ -19,7 +21,7 @@ two factor. |---|---|---| | First and last name | typed each visit | on file | | Person being visited | picked from the list | picked each visit | -| Photo | taken at the kiosk | taken at the kiosk | +| Photo | taken at the kiosk | on file if saved, otherwise taken at the kiosk | | WWCC / VIT / none | typed each visit | on file | | Mobile and/or email | at least one required | on file | @@ -103,25 +105,47 @@ staff list and log, and cannot touch the others. Turn it on per site under **Sites → Edit**. After a visitor signs in, the kiosk loads the badge into a hidden frame and prints it — one label, no dialog on most kiosk setups. The badge shows -the site, name, who they are visiting, time in, WWCC/VIT status (or a boxed **No WWCC / VIT**), -the photo if you want it, and an optional line of your own text. +the site, the visitor's name, who they are visiting, the time in, their WWCC/VIT number or a +boxed **No WWCC / VIT**, the photo if you want it, and an optional line of your own text. -Set the label size in millimetres to match your stock. Type scales with the smaller dimension, -so small labels stay legible. Common sizes: - -| Stock | mm | -|---|---| -| Card size | 86 × 54 | -| Brother DK-11202 shipping | 100 × 62 | -| Brother DK-11209 small address | 62 × 29 | -| Dymo 99014 shipping | 101 × 54 | - -Use **Preview badge** to check the layout in a browser before committing a roll to it. The kiosk -browser needs the label printer set as its default, with margins off and scaling at 100%. +Pick your stock from the **Label stock** list and the dimensions fill themselves in. A label +noticeably taller than it is wide gets a stacked layout — photo on top, name beneath — which is +what you want on a roll printer. Wider stock gets the photo alongside the text instead. Type +scales with the constraining dimension, so small labels stay readable. Admins can reprint from the **On site** list, and the visitor gets a "Print the badge again" button on the confirmation screen if the first one jams. +### Brother QL-820NWB + +The default for a new site is 62 × 100 mm, which matches the DK-11202 die-cut label. For a +continuous roll, 62 × 90 mm is a good visitor badge. + +It takes media 12 to 62 mm wide and prints up to 60.96 mm across at 300 × 300 dpi, so 62 mm is +the widest roll it will accept — the console warns you if you enter anything wider. Useful rolls: + +| Roll | Size | Good for | +|---|---|---| +| DK-22205 | 62 mm continuous | The default. Cut to any length; 90 mm suits a visitor badge | +| DK-11202 | 62 × 100 mm die-cut | Pre-cut, no length to choose | +| DK-22251 | 62 mm continuous, black/red | Same as DK-22205 but supports the red option below | +| DK-11208 | 38 × 90 mm die-cut | Narrower; turn the photo off | +| DK-11209 | 29 × 62 mm die-cut | Name and host only | + +**The red option.** Tick *Print the heading and the no-check warning in red* and the site name +and the **No WWCC / VIT** box print red instead of black, which makes a visitor without a check +obvious across a room. It only works on a DK-22251 roll — on any other roll the printer renders +it as grey. Two-colour printing is also far slower than black alone (Brother rate it at roughly +15 labels a minute against 110), which is irrelevant for one badge at a time but worth knowing. + +**Driver setup on the kiosk.** Set the QL-820NWB as the default printer for the browser running +the kiosk, choose the matching paper size in the driver, and set margins to none and scaling to +100%. Then use **Preview badge** in the console and print one before committing a roll — the +preview renders at the exact millimetre size the printer will receive. + +The QL-820NWB has Ethernet and Wi-Fi, so it does not need to hang off the kiosk tablet. Install +it as a network printer on whichever device drives the kiosk browser. + ## WWCC and VIT expiry warnings Give a recurring visitor an expiry date and the console watches it. Inside @@ -275,12 +299,34 @@ spreadsheet covers every site. ## Recurring visitors and PINs +Each saved person is one record: **mobile number, email address and PIN are all unique**, checked +when a record is added or edited and enforced by the database. If an existing database already +contains duplicates, the startup log names who collides and the checks stay at the application +level until you fix them. + +### A photo on file + +Give a recurring visitor a photo in the admin console — from the machine's camera or an uploaded +file — and the kiosk stops asking them to pose. They enter their PIN, pick who they are visiting, +and the sign in completes with their pass printing immediately. + +The stored photo is *copied* onto each visit rather than referenced, so the visit log stays a +true snapshot: replacing someone's photo later does not change what past visits show, and photo +retention cleaning up old visits can never delete a live profile photo. + +Leave the photo blank and they are asked at the kiosk as before. + +### PINs + The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with `APP_SECRET` rather than hashed, so an admin can reprint a lost card without resetting it. Four digits is only 10,000 combinations, so hashing would add nothing against anyone holding a copy of the database — the real protection is the lockout: five wrong PINs on a number locks it for 15 minutes. +Four digits gives 10,000 combinations and each must be unique, so that is the ceiling on +simultaneous recurring visitors. Deactivating someone frees theirs. + **Changing `APP_SECRET` makes every stored PIN unreadable.** If you have to change it, reissue PINs from the admin console afterwards. diff --git a/public/css/admin.css b/public/css/admin.css index e953bc1..814bf3f 100644 --- a/public/css/admin.css +++ b/public/css/admin.css @@ -294,3 +294,48 @@ tr.row-bad td { background: #fdf0f2; } word-break: break-all; } #system-body h3 { margin-bottom: 14px; } + +/* ------------------------------------------------- visitor photo editor */ + +.photo-editor { margin-bottom: 6px; } + +.photo-frame { + position: relative; + width: 148px; + aspect-ratio: 4 / 3; + margin-bottom: 12px; + background: #eef1f4; + border: 1px solid var(--rule); + border-radius: 3px; + overflow: hidden; + display: grid; + place-items: center; +} +.photo-frame img, +.photo-frame video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transform: scaleX(-1); +} +.photo-empty { margin: 0; padding: 0 10px; color: var(--muted); font-size: 13px; text-align: center; } + +.photo-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } +.photo-actions button, .photo-upload { padding: 8px 13px; font-size: 14px; } +.photo-upload { cursor: pointer; } + +.thumb-empty { + width: 42px; + height: 42px; + border-radius: 3px; + background: repeating-linear-gradient(45deg, #eef1f4, #eef1f4 5px, #e3e8ed 5px, #e3e8ed 10px); + display: block; +} + +.hint.warn { + padding: 9px 11px; + border-left: 4px solid #d9a441; + background: #fdf3d8; + color: var(--ink); +} diff --git a/public/index.html b/public/index.html index df58506..5c22e9d 100644 --- a/public/index.html +++ b/public/index.html @@ -224,10 +224,7 @@ diff --git a/public/js/admin.js b/public/js/admin.js index f14ffd4..bcb9b78 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -254,7 +254,7 @@ async function loadRecurring() { : ''; $('#recurring-table').innerHTML = table( - ['Visitor', 'Mobile', 'Site', 'Check', 'Expiry', 'Status', ''], + ['', 'Visitor', 'Mobile', 'Site', 'Check', 'Expiry', 'Status', ''], rows.map( (p) => ` ${esc(p.firstName)} ${esc(p.lastName)} @@ -316,7 +316,7 @@ function siteOptions(selectedId, { anyLabel = 'Any site' } = {}) { ); } -function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false } = {}) { +function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false, onOpen, onClose } = {}) { $('#modal-title').textContent = title; $('#modal-body').innerHTML = bodyHtml; $('#modal-save').textContent = saveLabel; @@ -324,13 +324,124 @@ function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = f const modal = $('#modal'); modal.returnValue = ''; modal.showModal(); + onOpen?.(); modal.onclose = () => { $('#modal-cancel').hidden = false; $('#modal-save').textContent = 'Save'; + onClose?.(); if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form'))); }; } +/* --------------------------------------------------- visitor photo editor */ +// A recurring visitor can have a photo kept on file, so the kiosk never asks them +// to pose again. It can come from this machine's camera or from a file. + +const photoEditor = { dataUrl: null, remove: false, stream: null }; + +function photoEditorMarkup(person) { + const existing = person?.hasPhoto ? `/admin/api/frequent/${person.id}/photo?t=${Date.now()}` : null; + return ` +
+
+ Photo on file + +

No photo on file

+
+
+ + + + +
+

With a photo saved here, this visitor signs in with their PIN and their + pass prints straight away — the kiosk does not ask them to pose.

+
`; +} + +function wirePhotoEditor() { + photoEditor.dataUrl = null; + photoEditor.remove = false; + + const preview = $('#photo-preview'); + const video = $('#photo-video'); + const empty = $('#photo-empty'); + + const showImage = (src) => { + preview.src = src; + preview.hidden = false; + video.hidden = true; + empty.hidden = true; + $('#photo-shoot').hidden = true; + $('#photo-camera').textContent = 'Retake'; + $('#photo-clear').hidden = false; + }; + + $('#photo-camera').addEventListener('click', async () => { + try { + photoEditor.stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'user', width: { ideal: 960 } }, + audio: false, + }); + video.srcObject = photoEditor.stream; + video.hidden = false; + preview.hidden = true; + empty.hidden = true; + $('#photo-shoot').hidden = false; + } catch { + toast('No camera available on this machine. Upload a file instead.', true); + } + }); + + $('#photo-shoot').addEventListener('click', () => { + const canvas = document.createElement('canvas'); + const width = 720; + canvas.width = width; + canvas.height = Math.round((video.videoHeight / video.videoWidth) * width) || 540; + canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height); + photoEditor.dataUrl = canvas.toDataURL('image/jpeg', 0.72); + photoEditor.remove = false; + stopPhotoCamera(); + showImage(photoEditor.dataUrl); + }); + + $('#photo-file').addEventListener('change', async (event) => { + const file = event.target.files[0]; + if (!file) return; + if (file.size > 4 * 1024 * 1024) return toast('That image is over 4 MB. Use a smaller one.', true); + const reader = new FileReader(); + reader.onload = () => { + photoEditor.dataUrl = reader.result; + photoEditor.remove = false; + stopPhotoCamera(); + showImage(reader.result); + }; + reader.readAsDataURL(file); + }); + + $('#photo-clear').addEventListener('click', () => { + photoEditor.dataUrl = null; + photoEditor.remove = true; + stopPhotoCamera(); + preview.hidden = true; + video.hidden = true; + empty.hidden = false; + $('#photo-shoot').hidden = true; + $('#photo-clear').hidden = true; + $('#photo-camera').textContent = 'Use the camera'; + }); +} + +function stopPhotoCamera() { + if (!photoEditor.stream) return; + photoEditor.stream.getTracks().forEach((t) => t.stop()); + photoEditor.stream = null; + const video = $('#photo-video'); + if (video) video.srcObject = null; +} + function openRecurringModal(person = null) { const editing = Boolean(person); openModal( @@ -354,12 +465,16 @@ function openRecurringModal(person = null) { ${field('PIN (leave blank to generate one)', 'pin', '')} ${field('Notes', 'notes', person?.notes)} + + ${photoEditorMarkup(person)} ${editing ? `` : ''} `, async (form) => { const payload = Object.fromEntries(form.entries()); payload.active = editing ? form.has('active') : true; if (!payload.pin) delete payload.pin; + if (photoEditor.dataUrl) payload.photo = photoEditor.dataUrl; + if (photoEditor.remove) payload.removePhoto = true; try { const saved = editing ? await api(`/frequent/${person.id}`, { method: 'PATCH', body: payload }) @@ -370,7 +485,8 @@ function openRecurringModal(person = null) { } catch (err) { toast(err.message, true); } - } + }, + { onOpen: wirePhotoEditor, onClose: stopPhotoCamera } ); } @@ -501,7 +617,7 @@ async function loadSites() {
Badge printing
${ s.badge.enabled - ? `On — ${s.badge.widthMm} × ${s.badge.heightMm} mm${s.badge.showPhoto ? ', with photo' : ''}` + ? `On — ${s.badge.widthMm} × ${s.badge.heightMm} mm${s.badge.showPhoto ? ', with photo' : ''}${s.badge.accent ? ', two-colour' : ''}` : 'Off' }
${s.badge.note ? `
Badge note
${esc(s.badge.note)}
` : ''} @@ -522,6 +638,22 @@ async function loadSites() { ); } +/** + * Label stock, so nobody has to measure a roll. The Brother QL-820NWB takes + * 12–62 mm wide media and prints up to 60.96 mm across, so anything wider than + * 62 mm is for a different printer. + */ +const LABEL_PRESETS = [ + { id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true }, + { id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true }, + { id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, accent: true }, + { id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false }, + { id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false }, + { id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false }, + { id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true }, + { id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true }, +]; + function openSiteModal(site) { openModal( `Edit ${site.name}`, @@ -530,14 +662,26 @@ function openSiteModal(site) { + - - ${field('Line printed at the bottom', 'note', site.badge.note)} -

Common label sizes: 86 × 54 mm (card), 100 × 62 mm and 62 × 29 mm (Brother), - 101 × 54 mm (Dymo). Preview before you commit a roll to it.

`, + + + +

Red needs a two-colour roll such as the Brother DK-22251. On any other + roll it prints as grey. Two-colour printing is also much slower than black alone.

+ ${field('Line printed at the bottom', 'note', site.badge.note)}`, async (form) => { const data = Object.fromEntries(form.entries()); try { @@ -552,19 +696,60 @@ function openSiteModal(site) { widthMm: Number(data.widthMm), heightMm: Number(data.heightMm), showPhoto: form.has('showPhoto'), + accent: form.has('accent'), note: data.note, }, }, }); - toast('Site saved.'); + toast('Site saved. Preview the badge before committing a roll to it.'); loadSites(); } catch (err) { toast(err.message, true); } - } + }, + { onOpen: wireBadgePreset } ); } +function wireBadgePreset() { + const width = $('#modal-form [name="widthMm"]'); + const height = $('#modal-form [name="heightMm"]'); + const warning = $('#badge-warning'); + + const check = () => { + const w = Number(width.value); + const h = Number(height.value); + if (w > 62) { + warning.hidden = false; + warning.className = 'hint warn'; + warning.textContent = `${w} mm is wider than a QL-820NWB can take — it handles 12 to 62 mm media, printing up to 60.96 mm across. Fine for a different printer.`; + } else if (h < w * 1.2 && $('#badge-photo').checked && w <= 40) { + warning.hidden = false; + warning.className = 'hint warn'; + warning.textContent = 'A photo on a label this narrow leaves very little room for the name. Consider turning the photo off.'; + } else { + warning.hidden = true; + } + }; + + $('#badge-preset').addEventListener('change', (event) => { + const preset = LABEL_PRESETS.find((p) => p.id === event.target.value); + if (!preset) return; + width.value = preset.w; + height.value = preset.h; + $('#badge-photo').checked = preset.photo; + $('#badge-accent').checked = Boolean(preset.accent); + check(); + }); + + [width, height].forEach((el) => el.addEventListener('input', () => { + $('#badge-preset').value = ''; + check(); + })); + $('#badge-photo').addEventListener('change', check); + check(); +} + $('#new-site').addEventListener('click', () => { openModal('Add a site', field('Site name', 'name', ''), async (form) => { try { diff --git a/public/js/kiosk.js b/public/js/kiosk.js index f9da10c..258b571 100644 --- a/public/js/kiosk.js +++ b/public/js/kiosk.js @@ -18,6 +18,7 @@ const state = { checkNumber: '', photo: null, frequentVisitorId: null, + hasStoredPhoto: false, }; let hosts = []; @@ -143,6 +144,7 @@ function resetState() { checkNumber: '', photo: null, frequentVisitorId: null, + hasStoredPhoto: false, }); history = []; $$('#app input').forEach((i) => { @@ -312,8 +314,13 @@ function buildReview() { /* ------------------------------------------------------------ submits */ async function submitSignIn() { - const button = state.mode === 'frequent' ? $('#cam-use') : $('#do-signin'); - button.disabled = true; + // Called from the review screen, the camera screen, or straight from the host + // list when a recurring visitor already has a photo on file. + const button = + (current === 'photo' && $('#cam-use')) || + (current === 'review' && $('#do-signin')) || + null; + if (button) button.disabled = true; try { const result = await api('/api/signin', { mode: state.mode, @@ -342,7 +349,7 @@ async function submitSignIn() { } catch (err) { say(err.message); } finally { - button.disabled = false; + if (button) button.disabled = false; } } @@ -501,9 +508,14 @@ $('#do-freq-auth').addEventListener('click', async () => { state.frequentVisitorId = person.id; state.firstName = person.firstName; state.lastName = person.lastName; + // With a photo already on file there is nothing to pose for: picking a host + // completes the sign in and the pass prints straight away. + state.hasStoredPhoto = Boolean(person.hasPhoto); $('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`; $('#in-freq-host-search').value = ''; - renderHosts($('#freq-host-list'), '', () => show('photo')); + renderHosts($('#freq-host-list'), '', () => + state.hasStoredPhoto ? submitSignIn() : show('photo') + ); show('freq-host'); } catch (err) { say(err.message); @@ -514,7 +526,9 @@ $('#do-freq-auth').addEventListener('click', async () => { }); $('#in-freq-host-search').addEventListener('input', (e) => - renderHosts($('#freq-host-list'), e.target.value, () => show('photo')) + renderHosts($('#freq-host-list'), e.target.value, () => + state.hasStoredPhoto ? submitSignIn() : show('photo') + ) ); /* sign out */ diff --git a/src/db.js b/src/db.js index c555742..60c2119 100644 --- a/src/db.js +++ b/src/db.js @@ -2,6 +2,7 @@ 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 }); @@ -16,9 +17,10 @@ CREATE TABLE IF NOT EXISTS sites ( 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_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, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) @@ -46,6 +48,8 @@ CREATE TABLE IF NOT EXISTS frequent_visitors ( 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')), @@ -138,8 +142,17 @@ 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'); 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)'); @@ -156,6 +169,86 @@ db.prepare('UPDATE hosts SET site_id = ? WHERE site_id IS NULL').run(firstSite.i 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; diff --git a/src/photos.js b/src/photos.js index 32251ad..b9729a7 100644 --- a/src/photos.js +++ b/src/photos.js @@ -31,6 +31,29 @@ export function savePhoto(dataUrl) { 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); diff --git a/src/pins.js b/src/pins.js index b76afcc..4239e23 100644 --- a/src/pins.js +++ b/src/pins.js @@ -43,12 +43,28 @@ export function verifyPin(stored, candidate) { 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; +// 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.' + ); } diff --git a/src/routes/admin.js b/src/routes/admin.js index 43085c9..00965b8 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -4,8 +4,8 @@ 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 { decryptPin, encryptPin, generatePin, pinLookup } from '../pins.js'; +import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js'; import * as sheets from '../sheets.js'; import * as tls from '../tls.js'; import * as users from '../users.js'; @@ -378,7 +378,7 @@ router.patch('/sites/:id', (req, res) => { 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 = ?` + badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, 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, @@ -387,6 +387,7 @@ router.patch('/sites/:id', (req, res) => { 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.accent !== undefined ? (badge.accent ? 1 : 0) : site.badge_accent, badge.note !== undefined ? clean(badge.note, 120) || null : site.badge_note , site.id); @@ -549,6 +550,7 @@ function shapeFrequent(row, includePin = false) { checkExpiry: row.check_expiry, defaultHostId: row.default_host_id, siteId: row.site_id, + hasPhoto: Boolean(row.photo_path), notes: row.notes, active: Boolean(row.active), createdAt: row.created_at, @@ -607,7 +609,25 @@ router.get('/alerts', (req, res) => { }); }); -function validateFrequent(body, { existingPhone = null } = {}) { +/** True when another recurring visitor already holds this PIN. */ +function pinTaken(pin, excludeId = null) { + const row = db + .prepare('SELECT id FROM frequent_visitors WHERE pin_lookup = ?') + .get(pinLookup(pin)); + return Boolean(row) && row.id !== excludeId; +} + +function allocatePin(requested, excludeId = null) { + if (requested && /^\d{4}$/.test(String(requested))) { + if (pinTaken(String(requested), excludeId)) { + throw new Error('Another recurring visitor already uses that PIN. Choose a different one.'); + } + return String(requested); + } + return generatePin((candidate) => pinTaken(candidate, excludeId)); +} + +function validateFrequent(body, { existingPhone = null, existingId = null } = {}) { const firstName = titleCase(body?.firstName, 60); const lastName = titleCase(body?.lastName, 60); const phone = normalisePhone(body?.phone); @@ -621,9 +641,24 @@ function validateFrequent(body, { existingPhone = null } = {}) { 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.'); + // One record per person: mobile number, email and PIN must each be unique. + const phoneClash = db + .prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE phone = ?') + .get(phone); + if (phoneClash && phoneClash.id !== existingId) { + throw new Error( + `${phoneClash.first_name} ${phoneClash.last_name} already uses that mobile number.` + ); + } + if (email) { + const emailClash = db + .prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE lower(email) = ?') + .get(email); + if (emailClash && emailClash.id !== existingId) { + throw new Error( + `${emailClash.first_name} ${emailClash.last_name} already uses that email address.` + ); + } } return { firstName, @@ -644,17 +679,19 @@ router.post('/frequent', (req, res) => { 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 pin = allocatePin(req.body?.pin); + const photoPath = req.body?.photo ? savePhoto(req.body.photo) : null; + 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)` + default_host_id, site_id, pin_enc, pin_lookup, photo_path, 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 + v.defaultHostId, siteId, encryptPin(pin), pinLookup(pin), photoPath, v.notes ); res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid), true)); } catch (err) { @@ -666,16 +703,31 @@ 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 v = validateFrequent( + { ...shapeFrequent(row), ...req.body }, + { existingPhone: row.phone, existingId: row.id } + ); const scope = scopedSiteId(req); + + // A new photo replaces the old file; removePhoto clears it entirely. + let photoPath = row.photo_path; + if (req.body?.photo) { + photoPath = savePhoto(req.body.photo); + if (row.photo_path) deletePhoto(row.photo_path); + } else if (req.body?.removePhoto) { + if (row.photo_path) deletePhoto(row.photo_path); + photoPath = null; + } + 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 = ?` + photo_path = ?, 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), + photoPath, v.notes, req.body?.active !== undefined ? (req.body.active ? 1 : 0) : row.active, row.id @@ -686,14 +738,27 @@ router.patch('/frequent/:id', (req, res) => { } }); +/** The photo kept on file for a recurring visitor. */ +router.get('/frequent/:id/photo', (req, res) => { + const row = db.prepare('SELECT photo_path FROM frequent_visitors WHERE id = ?').get(req.params.id); + const abs = row && photoAbsolutePath(row.photo_path); + if (!abs) return res.status(404).send('No photo on file.'); + res.setHeader('Cache-Control', 'private, max-age=60'); + res.sendFile(abs); +}); + 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 - ); + let pin; + try { + pin = allocatePin(req.body?.pin, row.id); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + db.prepare( + "UPDATE frequent_visitors SET pin_enc = ?, pin_lookup = ?, updated_at = datetime('now') WHERE id = ?" + ).run(encryptPin(pin), pinLookup(pin), row.id); db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone); res.json({ ok: true, pin }); }); diff --git a/src/routes/kiosk.js b/src/routes/kiosk.js index 020bb2d..330885c 100644 --- a/src/routes/kiosk.js +++ b/src/routes/kiosk.js @@ -2,7 +2,7 @@ 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 { savePhoto, photoAbsolutePath, copyStoredPhoto } from '../photos.js'; import { mirror } from '../sheets.js'; import { verifyPin } from '../pins.js'; import { listSites, resolveSite, badgeHtml } from '../sites.js'; @@ -150,10 +150,15 @@ router.post('/signin', signInLimiter, (req, res) => { }); } + // 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 (config.requirePhoto) { + } 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.' }); } @@ -336,6 +341,8 @@ router.post('/frequent/auth', pinLimiter, (req, res) => { checkType: person.check_type, checkNumber: person.check_number, defaultHostId: person.default_host_id, + // Tells the kiosk it can skip the camera step entirely. + hasPhoto: Boolean(person.photo_path), openVisit: open || null, }); }); diff --git a/src/sites.js b/src/sites.js index 6fcacb9..b7872a6 100644 --- a/src/sites.js +++ b/src/sites.js @@ -53,6 +53,7 @@ export function shapeSite(site) { 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, }, }; @@ -70,14 +71,43 @@ const esc = (value) => * 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 width = Number(site.badge_width_mm) || 62; + const height = Number(site.badge_height_mm) || 100; 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); + + // 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)); + const photoWidth = portrait ? unit * 0.52 : unit * 0.42; + + // 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 ? `` : ''; + const details = ` +
+
Visiting ${esc(visit.host_name)}
+
In at ${esc( + timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false }) + )} on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}
+
${ + noCheck + ? 'No WWCC / VIT' + : `${esc(visit.check_type)} ${esc(visit.check_number || '')}` + }
+ ${site.badge_note ? `
${esc(site.badge_note)}
` : ''} +
`; return ` @@ -91,27 +121,40 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = { .badge { width: ${width}mm; height: ${height}mm; - padding: ${unit * 0.075}mm ${unit * 0.09}mm; + padding: ${pad}mm; display: flex; - gap: ${unit * 0.07}mm; - align-items: stretch; + flex-direction: ${portrait ? 'column' : 'row'}; + align-items: ${portrait ? 'center' : 'stretch'}; + text-align: ${portrait ? 'center' : 'left'}; + gap: ${unit * 0.05}mm; font-family: "Segoe UI", Arial, Helvetica, sans-serif; color: #000; overflow: hidden; } .photo { - width: ${unit * 0.42}mm; + width: ${photoWidth}mm; + ${portrait ? `height: ${photoWidth * 0.78}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; } + .body { + flex: 1 1 auto; + min-width: 0; + width: 100%; + display: flex; + flex-direction: column; + ${portrait ? 'align-items: center;' : ''} + } .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; + 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; @@ -120,14 +163,21 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = { letter-spacing: -0.01em; overflow-wrap: anywhere; } - .rows { margin-top: auto; font-size: ${bodySize}mm; line-height: 1.35; } + .rows { + margin-top: auto; + padding-top: ${unit * 0.04}mm; + 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; } + .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.3mm solid #000; - font-size: ${bodySize * 0.85}mm; + border: 0.35mm solid ${accent}; + color: ${accent}; + font-weight: 700; + font-size: ${bodySize * 0.9}mm; } @media screen { body { background: #e7ecf0; padding: 12mm; } @@ -137,22 +187,11 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
- ${showPhoto ? `` : ''} + ${photo}
-
${esc(site.name)} · VISITOR
+
${esc(site.name)} · Visitor
${esc(visit.first_name)} ${esc(visit.last_name)}
-
-
Visiting ${esc(visit.host_name)}
-
In at ${esc( - timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false }) - )} on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}
-
${ - visit.check_type === 'NONE' - ? 'No WWCC / VIT' - : `${esc(visit.check_type)} ${esc(visit.check_number || '')}` - }
- ${site.badge_note ? `
${esc(site.badge_note)}
` : ''} -
+ ${details}
${autoPrint ? '' : ''}