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

This commit is contained in:
jessikitty committed 2026-09-04 14:55:59 +10:00
commit 3278890491
41 files changed
+8920

No files matched your search

+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;
}