Public Access
90 lines
3.3 KiB
JavaScript
90 lines
3.3 KiB
JavaScript
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;
|
|
}
|