13 Commits
Author SHA1 Message Date
jessikitty c3ce46913d Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-03 14:47:59 +10:00
jessikitty e6731dbdfa Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-03 14:43:03 +10:00
jessikitty 8c97c314e0 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-02 16:45:48 +10:00
jessikitty ff9f51eaf9 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-02 16:21:42 +10:00
jessikitty 74792b2764 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-02 16:07:52 +10:00
jessikitty c21123885d Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-01 16:08:27 +10:00
jessikitty cccba98d97 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-01 15:45:47 +10:00
jessikitty a7564b9033 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-01 15:24:03 +10:00
jessikitty 9da7e88eb3 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-09-01 15:14:46 +10:00
jessikitty a011587d66 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-08-31 15:16:09 +10:00
jessikitty 14678e13e8 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-08-31 14:54:17 +10:00
jessikitty b23ad422d0 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-08-31 14:33:30 +10:00
jessikitty ed77493817 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA 2026-08-31 09:47:36 +10:00
14 changed files with 45 additions and 729 deletions
-8
View File
@@ -76,13 +76,5 @@ GOOGLE_CREDENTIALS_PATH=/secrets/google-service-account.json
GOOGLE_CREDENTIALS_B64= GOOGLE_CREDENTIALS_B64=
SHEETS_RETRY_INTERVAL_MS=60000 SHEETS_RETRY_INTERVAL_MS=60000
# ------------------------------------------------------------- printing
# Badges are rendered and printed by the server, so kiosk tablets need no driver.
# The printer's address is set per site in Admin -> Sites, not here.
# PRINT_COMMAND=brother_ql
PRINT_TIMEOUT_MS=15000
# How long a sign in waits for the badge before falling back to the kiosk browser.
PRINT_SIGNIN_WAIT_MS=9000
# -------------------------------------------------------------- storage # -------------------------------------------------------------- storage
DATA_DIR=/data DATA_DIR=/data
+1 -5
View File
@@ -11,11 +11,7 @@ RUN npm install --omit=dev
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
ENV NODE_ENV=production ENV NODE_ENV=production
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends openssl ca-certificates tini util-linux \
openssl ca-certificates tini util-linux \
# Fonts for the server-rendered badge, and brother_ql to drive the label printer.
fonts-liberation python3 python3-pip \
&& pip3 install --break-system-packages --no-cache-dir "brother_ql==0.9.4" \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+6 -26
View File
@@ -185,33 +185,13 @@ obvious across a room. It only works on a DK-22251 roll — on any other roll th
it as grey. Two-colour printing is also far slower than black alone (Brother rate it at roughly it as grey. Two-colour printing is also far slower than black alone (Brother rate it at roughly
15 labels a minute against 110), which is irrelevant for one badge at a time but worth knowing. 15 labels a minute against 110), which is irrelevant for one badge at a time but worth knowing.
### Printing from the server **Driver setup on the kiosk.** Set the QL-820NWB as the default printer for the browser running
the kiosk, choose the matching paper size in the driver, and set margins to none and scaling to
100%. Then use **Preview badge** in the console and print one before committing a roll — the
preview renders at the exact millimetre size the printer will receive.
Set the printer's IP address under **Sites → Edit → Printer** and tick *Print from the server*. The QL-820NWB has Ethernet and Wi-Fi, so it does not need to hang off the kiosk tablet. Install
The server then renders the badge itself and pushes it to the printer over the network, so: it as a network printer on whichever device drives the kiosk browser.
- a kiosk tablet needs no printer driver, no default printer and no print dialog
- adding a second kiosk means plugging in a tablet, nothing else
- the badge prints automatically the moment someone completes their sign in
The QL-820NWB has Ethernet and Wi-Fi, so it lives on the network rather than tethered to a
tablet. Give it a **fixed IP** — a DHCP lease change would silently stop badges printing.
Rendering happens at 300 dpi and 696 dots across, which is the printer's fixed head width on a
62 mm roll. **Bitmap preview** on the site card shows the exact image that will be sent, and
**Test print** sends a sample badge. Use both before committing a roll.
**Rotation.** At 0° the badge is laid out across the 62 mm width and runs down the label. At 90°
it is laid out along the length and turned before printing, which is what you want when the label
hangs from its short edge. Set it per site and check the bitmap preview — the two look very
different and only one will suit how you hang them.
**If the printer cannot be reached**, sign in still completes. The kiosk falls back to its own
browser print dialog, and the failure is shown against the site in **Admin → Sites** with the
reason. Admins can reprint any badge from the **On site** list.
Leave *Print from the server* off and the kiosk prints through the browser as before: set the
QL-820NWB as the browser's default printer, margins to none, scaling 100%.
## WWCC and VIT expiry warnings ## WWCC and VIT expiry warnings
-1
View File
@@ -13,7 +13,6 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^1.0.8",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "express": "^4.21.1",
-81
View File
@@ -184,7 +184,6 @@ async function loadOnsite() {
<td class="mono">${stamp(v.signedInAt)}</td> <td class="mono">${stamp(v.signedInAt)}</td>
<td class="actions"> <td class="actions">
<button class="ghost" data-badge="${v.id}">Badge</button> <button class="ghost" data-badge="${v.id}">Badge</button>
<button class="ghost" data-print="${v.id}">Print</button>
<button class="ghost" data-signout="${v.id}">Sign out</button> <button class="ghost" data-signout="${v.id}">Sign out</button>
</td> </td>
</tr>` </tr>`
@@ -202,19 +201,6 @@ async function loadOnsite() {
$$('[data-badge]').forEach((btn) => $$('[data-badge]').forEach((btn) =>
btn.addEventListener('click', () => window.open(`/admin/api/badge/${btn.dataset.badge}`, '_blank')) btn.addEventListener('click', () => window.open(`/admin/api/badge/${btn.dataset.badge}`, '_blank'))
); );
$$('[data-print]').forEach((btn) =>
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await api(`/visits/${btn.dataset.print}/print`, { method: 'POST' });
toast('Sent to the printer.');
} catch (err) {
toast(err.message, true);
} finally {
btn.disabled = false;
}
})
);
} }
$('#refresh-onsite').addEventListener('click', () => loadOnsite()); $('#refresh-onsite').addEventListener('click', () => loadOnsite());
@@ -687,8 +673,6 @@ async function loadSites() {
<div class="actions"> <div class="actions">
<button class="ghost" data-edit-site="${s.id}">Edit</button> <button class="ghost" data-edit-site="${s.id}">Edit</button>
<button class="ghost" data-preview-badge="${s.id}">Preview badge</button> <button class="ghost" data-preview-badge="${s.id}">Preview badge</button>
<button class="ghost" data-bitmap="${s.id}">Bitmap preview</button>
${s.printer.enabled ? `<button class="ghost" data-test-print="${s.id}">Test print</button>` : ''}
</div> </div>
</div> </div>
<dl class="site-meta"> <dl class="site-meta">
@@ -699,20 +683,6 @@ async function loadSites() {
: 'Off' : 'Off'
}</dd> }</dd>
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''} ${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''}
<dt>Printer</dt>
<dd>${
s.printer.enabled && s.printer.host
? `${esc(s.printer.model)} at ${esc(s.printer.host)}:${s.printer.port}${
s.printer.rotate ? `, rotated ${s.printer.rotate}°` : ''
}${
s.printerStatus
? s.printerStatus.ok
? ` <span class="pill">last print ok, ${stamp(s.printerStatus.at)}</span>`
: ` <span class="pill bad">${esc(s.printerStatus.message)}</span>`
: ''
}`
: 'Printed by the kiosk browser'
}</dd>
<dt>Kiosk branding</dt> <dt>Kiosk branding</dt>
<dd> <dd>
${s.branding.hasBanner ? `Banner set, ${s.branding.bannerAlign === 'center' ? 'centred' : 'left'}` : 'No banner'} · ${s.branding.hasBanner ? `Banner set, ${s.branding.bannerAlign === 'center' ? 'centred' : 'left'}` : 'No banner'} ·
@@ -735,27 +705,6 @@ async function loadSites() {
window.open(`/admin/api/sites/${btn.dataset.previewBadge}/badge-preview`, '_blank') window.open(`/admin/api/sites/${btn.dataset.previewBadge}/badge-preview`, '_blank')
) )
); );
$$('[data-bitmap]').forEach((btn) =>
btn.addEventListener('click', () =>
window.open(`/admin/api/sites/${btn.dataset.bitmap}/badge-bitmap`, '_blank')
)
);
$$('[data-test-print]').forEach((btn) =>
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = 'Printing…';
try {
await api(`/sites/${btn.dataset.testPrint}/test-print`, { method: 'POST' });
toast('Sent to the printer.');
} catch (err) {
toast(err.message, true);
} finally {
btn.disabled = false;
btn.textContent = 'Test print';
loadSites();
}
})
);
} }
/** /**
@@ -801,29 +750,6 @@ 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> <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 <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> roll it prints as grey. Two-colour printing is also much slower than black alone.</p>
<h4 class="modal-section">Printer</h4>
<label class="inline"><input type="checkbox" name="printerEnabled" ${site.printer.enabled ? 'checked' : ''}> Print from the server, straight to a network printer</label>
<div class="modal-row">
${field('Printer IP address', 'printerHost', site.printer.host, 'text')}
${field('Port', 'printerPort', site.printer.port, 'number')}
</div>
<div class="modal-row">
${field('Model', 'printerModel', site.printer.model)}
<label class="modal-field"><span>Rotation</span>
<select name="printerRotate">
${[0, 90, 180, 270]
.map(
(deg) =>
`<option value="${deg}" ${Number(site.printer.rotate) === deg ? 'selected' : ''}>${deg}°</option>`
)
.join('')}
</select></label>
</div>
<p class="hint">With this on, the kiosk does not print at all — the server sends the badge
to the printer over the network, so a tablet needs no driver and no default printer. At 90°
the badge is laid out along the length of the label and turned, which reads correctly when
the label hangs from its short edge. Check it with <strong>Bitmap preview</strong> before
using a roll.</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> <h4 class="modal-section">Kiosk branding</h4>
<div class="banner-editor"> <div class="banner-editor">
@@ -877,13 +803,6 @@ function openSiteModal(site) {
accent: form.has('accent'), accent: form.has('accent'),
note: data.note, note: data.note,
}, },
printer: {
enabled: form.has('printerEnabled'),
host: data.printerHost,
port: Number(data.printerPort) || 9100,
model: data.printerModel,
rotate: Number(data.printerRotate) || 0,
},
branding: { branding: {
brand: data.brand || null, brand: data.brand || null,
signout: data.signout || null, signout: data.signout || null,
+1 -4
View File
@@ -382,12 +382,9 @@ async function submitSignIn() {
}); });
stopCamera(); stopCamera();
$('#done-in-message').textContent = `You're all set, ${result.firstName}.`; $('#done-in-message').textContent = `You're all set, ${result.firstName}.`;
const printing = result.serverPrinted || result.badgeUrl; $('#done-in-detail').textContent = result.badgeUrl
$('#done-in-detail').textContent = printing
? `${result.hostName} has been recorded as your host. Your badge is printing — please wear it, and sign out when you leave.` ? `${result.hostName} has been recorded as your host. Your badge is printing — please wear it, and sign out when you leave.`
: `${result.hostName} has been recorded as your host. Please sign out when you leave.`; : `${result.hostName} has been recorded as your host. Please sign out when you leave.`;
// With server printing the badge is already coming out of the label printer,
// so the kiosk neither prints nor offers to.
lastBadgeUrl = result.badgeUrl; lastBadgeUrl = result.badgeUrl;
$('#reprint-badge').hidden = !result.badgeUrl; $('#reprint-badge').hidden = !result.badgeUrl;
if (result.badgeUrl) printBadge(result.badgeUrl); if (result.badgeUrl) printBadge(result.badgeUrl);
+20 -66
View File
@@ -1,88 +1,42 @@
# Pushes this folder to the Gitea repo. # Pushes this folder into the Gitea repo created for it.
# # Run from PowerShell, inside the visitor-signin folder:
# Run from PowerShell inside the visitor-signin folder:
# .\push-to-gitea.ps1 # .\push-to-gitea.ps1
# If Windows blocks it: # If Windows blocks it: powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1
# powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1
$ErrorActionPreference = 'Stop'
$Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git' $Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git'
# git reports failure through its exit code, not as a PowerShell error, so every if (-not (Test-Path 'package.json')) {
# call has to be checked. Without this the script reports success after a Write-Error 'Run this from inside the visitor-signin folder.'
# rejected push, which is exactly what it used to do.
function Invoke-Git {
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments)
& git @Arguments
return $LASTEXITCODE
}
function Fail($message) {
Write-Host ''
Write-Host $message -ForegroundColor Red
exit 1 exit 1
} }
if (-not (Test-Path 'package.json')) { Fail 'Run this from inside the visitor-signin folder.' }
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Fail 'Git is not installed or not on PATH. Get it from https://git-scm.com/download/win' Write-Error 'Git is not installed or not on PATH. Install it from https://git-scm.com/download/win'
exit 1
} }
# Keeps line endings sane between Windows and the Ubuntu docker host. # Keep line endings sane between Windows and the Ubuntu docker host.
git config --global core.autocrlf input | Out-Null git config --global core.autocrlf input | Out-Null
if (-not (Test-Path '.git')) { if (-not (Test-Path '.git')) {
Write-Host 'Setting up a new local repository...' git init -b main
if ((Invoke-Git init -b main) -ne 0) { Fail 'git init failed.' } } else {
Write-Host 'This folder is already a git repo, adding a commit to it.'
} }
if ((git remote) -match '^origin$') { git add .
$message = 'Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA'
git commit -m $message
if (git remote | Select-String -Quiet '^origin$') {
git remote set-url origin $Remote git remote set-url origin $Remote
} else { } else {
git remote add origin $Remote git remote add origin $Remote
} }
# ---------------------------------------------------------------- commit git push -u origin main
git add -A
$pending = git status --porcelain
if ($pending) {
$message = Read-Host 'Describe this change (press enter for a dated default)'
if (-not $message) { $message = "Update $(Get-Date -Format 'yyyy-MM-dd HH:mm')" }
if ((Invoke-Git commit -m $message) -ne 0) { Fail 'git commit failed.' }
Write-Host 'Committed.' -ForegroundColor Green
} else {
Write-Host 'No file changes to commit. Checking for anything unpushed...' -ForegroundColor Yellow
}
# ------------------------------------------------------- catch up, then push
Write-Host 'Checking what is on the server...'
if ((Invoke-Git fetch origin) -ne 0) {
Fail 'Could not reach Gitea. Check the network and your sign in details.'
}
if (git ls-remote --heads origin main) {
$behind = (git rev-list --count HEAD..origin/main 2>$null)
if ($behind -and [int]$behind -gt 0) {
Write-Host "The server has $behind commit(s) this folder does not. Replaying your work on top..."
if ((Invoke-Git pull --rebase origin main) -ne 0) {
Write-Host ''
Write-Host 'The two histories could not be joined automatically.' -ForegroundColor Red
Write-Host ''
Write-Host 'See what is on the server that you do not have:' -ForegroundColor Yellow
Write-Host ' git log --oneline HEAD..origin/main'
Write-Host ''
Write-Host 'If that is nothing you need, and this folder is the good copy:' -ForegroundColor Yellow
Write-Host ' git push --force-with-lease origin main'
exit 1
}
}
}
if ((Invoke-Git push -u origin main) -ne 0) {
Fail 'The push was rejected. Read the message above. Nothing was sent.'
}
Write-Host '' Write-Host ''
Write-Host 'Pushed successfully.' -ForegroundColor Green Write-Host 'Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin' -ForegroundColor Green
Write-Host 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin' Write-Host 'Sign in with your Gitea username and password, or a token as the password.'
+12 -50
View File
@@ -1,58 +1,20 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Pushes this folder to the Gitea repo. Run from inside the visitor-signin folder. # Pushes this folder into the (empty) Gitea repo created for it.
set -uo pipefail # Run once from inside the extracted visitor-signin folder: ./push-to-gitea.sh
set -euo pipefail
REMOTE="https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git" REMOTE="https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git"
fail() { printf '\n%s\n' "$1" >&2; exit 1; } if [ ! -f package.json ]; then
echo "Run this from inside the visitor-signin folder." >&2
[ -f package.json ] || fail "Run this from inside the visitor-signin folder." exit 1
command -v git >/dev/null || fail "Git is not installed."
git config --global core.autocrlf input >/dev/null 2>&1 || true
[ -d .git ] || git init -b main || fail "git init failed."
if git remote | grep -qx origin; then
git remote set-url origin "$REMOTE"
else
git remote add origin "$REMOTE"
fi fi
git add -A git init -b main
if [ -n "$(git status --porcelain)" ]; then git add .
read -r -p "Describe this change (enter for a dated default): " MSG git commit -m "Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA"
[ -n "$MSG" ] || MSG="Update $(date '+%Y-%m-%d %H:%M')" git remote add origin "$REMOTE" 2>/dev/null || git remote set-url origin "$REMOTE"
git commit -m "$MSG" || fail "git commit failed." git push -u origin main
echo "Committed."
else
echo "No file changes to commit. Checking for anything unpushed..."
fi
git fetch origin || fail "Could not reach Gitea."
if git ls-remote --heads origin main | grep -q main; then
BEHIND=$(git rev-list --count HEAD..origin/main 2>/dev/null || echo 0)
if [ "$BEHIND" -gt 0 ]; then
echo "The server has $BEHIND commit(s) this folder does not. Replaying your work on top..."
if ! git pull --rebase origin main; then
cat >&2 <<'MSG'
The two histories could not be joined automatically.
See what is on the server that you do not have:
git log --oneline HEAD..origin/main
If that is nothing you need, and this folder is the good copy:
git push --force-with-lease origin main
MSG
exit 1
fi
fi
fi
git push -u origin main || fail "The push was rejected. Read the message above. Nothing was sent."
echo echo
echo "Pushed successfully." echo "Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
echo "https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
-9
View File
@@ -46,15 +46,6 @@ export const config = {
require2fa: bool(process.env.ADMIN_REQUIRE_2FA, true), require2fa: bool(process.env.ADMIN_REQUIRE_2FA, true),
}, },
printing: {
// brother_ql drives the label printer over the network. Overridable so a
// wrapper or a different binary can be swapped in.
command: process.env.PRINT_COMMAND || 'brother_ql',
timeoutMs: int(process.env.PRINT_TIMEOUT_MS, 15000),
// How long a sign in waits for the badge before falling back to the browser.
signInWaitMs: int(process.env.PRINT_SIGNIN_WAIT_MS, 9000),
},
// Admins are warned this many days before a WWCC or VIT expires. // Admins are warned this many days before a WWCC or VIT expires.
expiryWarningDays: int(process.env.EXPIRY_WARNING_DAYS, 28), expiryWarningDays: int(process.env.EXPIRY_WARNING_DAYS, 28),
-11
View File
@@ -22,11 +22,6 @@ CREATE TABLE IF NOT EXISTS sites (
badge_show_photo INTEGER NOT NULL DEFAULT 1, badge_show_photo INTEGER NOT NULL DEFAULT 1,
badge_accent INTEGER NOT NULL DEFAULT 0, badge_accent INTEGER NOT NULL DEFAULT 0,
badge_note TEXT, badge_note TEXT,
printer_enabled INTEGER NOT NULL DEFAULT 0,
printer_host TEXT,
printer_port INTEGER NOT NULL DEFAULT 9100,
printer_model TEXT NOT NULL DEFAULT 'QL-820NWB',
printer_rotate INTEGER NOT NULL DEFAULT 0,
banner_path TEXT, banner_path TEXT,
banner_height INTEGER NOT NULL DEFAULT 64, banner_height INTEGER NOT NULL DEFAULT 64,
banner_align TEXT NOT NULL DEFAULT 'left', banner_align TEXT NOT NULL DEFAULT 'left',
@@ -177,12 +172,6 @@ addColumn('sites', 'colour_page', 'TEXT');
addColumn('sites', 'colour_text', 'TEXT'); addColumn('sites', 'colour_text', 'TEXT');
// Optional "who are you from", handy for contractors and visiting staff. // Optional "who are you from", handy for contractors and visiting staff.
addColumn('visits', 'company', 'TEXT'); addColumn('visits', 'company', 'TEXT');
// Server-side printing, so a kiosk needs no printer driver of its own.
addColumn('sites', 'printer_enabled', 'INTEGER NOT NULL DEFAULT 0');
addColumn('sites', 'printer_host', 'TEXT');
addColumn('sites', 'printer_port', 'INTEGER NOT NULL DEFAULT 9100');
addColumn('sites', 'printer_model', "TEXT NOT NULL DEFAULT 'QL-820NWB'");
addColumn('sites', 'printer_rotate', 'INTEGER NOT NULL DEFAULT 0');
addColumn('frequent_visitors', 'company', 'TEXT'); addColumn('frequent_visitors', 'company', '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_visits_site ON visits(site_id, signed_out_at)');
-360
View File
@@ -1,360 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas';
import config from './config.js';
import { photoAbsolutePath } from './photos.js';
/**
* Printing happens on the server, not in the kiosk browser.
*
* The badge is drawn to a bitmap here and pushed straight to the printer over the
* network, so the tablet at the door needs no printer driver, no default printer
* and no print dialog — and a second kiosk can be added without configuring
* anything on it.
*
* The QL-820NWB prints 696 dots across a 62 mm roll at 300 dpi. That figure is
* fixed by the printer, so the bitmap is always 696 wide however the badge is
* laid out; rotation is applied to the finished image, not to the layout.
*/
const DPI = 300;
const DOTS_ACROSS_62MM = 696;
const FONT = 'Liberation Sans, DejaVu Sans, Arial, sans-serif';
const mm = (value) => Math.round((value / 25.4) * DPI);
/** Per-site outcome of the last print, surfaced in the admin console. */
const lastResult = new Map();
export function printerStatus(siteId) {
return lastResult.get(Number(siteId)) || null;
}
function note(siteId, ok, message) {
lastResult.set(Number(siteId), { ok, message, at: new Date().toISOString() });
}
export function isConfigured(site) {
return Boolean(site?.printer_enabled && site?.printer_host);
}
/** brother_ql's label id. The two-colour roll is a different label to the plain one. */
export function labelFor(site) {
return site?.badge_accent ? '62red' : '62';
}
/* ------------------------------------------------------------ rendering */
function wrapText(ctx, text, maxWidth, maxLines) {
const words = String(text || '').split(/\s+/).filter(Boolean);
const lines = [];
let line = '';
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (ctx.measureText(candidate).width <= maxWidth || !line) {
line = candidate;
} else {
lines.push(line);
line = word;
if (lines.length === maxLines - 1) break;
}
}
if (line) lines.push(line);
return lines.slice(0, maxLines);
}
/**
* Draws the badge at its designed size in dots. Mirrors the browser badge so the
* preview and the printed label agree.
*/
async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY = null) {
const unit = Math.min(widthDots, heightDots);
const pad = Math.round(unit * 0.07);
const black = '#000000';
const red = accent ? '#ff0000' : '#000000';
if (startY !== null) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, widthDots, heightDots);
}
const portrait = heightDots >= widthDots * 1.2;
const nameSize = Math.max(mm(3.2), Math.round(unit * (portrait ? 0.105 : 0.115)));
const bodySize = Math.max(mm(2.0), Math.round(unit * (portrait ? 0.055 : 0.062)));
let photo = null;
const abs = site.badge_show_photo ? photoAbsolutePath(visit.photo_path) : null;
if (abs) {
try {
photo = await loadImage(abs);
} catch {
photo = null;
}
}
const photoSize = photo ? Math.round(unit * (portrait ? 0.52 : 0.5)) : 0;
let cursorY = startY === null ? pad : startY;
let textLeft = pad;
let textWidth = widthDots - pad * 2;
const paint = startY !== null;
if (photo) {
if (portrait) {
const x = Math.round((widthDots - photoSize) / 2);
if (paint) ctx.drawImage(photo, x, cursorY, photoSize, photoSize);
if (paint) {
ctx.strokeStyle = black;
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
ctx.strokeRect(x, cursorY, photoSize, photoSize);
}
cursorY += photoSize + Math.round(unit * 0.05);
} else {
const y = Math.round((heightDots - photoSize) / 2);
if (paint) {
ctx.drawImage(photo, pad, y, photoSize, photoSize);
ctx.strokeStyle = black;
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
ctx.strokeRect(pad, y, photoSize, photoSize);
}
textLeft = pad + photoSize + Math.round(unit * 0.05);
textWidth = widthDots - textLeft - pad;
}
}
ctx.textBaseline = 'top';
ctx.textAlign = portrait ? 'center' : 'left';
const centreX = portrait ? widthDots / 2 : textLeft;
// Site name, with a rule under it.
ctx.fillStyle = red;
ctx.font = `${Math.round(bodySize * 0.8)}px ${FONT}`;
if (paint) ctx.fillText(`${site.name.toUpperCase()} · VISITOR`, centreX, cursorY, textWidth);
cursorY += Math.round(bodySize * 0.8 * 1.3);
if (paint) ctx.fillRect(textLeft, cursorY, textWidth, Math.max(2, Math.round(mm(0.35))));
cursorY += Math.round(unit * 0.04);
// Name, wrapped to at most two lines.
ctx.fillStyle = black;
ctx.font = `bold ${nameSize}px ${FONT}`;
const nameLines = wrapText(ctx, `${visit.first_name} ${visit.last_name}`, textWidth, 2);
for (const line of nameLines) {
if (paint) ctx.fillText(line, centreX, cursorY, textWidth);
cursorY += Math.round(nameSize * 1.05);
}
cursorY += Math.round(unit * 0.04);
// Detail rows.
const timeIn = new Date(visit.signed_in_at);
const rows = [
`Visiting ${visit.host_name}`,
`In at ${timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })} on ${timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' })}`,
];
ctx.font = `${bodySize}px ${FONT}`;
ctx.fillStyle = black;
for (const row of rows) {
for (const line of wrapText(ctx, row, textWidth, 2)) {
if (paint) ctx.fillText(line, centreX, cursorY, textWidth);
cursorY += Math.round(bodySize * 1.3);
}
}
// Check status: boxed and in the accent colour when they hold nothing.
if (visit.check_type === 'NONE') {
const label = 'No WWCC / VIT';
ctx.font = `bold ${Math.round(bodySize * 0.95)}px ${FONT}`;
const w = ctx.measureText(label).width + bodySize;
const x = portrait ? Math.round((widthDots - w) / 2) : textLeft;
const h = Math.round(bodySize * 1.5);
if (paint) {
ctx.strokeStyle = red;
ctx.lineWidth = Math.max(2, Math.round(mm(0.35)));
ctx.strokeRect(x, cursorY, w, h);
ctx.fillStyle = red;
ctx.textAlign = 'center';
ctx.fillText(label, x + w / 2, cursorY + Math.round(bodySize * 0.25));
ctx.textAlign = portrait ? 'center' : 'left';
}
cursorY += h + Math.round(bodySize * 0.3);
} else {
ctx.fillStyle = black;
ctx.font = `${bodySize}px ${FONT}`;
if (paint) {
ctx.fillText(`${visit.check_type} ${visit.check_number || ''}`.trim(), centreX, cursorY, textWidth);
}
cursorY += Math.round(bodySize * 1.3);
}
if (site.badge_note) {
ctx.fillStyle = black;
ctx.font = `${Math.round(bodySize * 0.85)}px ${FONT}`;
for (const line of wrapText(ctx, site.badge_note, textWidth, 2)) {
if (paint) ctx.fillText(line, centreX, cursorY, textWidth);
cursorY += Math.round(bodySize * 1.1);
}
}
return cursorY - (startY === null ? pad : startY);
}
/**
* Produces the PNG that gets sent to the printer.
*
* The bitmap is always 696 dots across, because that is the printer's fixed head
* width on a 62 mm roll. With rotation the badge is laid out along the length of
* the label instead and the finished image is turned, so the content still lands
* within those 696 dots.
*/
export async function renderBadgePng(visit, site) {
const rotate = Number(site.printer_rotate) || 0;
const lengthMm = Number(site.badge_height_mm) || 90;
const turned = rotate === 90 || rotate === 270;
const acrossDots = DOTS_ACROSS_62MM;
const alongDots = mm(lengthMm);
// Design canvas: swapped when the badge is laid out along the label.
const designW = turned ? alongDots : acrossDots;
const designH = turned ? acrossDots : alongDots;
const design = createCanvas(designW, designH);
const ctx = design.getContext('2d');
const accent = Boolean(site.badge_accent);
// Measure first, then draw the block centred down the label. Without this the
// content hugs the top and leaves a wide blank strip at the bottom of every badge.
const used = await drawBadge(ctx, designW, designH, visit, site, accent, null);
const pad = Math.round(Math.min(designW, designH) * 0.07);
const startY = Math.max(pad, Math.round((designH - used) / 2));
await drawBadge(ctx, designW, designH, visit, site, accent, startY);
if (!rotate) return design.toBuffer('image/png');
const out = createCanvas(turned ? acrossDots : designW, turned ? alongDots : designH);
const outCtx = out.getContext('2d');
outCtx.fillStyle = '#ffffff';
outCtx.fillRect(0, 0, out.width, out.height);
outCtx.translate(out.width / 2, out.height / 2);
outCtx.rotate((rotate * Math.PI) / 180);
outCtx.drawImage(design, -designW / 2, -designH / 2);
return out.toBuffer('image/png');
}
/* -------------------------------------------------------------- sending */
/**
* brother_ql reports failures as a Python traceback. Nobody at a front desk can
* act on that, so the useful last line is pulled out and the common network
* failures are rewritten as something with a next step.
*/
function explainPrintError(output, host) {
const lines = String(output || '')
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !/^deprecation warning/i.test(l));
const last = lines[lines.length - 1] || '';
if (/Connection refused/i.test(last)) {
return `${host} refused the connection. Check the printer is switched on and that port 9100 is the right one.`;
}
if (/timed out|timeout/i.test(last)) {
return `${host} did not answer. Check the IP address and that the printer is on the same network as the server.`;
}
if (/No route to host|Network is unreachable/i.test(last)) {
return `${host} cannot be reached from the server. Check the address and any firewall between them.`;
}
if (/Name or service not known|getaddrinfo/i.test(last)) {
return `${host} could not be resolved. Use the printer's IP address rather than a name.`;
}
if (/Unknown label|label/i.test(last) && /identifier/i.test(last)) {
return 'The printer rejected the label size. Check the roll loaded matches the badge settings.';
}
return last || 'The printer did not accept the job.';
}
function runBrotherQl(args, timeoutMs) {
return new Promise((resolve, reject) => {
execFile(
config.printing.command,
args,
{ timeout: timeoutMs, env: { ...process.env, BROTHER_QL_PRINTER: '', BROTHER_QL_MODEL: '' } },
(err, stdout, stderr) => {
const output = `${stdout || ''}${stderr || ''}`.trim();
if (err) {
if (err.code === 'ENOENT') {
return reject(
new Error(
`${config.printing.command} is not installed in the container. Rebuild the image, or set PRINT_COMMAND.`
)
);
}
if (err.killed) return reject(new Error('The printer did not respond in time.'));
return reject(new Error(output || err.message));
}
resolve(output);
}
);
});
}
/**
* Renders and prints one badge. Resolves with a short description on success and
* rejects with something an admin can act on.
*/
export async function printBadge(visit, site) {
if (!isConfigured(site)) throw new Error('Server printing is not turned on for this site.');
const png = await renderBadgePng(visit, site);
const file = path.join(os.tmpdir(), `badge-${crypto.randomBytes(6).toString('hex')}.png`);
fs.writeFileSync(file, png);
const port = Number(site.printer_port) || 9100;
const target = `tcp://${site.printer_host}:${port}`;
try {
await runBrotherQl(
[
'--backend', 'network',
'--model', site.printer_model || 'QL-820NWB',
'--printer', target,
'print',
'--label', labelFor(site),
file,
],
config.printing.timeoutMs
);
note(site.id, true, `Printed to ${site.printer_host}`);
return { ok: true, target };
} catch (err) {
const friendly = explainPrintError(err.message, site.printer_host);
note(site.id, false, friendly);
throw new Error(friendly);
} finally {
fs.rm(file, { force: true }, () => {});
}
}
/** A sample badge, for checking the printer and the layout without a real visit. */
export function sampleVisit(site) {
return {
id: 0,
first_name: 'Sample',
last_name: 'Visitor',
host_name: 'Jess Rogerson',
check_type: 'NONE',
check_number: null,
photo_path: null,
signed_in_at: new Date().toISOString(),
site_name: site.name,
};
}
export function available() {
return new Promise((resolve) => {
execFile(config.printing.command, ['--version'], (err) => resolve(!err));
});
}
+3 -74
View File
@@ -8,7 +8,6 @@ import { decryptPin, encryptPin, generatePin, pinLookup } from '../pins.js';
import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js'; import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js';
import * as sheets from '../sheets.js'; import * as sheets from '../sheets.js';
import * as tls from '../tls.js'; import * as tls from '../tls.js';
import * as printer from '../printer.js';
import * as users from '../users.js'; import * as users from '../users.js';
import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js'; import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js';
import { import {
@@ -364,12 +363,7 @@ router.post('/users/:id/reset-2fa', requireOwner, (req, res) => {
router.get('/sites', (req, res) => { router.get('/sites', (req, res) => {
const scope = scopedSiteId(req); const scope = scopedSiteId(req);
const rows = listSites().filter((s) => !scope || s.id === scope); const rows = listSites().filter((s) => !scope || s.id === scope);
res.json( res.json(rows.map(shapeSite));
rows.map((row) => ({
...shapeSite(row),
printerStatus: printer.printerStatus(row.id),
}))
);
}); });
router.post('/sites', requireOwner, (req, res) => { router.post('/sites', requireOwner, (req, res) => {
@@ -391,13 +385,11 @@ router.patch('/sites/:id', (req, res) => {
const badge = req.body?.badge || {}; const badge = req.body?.badge || {};
const branding = req.body?.branding || {}; const branding = req.body?.branding || {};
const printerCfg = req.body?.printer || {};
db.prepare( db.prepare(
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?, `UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?, badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?, colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?, banner_height = ?, banner_align = ? WHERE id = ?`
printer_port = ?, printer_model = ?, printer_rotate = ? WHERE id = ?`
).run( ).run(
clean(req.body?.name ?? site.name, 100) || site.name, clean(req.body?.name ?? site.name, 100) || site.name,
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug, req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
@@ -423,16 +415,7 @@ router.patch('/sites/:id', (req, res) => {
: site.banner_height, : site.banner_height,
branding.bannerAlign !== undefined branding.bannerAlign !== undefined
? normaliseAlign(branding.bannerAlign, site.banner_align) ? normaliseAlign(branding.bannerAlign, site.banner_align)
: 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
, site.id); , site.id);
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id))); res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
@@ -474,60 +457,6 @@ router.get('/sites/:id/banner', (req, res) => {
res.sendFile(abs); 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) {
res.status(400).json({ error: err.message });
}
});
/** 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) => { router.get('/sites/:id/badge-preview', (req, res) => {
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).send('Not found.'); if (!site) return res.status(404).send('Not found.');
+2 -27
View File
@@ -7,7 +7,6 @@ import { mirror } from '../sheets.js';
import { verifyPin } from '../pins.js'; import { verifyPin } from '../pins.js';
import { listSites, resolveSite, badgeHtml } from '../sites.js'; import { listSites, resolveSite, badgeHtml } from '../sites.js';
import { themeFor, bannerAbsolutePath } from '../branding.js'; import { themeFor, bannerAbsolutePath } from '../branding.js';
import * as printer from '../printer.js';
import fs from 'node:fs'; import fs from 'node:fs';
import { import {
clean, clean,
@@ -110,15 +109,7 @@ function openVisitFor(siteId, lastName, phone, email) {
/* -------------------------------------------------------------- sign in */ /* -------------------------------------------------------------- sign in */
/** Rejects rather than hanging the front desk on a printer that never answers. */ router.post('/signin', signInLimiter, (req, res) => {
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Printing timed out.')), ms)),
]);
}
router.post('/signin', signInLimiter, async (req, res) => {
try { try {
const body = req.body || {}; const body = req.body || {};
const site = siteFrom(req); const site = siteFrom(req);
@@ -224,20 +215,6 @@ router.post('/signin', signInLimiter, async (req, res) => {
mirror(); mirror();
delete req.session.frequentVisitorId; delete req.session.frequentVisitorId;
// With a networked printer the server does the printing, so the tablet needs
// no driver and no default printer. It is awaited briefly rather than fired
// and forgotten: if the printer is unreachable the kiosk falls back to its
// own print dialog instead of the visitor walking off without a badge.
let serverPrinted = false;
if (site.badge_enabled && printer.isConfigured(site)) {
try {
await withTimeout(printer.printBadge(visit, site), config.printing.signInWaitMs);
serverPrinted = true;
} catch (err) {
console.error('[print] badge failed, kiosk will fall back:', err.message);
}
}
// Lets this kiosk session fetch the badge for the visit it just created. // Lets this kiosk session fetch the badge for the visit it just created.
req.session.badgeVisitId = visit.id; req.session.badgeVisitId = visit.id;
req.session.badgeIssuedAt = Date.now(); req.session.badgeIssuedAt = Date.now();
@@ -248,9 +225,7 @@ router.post('/signin', signInLimiter, async (req, res) => {
hostName: host.name, hostName: host.name,
signedInAt, signedInAt,
visitId: visit.id, visitId: visit.id,
serverPrinted, badgeUrl: site.badge_enabled ? `/api/badge/${visit.id}` : null,
// Only offered when the server did not already print it.
badgeUrl: site.badge_enabled && !serverPrinted ? `/api/badge/${visit.id}` : null,
}); });
} catch (err) { } catch (err) {
console.error('[signin]', err); console.error('[signin]', err);
-7
View File
@@ -59,13 +59,6 @@ export function shapeSite(site) {
text: site.colour_text, text: site.colour_text,
theme: themeFor(site), theme: themeFor(site),
}, },
printer: {
enabled: Boolean(site.printer_enabled),
host: site.printer_host,
port: site.printer_port || 9100,
model: site.printer_model || 'QL-820NWB',
rotate: site.printer_rotate || 0,
},
badge: { badge: {
enabled: Boolean(site.badge_enabled), enabled: Boolean(site.badge_enabled),
widthMm: site.badge_width_mm, widthMm: site.badge_width_mm,