Public Access
1297 lines
49 KiB
JavaScript
1297 lines
49 KiB
JavaScript
import express from 'express';
|
|
import rateLimit from 'express-rate-limit';
|
|
import QRCode from 'qrcode';
|
|
import fs from 'node:fs';
|
|
import db from '../db.js';
|
|
import config from '../config.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 printer from '../printer.js';
|
|
import * as users from '../users.js';
|
|
import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js';
|
|
import {
|
|
DEFAULT_THEME,
|
|
bannerAbsolutePath,
|
|
deleteBanner,
|
|
normaliseAlign,
|
|
normaliseColour,
|
|
saveBanner,
|
|
} from '../branding.js';
|
|
import {
|
|
consumeRecoveryCode,
|
|
generateRecoveryCodes,
|
|
generateTotpSecret,
|
|
hashPassword,
|
|
hashRecoveryCodes,
|
|
otpauthUrl,
|
|
passwordProblem,
|
|
verifyPassword,
|
|
verifyTotp,
|
|
} from '../auth.js';
|
|
import {
|
|
clean,
|
|
isEmail,
|
|
isPhone,
|
|
localStamp,
|
|
normaliseEmail,
|
|
normalisePhone,
|
|
nowIso,
|
|
parseCsv,
|
|
titleCase,
|
|
toCsv,
|
|
} from '../util.js';
|
|
|
|
const router = express.Router();
|
|
const CHECK_TYPES = new Set(['WWCC', 'VIT', 'NONE']);
|
|
|
|
const loginLimiter = rateLimit({ windowMs: 15 * 60000, max: 20, standardHeaders: true });
|
|
|
|
/* ------------------------------------------------------------ sessions */
|
|
|
|
function currentUser(req) {
|
|
if (!req.session?.adminUserId) return null;
|
|
const user = users.findById(req.session.adminUserId);
|
|
return user && user.active ? user : null;
|
|
}
|
|
|
|
function requireAdmin(req, res, next) {
|
|
const user = currentUser(req);
|
|
if (!user) return res.status(401).json({ error: 'Sign in to the admin console first.' });
|
|
req.user = user;
|
|
// Someone on a temporary password can only change it or sign out.
|
|
if (user.must_change_password && !req.path.startsWith('/account/password') && req.path !== '/logout') {
|
|
return res.status(403).json({ error: 'Set a new password before continuing.', mustChangePassword: true });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireOwner(req, res, next) {
|
|
if (req.user.role !== 'owner') {
|
|
return res.status(403).json({ error: 'Only an owner account can do that.' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
/** null means "every site". Otherwise the single site this admin is limited to. */
|
|
function scopedSiteId(req) {
|
|
return req.user.site_id || null;
|
|
}
|
|
|
|
function assertSiteAllowed(req, siteId) {
|
|
const scope = scopedSiteId(req);
|
|
if (scope && Number(siteId) !== scope) {
|
|
const error = new Error('That site is outside your access.');
|
|
error.status = 403;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Adds a site filter to a WHERE clause built from `where`/`params`. */
|
|
function applySiteFilter(req, where, params) {
|
|
const scope = scopedSiteId(req);
|
|
const requested = req.query.siteId && req.query.siteId !== 'all' ? Number(req.query.siteId) : null;
|
|
const siteId = scope || requested;
|
|
if (siteId) {
|
|
where.push('site_id = ?');
|
|
params.push(siteId);
|
|
}
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- login */
|
|
|
|
router.post('/login', loginLimiter, (req, res) => {
|
|
const email = normaliseEmail(req.body?.email);
|
|
const password = String(req.body?.password || '');
|
|
const generic = { error: 'That email address and password do not match.' };
|
|
|
|
if (!email || !password) return res.status(400).json({ error: 'Enter your email and password.' });
|
|
|
|
const locked = users.lockState(email);
|
|
if (locked) {
|
|
return res.status(429).json({ error: 'Too many attempts. Try again in 15 minutes.' });
|
|
}
|
|
if (!users.domainAllowed(email)) {
|
|
return res.status(403).json({ error: `Sign in with an ${users.domainRuleText()} address.` });
|
|
}
|
|
|
|
const user = users.findByEmail(email);
|
|
if (!user || !user.active || !verifyPassword(password, user.password_hash)) {
|
|
users.noteFailure(email);
|
|
return res.status(401).json(generic);
|
|
}
|
|
users.clearFailures(email);
|
|
|
|
if (user.totp_enabled) {
|
|
req.session.pendingUserId = user.id;
|
|
// Told up front so the sign in pages can show an honest "step 2 of 3".
|
|
return res.json({
|
|
status: 'twoFactorRequired',
|
|
passwordChangeToFollow: Boolean(user.must_change_password),
|
|
});
|
|
}
|
|
if (config.admin.require2fa) {
|
|
req.session.pendingUserId = user.id;
|
|
return startTwoFactorSetup(req, res, user);
|
|
}
|
|
return completeLogin(req, res, user);
|
|
});
|
|
|
|
function completeLogin(req, res, user) {
|
|
delete req.session.pendingUserId;
|
|
delete req.session.pendingTotpSecret;
|
|
req.session.adminUserId = user.id;
|
|
db.prepare('UPDATE admin_users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id);
|
|
res.json({
|
|
status: user.must_change_password ? 'passwordChangeRequired' : 'ok',
|
|
user: users.shape(user),
|
|
});
|
|
}
|
|
|
|
async function startTwoFactorSetup(req, res, user) {
|
|
const secret = generateTotpSecret();
|
|
req.session.pendingTotpSecret = secret;
|
|
const url = otpauthUrl({ secret, email: user.email, issuer: config.siteName });
|
|
const qr = await QRCode.toDataURL(url, { margin: 1, width: 240 });
|
|
res.json({
|
|
status: 'twoFactorSetup',
|
|
secret,
|
|
qr,
|
|
passwordChangeToFollow: Boolean(user.must_change_password),
|
|
});
|
|
}
|
|
|
|
router.post('/login/2fa', loginLimiter, (req, res) => {
|
|
const user = req.session.pendingUserId ? users.findById(req.session.pendingUserId) : null;
|
|
if (!user) return res.status(401).json({ error: 'Start again from the sign in screen.' });
|
|
|
|
const code = clean(req.body?.code, 20);
|
|
|
|
// Enrolling: the secret is only saved once a real code from the app proves it works.
|
|
if (req.session.pendingTotpSecret) {
|
|
if (!verifyTotp(req.session.pendingTotpSecret, code)) {
|
|
return res.status(401).json({ error: 'That code did not match. Try the next one.' });
|
|
}
|
|
const recovery = generateRecoveryCodes();
|
|
db.prepare(
|
|
'UPDATE admin_users SET totp_secret = ?, totp_enabled = 1, recovery_codes = ? WHERE id = ?'
|
|
).run(req.session.pendingTotpSecret, hashRecoveryCodes(recovery), user.id);
|
|
delete req.session.pendingTotpSecret;
|
|
req.session.adminUserId = user.id;
|
|
delete req.session.pendingUserId;
|
|
db.prepare('UPDATE admin_users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id);
|
|
return res.json({
|
|
status: users.findById(user.id).must_change_password ? 'passwordChangeRequired' : 'ok',
|
|
recoveryCodes: recovery,
|
|
user: users.shape(users.findById(user.id)),
|
|
});
|
|
}
|
|
|
|
if (verifyTotp(user.totp_secret, code)) {
|
|
users.clearFailures(user.email);
|
|
return completeLogin(req, res, user);
|
|
}
|
|
|
|
// Recovery codes are one shot each.
|
|
const remaining = consumeRecoveryCode(user.recovery_codes, code);
|
|
if (remaining !== null) {
|
|
db.prepare('UPDATE admin_users SET recovery_codes = ? WHERE id = ?').run(remaining, user.id);
|
|
const left = JSON.parse(remaining).length;
|
|
req.session.adminUserId = user.id;
|
|
delete req.session.pendingUserId;
|
|
return res.json({
|
|
status: 'ok',
|
|
usedRecoveryCode: true,
|
|
recoveryCodesLeft: left,
|
|
user: users.shape(user),
|
|
});
|
|
}
|
|
|
|
users.noteFailure(user.email);
|
|
res.status(401).json({ error: 'That code is not right.' });
|
|
});
|
|
|
|
router.post('/logout', (req, res) => {
|
|
req.session.destroy(() => res.json({ ok: true }));
|
|
});
|
|
|
|
router.get('/session', (req, res) => {
|
|
const user = currentUser(req);
|
|
const anyUsers = users.countActive() > 0;
|
|
res.json({
|
|
admin: Boolean(user),
|
|
setupNeeded: !anyUsers,
|
|
user: user ? users.shape(user) : null,
|
|
siteName: config.siteName,
|
|
domainRule: users.domainRuleText(),
|
|
require2fa: config.admin.require2fa,
|
|
mustChangePassword: Boolean(user?.must_change_password),
|
|
});
|
|
});
|
|
|
|
router.use(requireAdmin);
|
|
|
|
/* -------------------------------------------------------------- account */
|
|
|
|
router.post('/account/password', (req, res) => {
|
|
const current = String(req.body?.currentPassword || '');
|
|
const next = String(req.body?.newPassword || '');
|
|
if (!verifyPassword(current, req.user.password_hash)) {
|
|
return res.status(401).json({ error: 'Your current password is not right.' });
|
|
}
|
|
const problem = passwordProblem(next);
|
|
if (problem) return res.status(400).json({ error: problem });
|
|
|
|
db.prepare('UPDATE admin_users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(
|
|
hashPassword(next),
|
|
req.user.id
|
|
);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.post('/account/2fa/start', async (req, res) => {
|
|
const secret = generateTotpSecret();
|
|
req.session.selfTotpSecret = secret;
|
|
const url = otpauthUrl({ secret, email: req.user.email, issuer: config.siteName });
|
|
res.json({ secret, qr: await QRCode.toDataURL(url, { margin: 1, width: 240 }) });
|
|
});
|
|
|
|
router.post('/account/2fa/enable', (req, res) => {
|
|
const secret = req.session.selfTotpSecret;
|
|
if (!secret) return res.status(400).json({ error: 'Start the setup again.' });
|
|
if (!verifyTotp(secret, clean(req.body?.code, 20))) {
|
|
return res.status(401).json({ error: 'That code did not match. Try the next one.' });
|
|
}
|
|
const recovery = generateRecoveryCodes();
|
|
db.prepare(
|
|
'UPDATE admin_users SET totp_secret = ?, totp_enabled = 1, recovery_codes = ? WHERE id = ?'
|
|
).run(secret, hashRecoveryCodes(recovery), req.user.id);
|
|
delete req.session.selfTotpSecret;
|
|
res.json({ ok: true, recoveryCodes: recovery });
|
|
});
|
|
|
|
router.post('/account/2fa/disable', (req, res) => {
|
|
if (config.admin.require2fa) {
|
|
return res.status(403).json({ error: 'Two factor is required for every admin on this server.' });
|
|
}
|
|
if (!verifyPassword(String(req.body?.password || ''), req.user.password_hash)) {
|
|
return res.status(401).json({ error: 'Your password is not right.' });
|
|
}
|
|
db.prepare(
|
|
'UPDATE admin_users SET totp_secret = NULL, totp_enabled = 0, recovery_codes = NULL WHERE id = ?'
|
|
).run(req.user.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
/* ---------------------------------------------------------------- users */
|
|
|
|
router.get('/users', requireOwner, (req, res) => {
|
|
const rows = db.prepare('SELECT * FROM admin_users ORDER BY email').all();
|
|
res.json(rows.map(users.shape));
|
|
});
|
|
|
|
router.post('/users', requireOwner, (req, res) => {
|
|
try {
|
|
const siteId = req.body?.siteId ? Number(req.body.siteId) : null;
|
|
if (siteId && !db.prepare('SELECT id FROM sites WHERE id = ?').get(siteId)) {
|
|
return res.status(400).json({ error: 'That site does not exist.' });
|
|
}
|
|
const { user, temporaryPassword } = users.createUser({
|
|
email: req.body?.email,
|
|
name: req.body?.name,
|
|
role: req.body?.role === 'owner' ? 'owner' : 'admin',
|
|
siteId,
|
|
});
|
|
res.json({ ...users.shape(user), temporaryPassword });
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.patch('/users/:id', requireOwner, (req, res) => {
|
|
const target = users.findById(req.params.id);
|
|
if (!target) return res.status(404).json({ error: 'Not found.' });
|
|
|
|
const makingInactive = req.body?.active === false;
|
|
const demoting = req.body?.role && req.body.role !== 'owner' && target.role === 'owner';
|
|
if ((makingInactive || demoting) && target.id === req.user.id) {
|
|
return res.status(400).json({ error: 'You cannot lock yourself out of your own account.' });
|
|
}
|
|
const owners = db
|
|
.prepare("SELECT COUNT(*) AS n FROM admin_users WHERE role = 'owner' AND active = 1").get().n;
|
|
if ((makingInactive || demoting) && target.role === 'owner' && owners <= 1) {
|
|
return res.status(400).json({ error: 'Keep at least one active owner account.' });
|
|
}
|
|
|
|
db.prepare('UPDATE admin_users SET name = ?, role = ?, site_id = ?, active = ? WHERE id = ?').run(
|
|
req.body?.name !== undefined ? clean(req.body.name, 80) || null : target.name,
|
|
req.body?.role === 'owner' ? 'owner' : req.body?.role === 'admin' ? 'admin' : target.role,
|
|
req.body?.siteId !== undefined ? (req.body.siteId ? Number(req.body.siteId) : null) : target.site_id,
|
|
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : target.active,
|
|
target.id
|
|
);
|
|
res.json(users.shape(users.findById(target.id)));
|
|
});
|
|
|
|
router.post('/users/:id/reset-password', requireOwner, (req, res) => {
|
|
const target = users.findById(req.params.id);
|
|
if (!target) return res.status(404).json({ error: 'Not found.' });
|
|
const temporary = req.body?.password || undefined;
|
|
const problem = temporary ? passwordProblem(temporary) : null;
|
|
if (problem) return res.status(400).json({ error: problem });
|
|
|
|
const password = temporary || `Vs${Math.random().toString(36).slice(2, 10)}9A`;
|
|
db.prepare('UPDATE admin_users SET password_hash = ?, must_change_password = 1 WHERE id = ?').run(
|
|
hashPassword(password),
|
|
target.id
|
|
);
|
|
users.clearFailures(target.email);
|
|
res.json({ ok: true, temporaryPassword: password });
|
|
});
|
|
|
|
router.post('/users/:id/reset-2fa', requireOwner, (req, res) => {
|
|
const target = users.findById(req.params.id);
|
|
if (!target) return res.status(404).json({ error: 'Not found.' });
|
|
db.prepare(
|
|
'UPDATE admin_users SET totp_secret = NULL, totp_enabled = 0, recovery_codes = NULL WHERE id = ?'
|
|
).run(target.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
/* ---------------------------------------------------------------- sites */
|
|
|
|
router.get('/sites', (req, res) => {
|
|
const scope = scopedSiteId(req);
|
|
const rows = listSites().filter((s) => !scope || s.id === scope);
|
|
res.json(
|
|
rows.map((row) => ({
|
|
...shapeSite(row),
|
|
printerStatus: printer.printerStatus(row.id),
|
|
}))
|
|
);
|
|
});
|
|
|
|
router.post('/sites', requireOwner, (req, res) => {
|
|
const name = clean(req.body?.name, 100);
|
|
if (!name) return res.status(400).json({ error: 'Give the site a name.' });
|
|
const slug = uniqueSlug(req.body?.slug || name);
|
|
const info = db.prepare('INSERT INTO sites (name, slug) VALUES (?, ?)').run(name, slug);
|
|
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(info.lastInsertRowid)));
|
|
});
|
|
|
|
router.patch('/sites/:id', (req, res) => {
|
|
try {
|
|
assertSiteAllowed(req, req.params.id);
|
|
} catch (err) {
|
|
return res.status(err.status || 403).json({ error: err.message });
|
|
}
|
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
|
if (!site) return res.status(404).json({ error: 'Not found.' });
|
|
|
|
const badge = req.body?.badge || {};
|
|
const branding = req.body?.branding || {};
|
|
const printerCfg = req.body?.printer || {};
|
|
db.prepare(
|
|
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
|
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
|
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
|
|
banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?,
|
|
printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ? WHERE id = ?`
|
|
).run(
|
|
clean(req.body?.name ?? site.name, 100) || site.name,
|
|
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
|
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : site.active,
|
|
badge.enabled !== undefined ? (badge.enabled ? 1 : 0) : site.badge_enabled,
|
|
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,
|
|
// A blank colour means "use the default", so it is stored as NULL rather than
|
|
// being quietly frozen at whatever the default happens to be today.
|
|
branding.brand !== undefined
|
|
? normaliseColour(branding.brand, null)
|
|
: site.colour_brand,
|
|
branding.signout !== undefined
|
|
? normaliseColour(branding.signout, null)
|
|
: site.colour_signout,
|
|
branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page,
|
|
branding.text !== undefined ? normaliseColour(branding.text, null) : site.colour_text,
|
|
branding.bannerHeight !== undefined
|
|
? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64))
|
|
: site.banner_height,
|
|
branding.bannerAlign !== undefined
|
|
? normaliseAlign(branding.bannerAlign, site.banner_align)
|
|
: site.banner_align,
|
|
printerCfg.enabled !== undefined ? (printerCfg.enabled ? 1 : 0) : site.printer_enabled,
|
|
printerCfg.host !== undefined ? clean(printerCfg.host, 120) || null : site.printer_host,
|
|
printerCfg.port !== undefined
|
|
? Math.min(65535, Math.max(1, Number(printerCfg.port) || 9100))
|
|
: site.printer_port,
|
|
printerCfg.model !== undefined ? clean(printerCfg.model, 40) || 'QL-820NWB' : site.printer_model,
|
|
printerCfg.rotate !== undefined
|
|
? ([0, 90, 180, 270].includes(Number(printerCfg.rotate)) ? Number(printerCfg.rotate) : 0)
|
|
: site.printer_rotate,
|
|
printerCfg.label !== undefined
|
|
? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62')
|
|
: site.printer_label
|
|
, site.id);
|
|
|
|
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
|
|
});
|
|
|
|
/* ------------------------------------------------------------- branding */
|
|
|
|
router.post('/sites/:id/banner', (req, res) => {
|
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
|
if (!site) return res.status(404).json({ error: 'Not found.' });
|
|
try {
|
|
assertSiteAllowed(req, site.id);
|
|
const name = saveBanner(site.id, req.body?.image);
|
|
if (site.banner_path) deleteBanner(site.banner_path);
|
|
db.prepare('UPDATE sites SET banner_path = ? WHERE id = ?').run(name, site.id);
|
|
res.json({ ok: true, url: `/api/branding/${site.id}/banner?t=${Date.now()}` });
|
|
} catch (err) {
|
|
res.status(err.status || 400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/sites/:id/banner', (req, res) => {
|
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
|
if (!site) return res.status(404).json({ error: 'Not found.' });
|
|
try {
|
|
assertSiteAllowed(req, site.id);
|
|
} catch (err) {
|
|
return res.status(403).json({ error: err.message });
|
|
}
|
|
if (site.banner_path) deleteBanner(site.banner_path);
|
|
db.prepare('UPDATE sites SET banner_path = NULL WHERE id = ?').run(site.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.get('/sites/: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.sendFile(abs);
|
|
});
|
|
|
|
/**
|
|
* The exact bitmap that would be sent to the printer, so the layout and the
|
|
* rotation can be checked without using a label.
|
|
*/
|
|
router.get('/sites/:id/badge-bitmap', async (req, res) => {
|
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
|
if (!site) return res.status(404).send('Not found.');
|
|
try {
|
|
const visit = req.query.visitId
|
|
? db.prepare('SELECT * FROM visits WHERE id = ?').get(req.query.visitId)
|
|
: printer.sampleVisit(site);
|
|
if (!visit) return res.status(404).send('No such visit.');
|
|
const png = await printer.renderBadgePng(visit, site);
|
|
res.setHeader('Content-Type', 'image/png');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.send(png);
|
|
} catch (err) {
|
|
res.status(500).send(`Could not render the badge: ${err.message}`);
|
|
}
|
|
});
|
|
|
|
router.post('/sites/:id/test-print', async (req, res) => {
|
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
|
if (!site) return res.status(404).json({ error: 'Not found.' });
|
|
try {
|
|
assertSiteAllowed(req, site.id);
|
|
} catch (err) {
|
|
return res.status(403).json({ error: err.message });
|
|
}
|
|
try {
|
|
const result = await printer.printBadge(printer.sampleVisit(site), site);
|
|
res.json({ ok: true, ...result });
|
|
} catch (err) {
|
|
// The raw output and the exact command matter when the friendly message is
|
|
// not enough — a printer refusing a job says why in its own words.
|
|
res.status(400).json({ error: err.message, raw: err.raw || null, command: err.command || null });
|
|
}
|
|
});
|
|
|
|
/** Everything about how this site would print, for working out why it will not. */
|
|
router.get('/sites/:id/printer-diagnostics', (req, res) => {
|
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
|
if (!site) return res.status(404).json({ error: 'Not found.' });
|
|
res.json({
|
|
site: site.name,
|
|
printing: {
|
|
enabled: Boolean(site.printer_enabled),
|
|
host: site.printer_host,
|
|
port: site.printer_port,
|
|
model: site.printer_model,
|
|
rotate: site.printer_rotate,
|
|
},
|
|
rollSetting: site.printer_label,
|
|
labelSentToPrinter: printer.labelFor(site),
|
|
redRequested: Boolean(site.badge_accent),
|
|
redWillPrint: printer.accentWillPrintRed(site),
|
|
badgeMm: { width: site.badge_width_mm, height: site.badge_height_mm },
|
|
lastResult: printer.printerStatus(site.id),
|
|
command: [
|
|
'brother_ql --backend network',
|
|
`--model ${site.printer_model || 'QL-820NWB'}`,
|
|
`--printer tcp://${site.printer_host}:${site.printer_port || 9100}`,
|
|
`print --label ${printer.labelFor(site)} badge.png`,
|
|
].join(' '),
|
|
});
|
|
});
|
|
|
|
/** Reprints a real visitor's badge on the server's printer. */
|
|
router.post('/visits/:id/print', async (req, res) => {
|
|
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id);
|
|
if (!visit) return res.status(404).json({ error: 'Not found.' });
|
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(visit.site_id);
|
|
if (!site) return res.status(404).json({ error: 'That site no longer exists.' });
|
|
if (!printer.isConfigured(site)) {
|
|
return res.status(400).json({ error: 'Server printing is not turned on for this site.' });
|
|
}
|
|
try {
|
|
await printer.printBadge(visit, site);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.get('/sites/:id/badge-preview', (req, res) => {
|
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
|
if (!site) return res.status(404).send('Not found.');
|
|
const sample = {
|
|
first_name: 'Sample',
|
|
last_name: 'Visitor',
|
|
host_name: 'Jess Rogerson',
|
|
check_type: 'WWCC',
|
|
check_number: 'WWC1234567E',
|
|
signed_in_at: nowIso(),
|
|
};
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.send(badgeHtml(sample, site, { autoPrint: false }));
|
|
});
|
|
|
|
/* ---------------------------------------------------------------- hosts */
|
|
|
|
function hostSiteId(req) {
|
|
const scope = scopedSiteId(req);
|
|
const asked = req.body?.siteId ?? req.query.siteId;
|
|
const siteId = scope || (asked && asked !== 'all' ? Number(asked) : null);
|
|
return siteId;
|
|
}
|
|
|
|
router.get('/hosts', (req, res) => {
|
|
const siteId = hostSiteId(req);
|
|
const sql = siteId
|
|
? 'SELECT * FROM hosts WHERE site_id = ? ORDER BY name COLLATE NOCASE'
|
|
: 'SELECT * FROM hosts ORDER BY name COLLATE NOCASE';
|
|
const rows = siteId ? db.prepare(sql).all(siteId) : db.prepare(sql).all();
|
|
res.json(rows);
|
|
});
|
|
|
|
router.post('/hosts', (req, res) => {
|
|
const name = titleCase(req.body?.name, 120);
|
|
const siteId = hostSiteId(req);
|
|
if (!name) return res.status(400).json({ error: 'Name is required.' });
|
|
if (!siteId) return res.status(400).json({ error: 'Choose which site this person belongs to.' });
|
|
try {
|
|
assertSiteAllowed(req, siteId);
|
|
} catch (err) {
|
|
return res.status(403).json({ error: err.message });
|
|
}
|
|
const info = db
|
|
.prepare('INSERT INTO hosts (name, email, area, site_id, active) VALUES (?, ?, ?, ?, 1)')
|
|
.run(name, normaliseEmail(req.body?.email) || null, clean(req.body?.area, 80) || null, siteId);
|
|
res.json(db.prepare('SELECT * FROM hosts WHERE id = ?').get(info.lastInsertRowid));
|
|
});
|
|
|
|
router.patch('/hosts/:id', (req, res) => {
|
|
const host = db.prepare('SELECT * FROM hosts WHERE id = ?').get(req.params.id);
|
|
if (!host) return res.status(404).json({ error: 'Not found.' });
|
|
try {
|
|
assertSiteAllowed(req, host.site_id);
|
|
} catch (err) {
|
|
return res.status(403).json({ error: err.message });
|
|
}
|
|
db.prepare('UPDATE hosts SET name = ?, email = ?, area = ?, active = ? WHERE id = ?').run(
|
|
titleCase(req.body?.name ?? host.name, 120),
|
|
req.body?.email !== undefined ? normaliseEmail(req.body.email) || null : host.email,
|
|
req.body?.area !== undefined ? clean(req.body.area, 80) || null : host.area,
|
|
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : host.active,
|
|
host.id
|
|
);
|
|
res.json(db.prepare('SELECT * FROM hosts WHERE id = ?').get(host.id));
|
|
});
|
|
|
|
router.delete('/hosts/:id', (req, res) => {
|
|
const host = db.prepare('SELECT * FROM hosts WHERE id = ?').get(req.params.id);
|
|
if (!host) return res.status(404).json({ error: 'Not found.' });
|
|
try {
|
|
assertSiteAllowed(req, host.site_id);
|
|
} catch (err) {
|
|
return res.status(403).json({ error: err.message });
|
|
}
|
|
db.prepare('UPDATE hosts SET active = 0 WHERE id = ?').run(host.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
/**
|
|
* CSV import, scoped to one site. Headings understood: name, email, area
|
|
* (or department / team / role). A single unnamed column is treated as the name.
|
|
*/
|
|
router.post('/hosts/import', (req, res) => {
|
|
const siteId = hostSiteId(req);
|
|
if (!siteId) return res.status(400).json({ error: 'Choose which site this list belongs to.' });
|
|
try {
|
|
assertSiteAllowed(req, siteId);
|
|
} catch (err) {
|
|
return res.status(403).json({ error: err.message });
|
|
}
|
|
|
|
const rows = parseCsv(req.body?.csv || '');
|
|
if (!rows.length) return res.status(400).json({ error: 'That CSV had no rows in it.' });
|
|
|
|
const header = rows[0].map((h) => h.trim().toLowerCase());
|
|
const looksLikeHeader = header.some((h) =>
|
|
['name', 'full name', 'staff', 'email', 'area', 'department', 'team'].includes(h)
|
|
);
|
|
const body = looksLikeHeader ? rows.slice(1) : rows;
|
|
const idx = {
|
|
name: looksLikeHeader ? header.findIndex((h) => ['name', 'full name', 'staff'].includes(h)) : 0,
|
|
email: looksLikeHeader ? header.findIndex((h) => h === 'email') : -1,
|
|
area: looksLikeHeader
|
|
? header.findIndex((h) => ['area', 'department', 'team', 'role'].includes(h))
|
|
: -1,
|
|
};
|
|
if (idx.name < 0) idx.name = 0;
|
|
|
|
const replace = Boolean(req.body?.replace);
|
|
const insert = db.prepare(
|
|
'INSERT INTO hosts (name, email, area, site_id, active) VALUES (?, ?, ?, ?, 1)'
|
|
);
|
|
const existing = db.prepare('SELECT id FROM hosts WHERE lower(name) = lower(?) AND site_id = ?');
|
|
const reactivate = db.prepare('UPDATE hosts SET active = 1, email = ?, area = ? WHERE id = ?');
|
|
|
|
let added = 0;
|
|
let updated = 0;
|
|
db.transaction(() => {
|
|
if (replace) db.prepare('UPDATE hosts SET active = 0 WHERE site_id = ?').run(siteId);
|
|
for (const row of body) {
|
|
const name = titleCase(row[idx.name], 120);
|
|
if (!name) continue;
|
|
const email = idx.email >= 0 ? normaliseEmail(row[idx.email]) || null : null;
|
|
const area = idx.area >= 0 ? clean(row[idx.area], 80) || null : null;
|
|
const found = existing.get(name, siteId);
|
|
if (found) {
|
|
reactivate.run(email, area, found.id);
|
|
updated += 1;
|
|
} else {
|
|
insert.run(name, email, area, siteId);
|
|
added += 1;
|
|
}
|
|
}
|
|
})();
|
|
|
|
const total = db
|
|
.prepare('SELECT COUNT(*) AS n FROM hosts WHERE active = 1 AND site_id = ?')
|
|
.get(siteId).n;
|
|
res.json({ added, updated, total });
|
|
});
|
|
|
|
/* ------------------------------------------------- recurring visitors */
|
|
|
|
function shapeFrequent(row, includePin = false) {
|
|
return {
|
|
id: row.id,
|
|
firstName: row.first_name,
|
|
lastName: row.last_name,
|
|
company: row.company,
|
|
phone: row.phone,
|
|
email: row.email,
|
|
checkType: row.check_type,
|
|
checkNumber: row.check_number,
|
|
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,
|
|
expiry: expiryState(row.check_type, row.check_expiry),
|
|
...(includePin ? { pin: decryptPin(row.pin_enc) } : {}),
|
|
};
|
|
}
|
|
|
|
/** Days until a WWCC or VIT lapses, plus a plain status an admin can act on. */
|
|
export function expiryState(checkType, checkExpiry) {
|
|
if (checkType === 'NONE' || !checkExpiry) return { status: 'none', daysLeft: null };
|
|
const due = new Date(`${checkExpiry}T23:59:59`);
|
|
if (Number.isNaN(due.getTime())) return { status: 'none', daysLeft: null };
|
|
const daysLeft = Math.ceil((due.getTime() - Date.now()) / 86400000);
|
|
if (daysLeft < 0) return { status: 'expired', daysLeft };
|
|
if (daysLeft <= config.expiryWarningDays) return { status: 'expiring', daysLeft };
|
|
return { status: 'ok', daysLeft };
|
|
}
|
|
|
|
router.get('/frequent', (req, res) => {
|
|
const scope = scopedSiteId(req);
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT * FROM frequent_visitors
|
|
${scope ? 'WHERE site_id IS NULL OR site_id = ?' : ''}
|
|
ORDER BY last_name COLLATE NOCASE, first_name COLLATE NOCASE`
|
|
)
|
|
.all(...(scope ? [scope] : []));
|
|
res.json(rows.map((r) => shapeFrequent(r)));
|
|
});
|
|
|
|
router.get('/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.' });
|
|
res.json({
|
|
...shapeFrequent(row, true),
|
|
// Context for the removal confirmation.
|
|
visitCount: db
|
|
.prepare('SELECT COUNT(*) AS n FROM visits WHERE frequent_visitor_id = ?')
|
|
.get(row.id).n,
|
|
onSite: Boolean(
|
|
db
|
|
.prepare('SELECT 1 FROM visits WHERE frequent_visitor_id = ? AND signed_out_at IS NULL')
|
|
.get(row.id)
|
|
),
|
|
});
|
|
});
|
|
|
|
/** Everyone whose check lapses inside the warning window, or already has. */
|
|
router.get('/alerts', (req, res) => {
|
|
const scope = scopedSiteId(req);
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT * FROM frequent_visitors
|
|
WHERE active = 1 AND check_type <> 'NONE' AND check_expiry IS NOT NULL
|
|
${scope ? 'AND (site_id IS NULL OR site_id = ?)' : ''}`
|
|
)
|
|
.all(...(scope ? [scope] : []))
|
|
.map((r) => shapeFrequent(r))
|
|
.filter((r) => r.expiry.status === 'expiring' || r.expiry.status === 'expired')
|
|
.sort((a, b) => a.expiry.daysLeft - b.expiry.daysLeft);
|
|
|
|
res.json({
|
|
warningDays: config.expiryWarningDays,
|
|
expired: rows.filter((r) => r.expiry.status === 'expired'),
|
|
expiring: rows.filter((r) => r.expiry.status === 'expiring'),
|
|
});
|
|
});
|
|
|
|
/** 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);
|
|
const email = normaliseEmail(body?.email);
|
|
const checkType = clean(body?.checkType, 10).toUpperCase() || 'NONE';
|
|
|
|
if (!firstName || !lastName) throw new Error('First and last name are required.');
|
|
if (!isPhone(phone)) throw new Error('A valid mobile number is required — it is their username.');
|
|
if (email && !isEmail(email)) throw new Error('That email address is not valid.');
|
|
if (!CHECK_TYPES.has(checkType)) throw new Error('Check type must be WWCC, VIT or NONE.');
|
|
if (checkType !== 'NONE' && !clean(body?.checkNumber)) {
|
|
throw new Error(`A ${checkType} number is required.`);
|
|
}
|
|
// 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,
|
|
lastName,
|
|
company: clean(body?.company, 80) || null,
|
|
phone,
|
|
email: email || null,
|
|
checkType,
|
|
checkNumber: clean(body?.checkNumber, 40) || null,
|
|
checkExpiry: clean(body?.checkExpiry, 20) || null,
|
|
defaultHostId: body?.defaultHostId ? Number(body.defaultHostId) : null,
|
|
siteId: body?.siteId ? Number(body.siteId) : null,
|
|
notes: clean(body?.notes, 300) || null,
|
|
};
|
|
}
|
|
|
|
router.post('/frequent', (req, res) => {
|
|
try {
|
|
const v = validateFrequent(req.body);
|
|
const scope = scopedSiteId(req);
|
|
const siteId = scope || v.siteId;
|
|
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, company, phone, email, check_type, check_number, check_expiry,
|
|
default_host_id, site_id, pin_enc, pin_lookup, photo_path, notes, active)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
|
)
|
|
.run(
|
|
v.firstName, v.lastName, v.company, v.phone, v.email, v.checkType, v.checkNumber,
|
|
v.checkExpiry, 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) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
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, 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 = ?, company = ?, phone = ?, email = ?,
|
|
check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?,
|
|
photo_path = ?, notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
|
|
).run(
|
|
v.firstName, v.lastName, v.company, 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
|
|
);
|
|
res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(row.id), true));
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/** 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.' });
|
|
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 });
|
|
});
|
|
|
|
/**
|
|
* Removes a recurring visitor for good.
|
|
*
|
|
* Their visit history is deliberately kept: visits store the name, contact details
|
|
* and host as their own columns, so the log stays a complete record of who was in
|
|
* the building even after the person's saved record is gone. Deleting the record
|
|
* frees their mobile number, email and PIN for someone else.
|
|
*
|
|
* To keep someone on file but stop them signing in, untick Active instead.
|
|
*/
|
|
router.delete('/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.' });
|
|
|
|
const scope = scopedSiteId(req);
|
|
if (scope && row.site_id && row.site_id !== scope) {
|
|
return res.status(403).json({ error: 'That visitor belongs to another site.' });
|
|
}
|
|
|
|
const openVisit = db
|
|
.prepare('SELECT id FROM visits WHERE frequent_visitor_id = ? AND signed_out_at IS NULL')
|
|
.get(row.id);
|
|
if (openVisit && !req.query.force) {
|
|
return res.status(409).json({
|
|
error: `${row.first_name} is signed in right now. Sign them out first, or confirm to remove anyway.`,
|
|
onSite: true,
|
|
});
|
|
}
|
|
|
|
const visitCount = db
|
|
.prepare('SELECT COUNT(*) AS n FROM visits WHERE frequent_visitor_id = ?')
|
|
.get(row.id).n;
|
|
|
|
if (row.photo_path) deletePhoto(row.photo_path);
|
|
db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone);
|
|
// visits.frequent_visitor_id is ON DELETE SET NULL, so the history survives.
|
|
db.prepare('DELETE FROM frequent_visitors WHERE id = ?').run(row.id);
|
|
|
|
res.json({
|
|
ok: true,
|
|
name: `${row.first_name} ${row.last_name}`,
|
|
visitsKept: visitCount,
|
|
});
|
|
});
|
|
|
|
/* ------------------------------------------------------------- visits */
|
|
|
|
function shapeVisit(v) {
|
|
return {
|
|
id: v.id,
|
|
siteId: v.site_id,
|
|
siteName: v.site_name,
|
|
visitorType: v.visitor_type,
|
|
firstName: v.first_name,
|
|
lastName: v.last_name,
|
|
company: v.company,
|
|
phone: v.phone,
|
|
email: v.email,
|
|
checkType: v.check_type,
|
|
checkNumber: v.check_number,
|
|
hostName: v.host_name,
|
|
visitReason: v.visit_reason,
|
|
hasPhoto: Boolean(v.photo_path),
|
|
signedInAt: v.signed_in_at,
|
|
signedOutAt: v.signed_out_at,
|
|
signedOutBy: v.signed_out_by,
|
|
};
|
|
}
|
|
|
|
router.get('/onsite', (req, res) => {
|
|
const where = ['signed_out_at IS NULL'];
|
|
const params = [];
|
|
applySiteFilter(req, where, params);
|
|
const rows = db
|
|
.prepare(`SELECT * FROM visits WHERE ${where.join(' AND ')} ORDER BY signed_in_at`)
|
|
.all(...params);
|
|
res.json(rows.map(shapeVisit));
|
|
});
|
|
|
|
router.get('/visits', (req, res) => {
|
|
const where = [];
|
|
const params = [];
|
|
applySiteFilter(req, where, params);
|
|
|
|
const from = clean(req.query.from, 10);
|
|
const to = clean(req.query.to, 10);
|
|
const q = clean(req.query.q, 60);
|
|
if (from) {
|
|
where.push('signed_in_at >= ?');
|
|
params.push(`${from}T00:00:00.000Z`);
|
|
}
|
|
if (to) {
|
|
where.push('signed_in_at <= ?');
|
|
params.push(`${to}T23:59:59.999Z`);
|
|
}
|
|
if (q) {
|
|
where.push(
|
|
'(last_name LIKE ? OR first_name LIKE ? OR company LIKE ? OR host_name LIKE ? OR phone LIKE ? OR email LIKE ?)'
|
|
);
|
|
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
|
|
}
|
|
const sql = `SELECT * FROM visits ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY signed_in_at DESC LIMIT 500`;
|
|
res.json(db.prepare(sql).all(...params).map(shapeVisit));
|
|
});
|
|
|
|
router.post('/visits/:id/signout', (req, res) => {
|
|
const visit = db
|
|
.prepare('SELECT * FROM visits WHERE id = ? AND signed_out_at IS NULL')
|
|
.get(req.params.id);
|
|
if (!visit) return res.status(404).json({ error: 'That visit is already closed.' });
|
|
try {
|
|
if (scopedSiteId(req)) assertSiteAllowed(req, visit.site_id);
|
|
} catch (err) {
|
|
return res.status(403).json({ error: err.message });
|
|
}
|
|
db.prepare('UPDATE visits SET signed_out_at = ?, signed_out_by = ? WHERE id = ?').run(
|
|
nowIso(),
|
|
'admin',
|
|
visit.id
|
|
);
|
|
sheets.mirror();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.get('/visits.csv', (req, res) => {
|
|
const where = [];
|
|
const params = [];
|
|
applySiteFilter(req, where, params);
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT * FROM visits ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY signed_in_at DESC`
|
|
)
|
|
.all(...params);
|
|
|
|
const csv = toCsv([
|
|
['Visit ID', 'Site', 'Type', 'First name', 'Last name', 'Company', 'Phone', 'Email', 'Check type', 'Check number', 'Visiting', 'Reason', 'Signed in', 'Signed out', 'Closed by', 'Photo'],
|
|
...rows.map((v) => [
|
|
v.id, v.site_name, v.visitor_type, v.first_name, v.last_name, v.company, v.phone, v.email,
|
|
v.check_type, v.check_number, v.host_name, v.visit_reason,
|
|
localStamp(v.signed_in_at), localStamp(v.signed_out_at), v.signed_out_by,
|
|
v.photo_path ? 'yes' : 'no',
|
|
]),
|
|
]);
|
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
res.setHeader('Content-Disposition', `attachment; filename="visits-${new Date().toISOString().slice(0, 10)}.csv"`);
|
|
res.send(csv);
|
|
});
|
|
|
|
router.get('/photo/:id', (req, res) => {
|
|
const visit = db.prepare('SELECT photo_path FROM visits WHERE id = ?').get(req.params.id);
|
|
const abs = visit && photoAbsolutePath(visit.photo_path);
|
|
if (!abs) return res.status(404).send('No photo on file.');
|
|
res.setHeader('Cache-Control', 'private, max-age=300');
|
|
res.sendFile(abs);
|
|
});
|
|
|
|
router.delete('/photo/:id', (req, res) => {
|
|
const visit = db.prepare('SELECT photo_path FROM visits WHERE id = ?').get(req.params.id);
|
|
if (visit?.photo_path) {
|
|
deletePhoto(visit.photo_path);
|
|
db.prepare('UPDATE visits SET photo_path = NULL WHERE id = ?').run(req.params.id);
|
|
}
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
/** Reprint a badge for someone already on site. */
|
|
router.get('/badge/:id', (req, res) => {
|
|
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id);
|
|
if (!visit) return res.status(404).send('Not found.');
|
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(visit.site_id);
|
|
if (!site) return res.status(404).send('That site no longer exists.');
|
|
|
|
let photoUrl = null;
|
|
const abs = photoAbsolutePath(visit.photo_path);
|
|
if (abs && site.badge_show_photo) {
|
|
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 }));
|
|
});
|
|
|
|
/* --------------------------------------------------- printable PIN card */
|
|
|
|
router.get('/pass/:id', (req, res) => {
|
|
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
|
|
if (!row) return res.status(404).send('Not found.');
|
|
const pin = decryptPin(row.pin_enc) || '????';
|
|
const host = row.default_host_id
|
|
? db.prepare('SELECT name FROM hosts WHERE id = ?').get(row.default_host_id)
|
|
: null;
|
|
const site = row.site_id ? db.prepare('SELECT name FROM sites WHERE id = ?').get(row.site_id) : null;
|
|
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.send(`<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Sign in card — ${esc(row.first_name)} ${esc(row.last_name)}</title>
|
|
<style>
|
|
:root { --ink:#16202b; --muted:#5d6b7a; --rule:#c9d3dc; --deep:#0b4f4a; }
|
|
* { box-sizing: border-box; }
|
|
body { margin:0; padding:24px; background:#eef1f4; color:var(--ink);
|
|
font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
|
|
.card { width: 105mm; min-height: 74mm; margin: 0 auto; background:#fff; padding: 12mm 11mm;
|
|
border:1px solid var(--rule); }
|
|
h1 { margin:0; font-size: 21px; letter-spacing:-0.01em; }
|
|
.site { margin:0 0 14px; font-size:12px; color:var(--muted); }
|
|
.org { margin: 3px 0 0; font-size: 13px; color: var(--muted); }
|
|
.pin { margin: 14px 0 4px; font-size: 46px; font-weight: 700; letter-spacing: 0.22em;
|
|
font-variant-numeric: tabular-nums; color: var(--deep); }
|
|
.pin-label { margin:0 0 16px; font-size:12px; color:var(--muted); }
|
|
dl { display:grid; grid-template-columns: 34mm 1fr; gap:5px 10px; margin:0;
|
|
font-size:12.5px; border-top:1px solid var(--rule); padding-top:10px; }
|
|
dt { color: var(--muted); }
|
|
dd { margin:0; }
|
|
.how { margin-top:12px; font-size:11.5px; color:var(--muted); line-height:1.5; }
|
|
.no-print { text-align:center; margin: 18px 0; }
|
|
button { font:inherit; padding:10px 20px; border:1px solid var(--deep); background:var(--deep);
|
|
color:#fff; border-radius:2px; cursor:pointer; }
|
|
@media print {
|
|
body { background:#fff; padding:0; }
|
|
.card { border:none; }
|
|
.no-print { display:none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="no-print"><button onclick="window.print()">Print this card</button></div>
|
|
<div class="card">
|
|
<p class="site">${esc(site ? site.name : config.siteName)}</p>
|
|
<h1>${esc(row.first_name)} ${esc(row.last_name)}</h1>
|
|
${row.company ? `<p class="org">${esc(row.company)}</p>` : ''}
|
|
<p class="pin">${esc(pin)}</p>
|
|
<p class="pin-label">Your PIN. Keep this card, it is not sent to you again.</p>
|
|
<dl>
|
|
<dt>Mobile (your username)</dt><dd>${esc(row.phone)}</dd>
|
|
<dt>Check on file</dt><dd>${row.check_type === 'NONE' ? 'None recorded' : `${esc(row.check_type)} ${esc(row.check_number || '')}`}</dd>
|
|
${row.check_expiry ? `<dt>Expires</dt><dd>${esc(row.check_expiry)}</dd>` : ''}
|
|
${site ? `<dt>Site</dt><dd>${esc(site.name)}</dd>` : '<dt>Site</dt><dd>Any site</dd>'}
|
|
${host ? `<dt>Usually visiting</dt><dd>${esc(host.name)}</dd>` : ''}
|
|
<dt>Issued</dt><dd>${esc(localStamp(nowIso()))}</dd>
|
|
</dl>
|
|
<p class="how">At the kiosk, tap <strong>I have a PIN</strong>, enter your mobile number
|
|
and this PIN, pick who you are visiting, and take a photo. Sign out with your last name and
|
|
mobile number on the way out.</p>
|
|
</div>
|
|
</body>
|
|
</html>`);
|
|
});
|
|
|
|
/* ------------------------------------------------------------- system */
|
|
|
|
router.get('/status', (req, res) => {
|
|
const scope = scopedSiteId(req);
|
|
res.json({
|
|
siteName: config.siteName,
|
|
timezone: config.timezone,
|
|
requirePhoto: config.requirePhoto,
|
|
photoRetentionDays: config.photoRetentionDays,
|
|
autoSignOutTime: config.autoSignOutTime || null,
|
|
expiryWarningDays: config.expiryWarningDays,
|
|
require2fa: config.admin.require2fa,
|
|
domainRule: users.domainRuleText(),
|
|
siteCount: listSites({ activeOnly: true }).length,
|
|
onSite: scope
|
|
? db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL AND site_id = ?').get(scope).n
|
|
: db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL').get().n,
|
|
sheets: {
|
|
enabled: sheets.isEnabled(),
|
|
lastOk: sheets.status.lastOk,
|
|
lastError: sheets.status.lastError,
|
|
tab: sheets.tabName(),
|
|
stale: sheets.isStale(),
|
|
serviceAccount: sheets.serviceAccountEmail(),
|
|
spreadsheetId: config.sheets.spreadsheetId || null,
|
|
lastOnSiteSync: sheets.status.lastOnSiteSync,
|
|
onSiteCount: sheets.status.onSiteCount,
|
|
onSiteError: sheets.status.onSiteError,
|
|
},
|
|
tls: tls.describe(),
|
|
});
|
|
});
|
|
|
|
router.post('/sheets/resync', async (req, res) => {
|
|
try {
|
|
res.json({ ok: true, ...(await sheets.syncOnSite()) });
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/sheets/test', async (req, res) => {
|
|
try {
|
|
res.json({ ok: true, ...(await sheets.testConnection()) });
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/* ---------------------------------------------------------------- tls */
|
|
|
|
router.get('/tls', (req, res) => {
|
|
res.json(tls.describe());
|
|
});
|
|
|
|
/** The CA certificate is public by design — it is what tablets need to trust. */
|
|
router.get('/tls/ca.crt', (req, res) => {
|
|
const ca = tls.caCertificate();
|
|
if (!ca) return res.status(404).send('No certificate authority has been generated.');
|
|
res.setHeader('Content-Type', 'application/x-x509-ca-cert');
|
|
res.setHeader('Content-Disposition', 'attachment; filename="visitor-signin-ca.crt"');
|
|
res.send(ca);
|
|
});
|
|
|
|
/**
|
|
* The same authority in DER form, for Jamf Pro and anything else built on Apple's
|
|
* tooling. Offered as .cer and .der because different consoles insist on
|
|
* different extensions for the identical bytes.
|
|
*/
|
|
router.get(['/tls/ca.cer', '/tls/ca.der'], (req, res) => {
|
|
try {
|
|
const der = tls.caCertificateDer();
|
|
if (!der) return res.status(404).send('No certificate authority has been generated yet.');
|
|
const ext = req.path.endsWith('.der') ? 'der' : 'cer';
|
|
res.setHeader('Content-Type', 'application/pkix-cert');
|
|
res.setHeader('Content-Disposition', `attachment; filename="visitor-signin-ca.${ext}"`);
|
|
res.send(der);
|
|
} catch (err) {
|
|
res.status(500).send(`Could not convert the certificate: ${err.message}`);
|
|
}
|
|
});
|
|
|
|
router.post('/tls/renew', requireOwner, (req, res) => {
|
|
try {
|
|
// A brand new CA means every kiosk device has to trust it again, so it is
|
|
// deliberately a separate, explicit choice.
|
|
const newCa = Boolean(req.body?.newCa);
|
|
tls.ensureCertificates({ force: newCa });
|
|
const reload = req.app.get('reloadTls');
|
|
const reloaded = reload ? reload() : false;
|
|
res.json({ ok: true, reloaded, newCa, info: tls.describe() });
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/photos/purge', (req, res) => {
|
|
res.json({ purged: purgeOldPhotos() });
|
|
});
|
|
|
|
export default router;
|