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

This commit is contained in:
2026-09-02 16:07:52 +10:00
parent c21123885d
commit 74792b2764
11 changed files with 469 additions and 11 deletions
+31
View File
@@ -101,6 +101,37 @@ same person can be signed in at two sites at once without the system arguing abo
An admin account can be limited to a single site. Those admins see only that site's visitors,
staff list and log, and cannot touch the others.
## Branding the kiosk
Each site can carry its own banner and colours, set under **Sites → Edit**.
**Banner.** Upload a PNG, JPEG or WebP up to 2 MB. A PNG with a transparent background is the
one to use — it sits straight on the bar colour with nothing painted behind it, so it works
whatever colour you pick. The admin preview shows it on a checkerboard so you can see the
transparency. When a banner is set it replaces the site name in the kiosk header; set the
on-screen height in pixels to suit the shape of your logo. SVG is deliberately not accepted:
it can carry script, and this file is served to every kiosk.
**Colours.** Three are settable:
| Setting | Where it shows |
|---|---|
| Bar and buttons | The top bar, the Sign in door, primary buttons, the confirmation mark |
| Sign out | The Sign out door and the signed-out confirmation |
| Page background | Behind everything, with card and rule colours derived from it |
Everything else is worked out from those three. In particular the **text colour on a coloured
background is chosen by contrast**, not fixed — pick a pale yellow for the bar and the text on
it turns dark automatically, instead of staying white and becoming unreadable. Card, border and
body-text colours follow the page colour, so a dark background gives a usable dark theme rather
than white boxes.
Leave a colour box empty to fall back to the default. Anything that is not a six-digit hex value
is ignored rather than applied.
The admin console keeps its own neutral look, so it stays recognisable when you are switching
between sites. Printed badges stay black on white — a label printer has no colours to give.
## Badge printing
Turn it on per site under **Sites → Edit**. After a visitor signs in, the kiosk loads the badge
+49
View File
@@ -351,3 +351,52 @@ tr.row-bad td { background: #fdf0f2; }
/* The confirmation for a destructive action should not look like a Save. */
#modal.destructive { border-top-color: var(--alert); }
#modal.destructive .primary { border-color: var(--alert); background: var(--alert); }
/* ----------------------------------------------------- branding editor */
.banner-frame {
/* A checkerboard, so a transparent PNG reads as transparent rather than white. */
background-color: #fff;
background-image:
linear-gradient(45deg, #e3e8ed 25%, transparent 25%, transparent 75%, #e3e8ed 75%),
linear-gradient(45deg, #e3e8ed 25%, transparent 25%, transparent 75%, #e3e8ed 75%);
background-size: 14px 14px;
background-position: 0 0, 7px 7px;
border: 1px solid var(--rule);
border-radius: 3px;
padding: 10px;
margin-bottom: 12px;
min-height: 76px;
display: grid;
place-items: center;
}
.banner-frame img { max-width: 100%; max-height: 90px; display: block; }
.banner-empty { margin: 0; color: var(--muted); font-size: 13px; }
.colour-row { display: flex; gap: 8px; align-items: center; }
.colour-row input[type="color"] {
width: 42px;
height: 40px;
padding: 2px;
border: 1px solid var(--rule);
border-radius: 3px;
background: none;
cursor: pointer;
}
.colour-row input[type="text"] {
flex: 1 1 auto;
min-width: 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 13px;
}
.modal-row-3 { grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
.swatch {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 2px;
border: 1px solid var(--rule);
vertical-align: -2px;
margin-right: 2px;
}
+19 -6
View File
@@ -6,8 +6,10 @@
--rule: #c9d3dc;
--deep: #0b4f4a;
--deep-dark: #083a36;
--on-brand: #ffffff;
--exit: #2c4a6b;
--exit-dark: #1f3650;
--on-exit: #ffffff;
--alert: #96162f;
--focus: #0b4f4a;
}
@@ -45,7 +47,17 @@ body {
gap: 16px;
padding: 14px 22px;
background: var(--deep);
color: #eef5f3;
color: var(--on-brand);
}
/* An uploaded banner sits in the bar in place of the site name. Transparent PNGs
are the point, so nothing is painted behind it. */
.banner {
max-height: 64px;
max-width: min(70%, 460px);
width: auto;
object-fit: contain;
display: block;
}
.site { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
@@ -108,11 +120,11 @@ h2 { font-size: 25px; margin: 4px 0 20px; }
border-left: 7px solid var(--deep-dark);
border-radius: 3px;
background: var(--deep);
color: #fff;
color: var(--on-brand);
font: inherit;
cursor: pointer;
}
.door-out { background: var(--exit); border-left-color: var(--exit-dark); }
.door-out { background: var(--exit); border-left-color: var(--exit-dark); color: var(--on-exit); }
.door:active { transform: translateY(1px); }
.door-title { display: block; font-size: 27px; font-weight: 650; letter-spacing: -0.01em; }
@@ -131,6 +143,7 @@ h2 { font-size: 25px; margin: 4px 0 20px; }
cursor: pointer;
}
.text-action:hover { background: var(--card); }
.text-action, .field input, .picker, .choice, .host-option { color: var(--ink); }
/* ------------------------------------------------------- step rail */
@@ -280,11 +293,11 @@ a:focus-visible {
padding: 7px 14px;
border-radius: 2px;
background: var(--deep);
color: #fff;
color: var(--on-brand);
font-size: 14px;
font-weight: 600;
}
#screen-done-out .mark { background: var(--exit); }
#screen-done-out .mark { background: var(--exit); color: var(--on-exit); }
/* ------------------------------------------------------------ buttons */
@@ -306,7 +319,7 @@ a:focus-visible {
.nav .primary {
border: 1px solid var(--deep);
background: var(--deep);
color: #fff;
color: var(--on-brand);
font-weight: 600;
}
.nav .ghost {
+1
View File
@@ -11,6 +11,7 @@
<body>
<header class="bar">
<img class="banner" id="banner" alt="" hidden>
<p class="site" id="siteName">Visitor sign in</p>
<p class="clock" id="clock"></p>
</header>
+113 -3
View File
@@ -679,6 +679,13 @@ async function loadSites() {
: 'Off'
}</dd>
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''}
<dt>Kiosk branding</dt>
<dd>
${s.branding.hasBanner ? 'Banner set' : 'No banner'} ·
<span class="swatch" style="background:${esc(s.branding.theme.brand)}"></span>
<span class="swatch" style="background:${esc(s.branding.theme.signout)}"></span>
<span class="swatch" style="background:${esc(s.branding.theme.page)}"></span>
</dd>
</dl>
</article>`
)
@@ -739,7 +746,32 @@ function openSiteModal(site) {
<label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label>
<p class="hint">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.</p>
${field('Line printed at the bottom', 'note', site.badge.note)}`,
${field('Line printed at the bottom', 'note', site.badge.note)}
<h4 class="modal-section">Kiosk branding</h4>
<div class="banner-editor">
<div class="banner-frame" id="banner-frame">
<img id="banner-preview" alt="Banner"${
site.branding.hasBanner ? ` src="/admin/api/sites/${site.id}/banner?t=${Date.now()}"` : ' hidden'
}>
<p class="banner-empty" id="banner-empty"${site.branding.hasBanner ? ' hidden' : ''}>No banner</p>
</div>
<div class="photo-actions">
<label class="ghost photo-upload">Upload an image
<input type="file" id="banner-file" accept="image/png,image/jpeg,image/webp" hidden>
</label>
<button type="button" class="ghost danger" id="banner-clear"${site.branding.hasBanner ? '' : ' hidden'}>Remove</button>
</div>
<p class="hint">A PNG with a transparent background works best — it sits straight on the
bar colour with nothing painted behind it. Up to 2 MB.</p>
${field('Banner height on screen (px)', 'bannerHeight', site.branding.bannerHeight, 'number')}
</div>
<div class="modal-row modal-row-3">
${colourField('Bar and buttons', 'brand', site.branding.brand, '#0b4f4a')}
${colourField('Sign out', 'signout', site.branding.signout, '#2c4a6b')}
${colourField('Page background', 'page', site.branding.page, '#e7ecf0')}
</div>
<p class="hint">Text colours are worked out from these, so a pale brand colour gets dark
text rather than white. Clear a box to go back to the default.</p>`,
async (form) => {
const data = Object.fromEntries(form.entries());
try {
@@ -757,18 +789,96 @@ function openSiteModal(site) {
accent: form.has('accent'),
note: data.note,
},
branding: {
brand: data.brand || null,
signout: data.signout || null,
page: data.page || null,
bannerHeight: Number(data.bannerHeight) || 64,
},
},
});
toast('Site saved. Preview the badge before committing a roll to it.');
if (bannerEditor.dataUrl) {
await api(`/sites/${site.id}/banner`, {
method: 'POST',
body: { image: bannerEditor.dataUrl },
});
} else if (bannerEditor.remove) {
await api(`/sites/${site.id}/banner`, { method: 'DELETE' });
}
toast('Site saved. Reload the kiosk to see the change.');
loadSites();
} catch (err) {
toast(err.message, true);
}
},
{ onOpen: wireBadgePreset }
{
onOpen: () => {
wireBadgePreset();
wireBannerEditor();
},
}
);
}
/**
* A colour box paired with a text field, so a colour can be picked by eye or
* pasted from a brand guide, and cleared entirely to fall back to the default.
*/
function colourField(label, name, value, fallback) {
const current = value || '';
return `<label class="modal-field colour-field">
<span>${esc(label)}</span>
<span class="colour-row">
<input type="color" data-colour-for="${name}" value="${esc(current || fallback)}">
<input type="text" name="${name}" value="${esc(current)}" placeholder="${esc(fallback)}"
maxlength="7" spellcheck="false">
</span>
</label>`;
}
const bannerEditor = { dataUrl: null, remove: false };
function wireBannerEditor() {
bannerEditor.dataUrl = null;
bannerEditor.remove = false;
// Keep the swatch and the hex box in step, in both directions.
$$('#modal-form [data-colour-for]').forEach((swatch) => {
const text = $(`#modal-form [name="${swatch.dataset.colourFor}"]`);
swatch.addEventListener('input', () => {
text.value = swatch.value;
});
text.addEventListener('input', () => {
if (/^#[0-9a-fA-F]{6}$/.test(text.value.trim())) swatch.value = text.value.trim();
});
});
$('#banner-file').addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) return toast('That image is over 2 MB. Use a smaller one.', true);
const reader = new FileReader();
reader.onload = () => {
bannerEditor.dataUrl = reader.result;
bannerEditor.remove = false;
$('#banner-preview').src = reader.result;
$('#banner-preview').hidden = false;
$('#banner-empty').hidden = true;
$('#banner-clear').hidden = false;
};
reader.readAsDataURL(file);
});
$('#banner-clear').addEventListener('click', () => {
bannerEditor.dataUrl = null;
bannerEditor.remove = true;
$('#banner-preview').hidden = true;
$('#banner-preview').removeAttribute('src');
$('#banner-empty').hidden = false;
$('#banner-clear').hidden = true;
});
}
function wireBadgePreset() {
const width = $('#modal-form [name="widthMm"]');
const height = $('#modal-form [name="heightMm"]');
+38
View File
@@ -435,11 +435,49 @@ async function chooseSite() {
show('site', { push: false });
}
/**
* Applies the site's colours as CSS variables. Only three are chosen by an admin;
* the shades and the text colours that sit on them are derived server-side so a
* dark logo colour cannot end up with dark text on it.
*/
function applyTheme(theme, banner) {
if (theme) {
const root = document.documentElement.style;
root.setProperty('--deep', theme.brand);
root.setProperty('--deep-dark', theme.brandDark);
root.setProperty('--on-brand', theme.onBrand);
root.setProperty('--exit', theme.signout);
root.setProperty('--exit-dark', theme.signoutDark);
root.setProperty('--on-exit', theme.onSignout);
root.setProperty('--paper', theme.page);
root.setProperty('--card', theme.card);
root.setProperty('--ink', theme.ink);
root.setProperty('--rule', theme.rule);
root.setProperty('--focus', theme.brand);
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.brand);
}
const img = $('#banner');
if (banner?.url) {
img.src = banner.url;
img.style.maxHeight = `${banner.height}px`;
img.hidden = false;
// The banner carries the branding, so the name beside it would just repeat it.
$('#siteName').hidden = true;
} else {
img.hidden = true;
img.removeAttribute('src');
$('#siteName').hidden = false;
}
}
async function loadSiteContext() {
siteConfig = await api('/api/config');
const name = siteConfig.site ? siteConfig.site.name : siteConfig.siteName;
document.title = name;
$('#siteName').textContent = name;
$('#banner').alt = name;
applyTheme(siteConfig.theme, siteConfig.banner);
const change = $('#change-site');
change.hidden = !siteConfig.multiSite;
+123
View File
@@ -0,0 +1,123 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import config from './config.js';
/**
* Per-site branding: an uploaded banner and a small set of colours.
*
* Only three colours are settable, and the rest of the palette is derived from
* them. Exposing every colour would let someone produce an unreadable kiosk, and
* the one that matters most — the text on a coloured bar — is chosen by contrast
* rather than left to chance.
*/
export const DEFAULT_THEME = {
brand: '#0b4f4a',
signout: '#2c4a6b',
page: '#e7ecf0',
};
const BANNER_DIR = path.join(config.dataDir, 'branding');
const MAX_BANNER_BYTES = 2 * 1024 * 1024;
fs.mkdirSync(BANNER_DIR, { recursive: true });
/* -------------------------------------------------------------- colour */
export function isHexColour(value) {
return /^#[0-9a-f]{6}$/i.test(String(value || '').trim());
}
export function normaliseColour(value, fallback) {
return isHexColour(value) ? String(value).trim().toLowerCase() : fallback;
}
function toRgb(hex) {
return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
}
/** Relative luminance, per WCAG, used to pick readable text over a colour. */
function luminance(hex) {
const [r, g, b] = toRgb(hex).map((channel) => {
const c = channel / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/** White or near-black, whichever is easier to read on the given background. */
export function readableOn(hex) {
return luminance(hex) > 0.45 ? '#16202b' : '#ffffff';
}
/** Shifts a colour towards black (negative) or white (positive). */
export function shade(hex, amount) {
const channels = toRgb(hex).map((channel) => {
const target = amount < 0 ? 0 : 255;
const value = Math.round(channel + (target - channel) * Math.abs(amount));
return Math.max(0, Math.min(255, value));
});
return `#${channels.map((c) => c.toString(16).padStart(2, '0')).join('')}`;
}
/** The full palette the kiosk needs, derived from what an admin actually set. */
export function themeFor(site) {
const brand = normaliseColour(site?.colour_brand, DEFAULT_THEME.brand);
const signout = normaliseColour(site?.colour_signout, DEFAULT_THEME.signout);
const page = normaliseColour(site?.colour_page, DEFAULT_THEME.page);
return {
brand,
brandDark: shade(brand, -0.25),
onBrand: readableOn(brand),
signout,
signoutDark: shade(signout, -0.25),
onSignout: readableOn(signout),
page,
// A card needs to lift off the page whether the page is light or dark.
card: luminance(page) > 0.5 ? '#ffffff' : shade(page, 0.12),
ink: readableOn(page) === '#ffffff' ? '#f2f5f7' : '#16202b',
rule: luminance(page) > 0.5 ? shade(page, -0.12) : shade(page, 0.2),
};
}
/* -------------------------------------------------------------- banner */
/** Accepts a data URL from the admin console and writes it to disk. */
export function saveBanner(siteId, dataUrl) {
const match = /^data:image\/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)$/.exec(
String(dataUrl || '').trim()
);
if (!match) {
// SVG is deliberately not accepted: it can carry script, and this file is
// served to every kiosk.
throw new Error('Use a PNG, JPEG or WebP image. PNG keeps transparency.');
}
const buffer = Buffer.from(match[2], 'base64');
if (buffer.length > MAX_BANNER_BYTES) throw new Error('That image is over 2 MB. Use a smaller one.');
const ext = match[1] === 'jpg' ? 'jpeg' : match[1];
const name = `site-${siteId}-${crypto.randomBytes(4).toString('hex')}.${ext}`;
fs.mkdirSync(BANNER_DIR, { recursive: true });
fs.writeFileSync(path.join(BANNER_DIR, name), buffer);
return name;
}
export function bannerAbsolutePath(name) {
if (!name) return null;
const resolved = path.resolve(BANNER_DIR, name);
if (!resolved.startsWith(path.resolve(BANNER_DIR))) return null;
return fs.existsSync(resolved) ? resolved : null;
}
export function deleteBanner(name) {
const abs = bannerAbsolutePath(name);
if (abs) {
try {
fs.unlinkSync(abs);
} catch {
/* already gone */
}
}
}
+11
View File
@@ -22,6 +22,11 @@ CREATE TABLE IF NOT EXISTS sites (
badge_show_photo INTEGER NOT NULL DEFAULT 1,
badge_accent INTEGER NOT NULL DEFAULT 0,
badge_note TEXT,
banner_path TEXT,
banner_height INTEGER NOT NULL DEFAULT 64,
colour_brand TEXT,
colour_signout TEXT,
colour_page TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -153,6 +158,12 @@ addColumn('frequent_visitors', 'photo_path', 'TEXT');
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');
// Per-site branding on the kiosk.
addColumn('sites', 'banner_path', 'TEXT');
addColumn('sites', 'banner_height', 'INTEGER NOT NULL DEFAULT 64');
addColumn('sites', 'colour_brand', 'TEXT');
addColumn('sites', 'colour_signout', 'TEXT');
addColumn('sites', 'colour_page', 'TEXT');
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)');
+59 -2
View File
@@ -10,6 +10,13 @@ import * as sheets from '../sheets.js';
import * as tls from '../tls.js';
import * as users from '../users.js';
import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js';
import {
DEFAULT_THEME,
bannerAbsolutePath,
deleteBanner,
normaliseColour,
saveBanner,
} from '../branding.js';
import {
consumeRecoveryCode,
generateRecoveryCodes,
@@ -376,9 +383,11 @@ router.patch('/sites/:id', (req, res) => {
if (!site) return res.status(404).json({ error: 'Not found.' });
const badge = req.body?.badge || {};
const branding = req.body?.branding || {};
db.prepare(
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ? WHERE id = ?`
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
colour_brand = ?, colour_signout = ?, colour_page = ?, banner_height = ? WHERE id = ?`
).run(
clean(req.body?.name ?? site.name, 100) || site.name,
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
@@ -388,12 +397,60 @@ router.patch('/sites/:id', (req, res) => {
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
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.bannerHeight !== undefined
? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64))
: site.banner_height
, 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);
});
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.');
+16
View File
@@ -6,6 +6,7 @@ import { savePhoto, photoAbsolutePath, copyStoredPhoto } from '../photos.js';
import { mirror } from '../sheets.js';
import { verifyPin } from '../pins.js';
import { listSites, resolveSite, badgeHtml } from '../sites.js';
import { themeFor, bannerAbsolutePath } from '../branding.js';
import fs from 'node:fs';
import {
clean,
@@ -47,9 +48,24 @@ router.get('/config', (req, res) => {
: null,
siteName: site ? site.name : config.siteName,
requirePhoto: config.requirePhoto,
// Branding for this kiosk: colours are applied as CSS variables and the
// banner replaces the site name in the top bar.
theme: themeFor(site),
banner: site?.banner_path
? { url: `/api/branding/${site.id}/banner`, height: site.banner_height || 64 }
: null,
});
});
/** The site banner. Public, because the kiosk shows it before anyone signs in. */
router.get('/branding/: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.setHeader('Cache-Control', 'public, max-age=300');
res.sendFile(abs);
});
router.get('/hosts', (req, res) => {
const site = siteFrom(req);
if (!site) return res.json([]);
+9
View File
@@ -1,5 +1,6 @@
import db from './db.js';
import { clean, localStamp } from './util.js';
import { themeFor } from './branding.js';
export function listSites({ activeOnly = false } = {}) {
const sql = `SELECT * FROM sites ${activeOnly ? 'WHERE active = 1' : ''} ORDER BY name COLLATE NOCASE`;
@@ -48,6 +49,14 @@ export function shapeSite(site) {
name: site.name,
slug: site.slug,
active: Boolean(site.active),
branding: {
hasBanner: Boolean(site.banner_path),
bannerHeight: site.banner_height || 64,
brand: site.colour_brand,
signout: site.colour_signout,
page: site.colour_page,
theme: themeFor(site),
},
badge: {
enabled: Boolean(site.badge_enabled),
widthMm: site.badge_width_mm,