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