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
24 changed files with 66 additions and 2074 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
+11 -127
View File
@@ -179,119 +179,19 @@ the widest roll it will accept — the console warns you if you enter anything w
| DK-11208 | 38 × 90 mm die-cut | Narrower; turn the photo off | | DK-11208 | 38 × 90 mm die-cut | Narrower; turn the photo off |
| DK-11209 | 29 × 62 mm die-cut | Name and host only | | DK-11209 | 29 × 62 mm die-cut | Name and host only |
**A DK-22251 must be set as the black/red roll even if nothing on the badge is red.** The printer
refuses a monochrome job on two-colour tape, saying *Black/Red on White paper is installed now.
Change it to Monochrome media.* The roll setting controls how the job is built, not just its
colour.
### Photos on the badge
A thermal printer has one bit per dot: every pixel is either burnt or not. A photo therefore has
to be reduced to pure black and white, and how that is done makes the difference between a
recognisable face and a few solid blobs.
Under **Sites → Edit → Badge printing**:
| Setting | What it does |
|---|---|
| Error diffusion | Scatters the rounding error into neighbouring dots, so mid tones survive as a pattern. The default, and much the best for faces. |
| Hard threshold | Every pixel darker than the cut becomes solid black. Crisp for line art, ruinous for photographs. |
| Leave it to the driver | Sends greyscale and lets `brother_ql` decide. |
| Threshold | Where the cut falls. Higher is darker. |
| Contrast | Applied before the reduction. Webcam photos are flat and flatten further at one bit, so a lift of 20 to 30 usually helps. |
**Preview the photo settings** renders the most recent real visitor photo at several settings side
by side, so the choice is made by eye. The halftoning happens in the server's renderer, not in the
printer driver, so the preview and the printed label are the same image.
**Tell it which roll is loaded.** *Roll loaded in the printer* under **Sites → Edit → Printer**
must match what is physically in the machine. A two-colour job sent to a plain roll is refused
outright: the printer shows **Wrong Roll Type** and nothing comes out. The setting is separate
from the red styling option on purpose — a design choice should not silently change the media
type the printer is told to expect.
**The red option.** Tick *Print the heading and the no-check warning in red* and the site name **The red option.** Tick *Print the heading and the no-check warning in red* and the site name
and the **No WWCC / VIT** box print red instead of black, which makes a visitor without a check and the **No WWCC / VIT** box print red instead of black, which makes a visitor without a check
obvious across a room. It needs the roll setting above to be the DK-22251; with a plain roll obvious across a room. It only works on a DK-22251 roll — on any other roll the printer renders
selected the badge is drawn in black instead and the console says so. 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.
**Label stock** at the top of the badge settings is a shortcut that fills in the size, the roll
type and the colour option. It is not itself a saved setting — the three fields it fills are what
get stored, which is why it can appear to "revert" when the same dimensions describe two rolls.
**Ask the printer what it has loaded.** This is the first thing to run when a job is refused:
```bash
docker compose exec visitor-signin node scripts/printer-status.mjs
```
It reports the media width, whether the roll is continuous or die-cut, any error the printer is
holding, and the roll id that matches.
**It does not work on every printer.** Many Brother network print servers are write-only on port
9100: they accept jobs but never answer a status request, even though the same printer reports
happily over USB. The QL-820NWB is one of them. When that happens, **Brother Status Monitor on a
PC with the printer installed is the thing to use** — it gives the exact reason a job was refused,
in plain words, which is more than anything on the server can tell you. `brother_ql`'s network
backend never reads the socket at all, so it reports success no matter what the printer does.
If the printer will not answer, work through the possibilities one at a time:
```bash
docker compose exec -it visitor-signin node scripts/print-probe.mjs
```
It sends one label per roll id and waits for you to say whether anything came out, then moves on.
The `-it` matters: it asks questions and needs a terminal.
**Diagnostics from the command line.** When the console is not enough:
```bash
docker compose exec visitor-signin node scripts/print-test.mjs # render and print
docker compose exec visitor-signin node scripts/print-test.mjs --dry # render only
docker compose exec visitor-signin node scripts/print-test.mjs --label 62x100
```
It prints the settings it is using, the exact `brother_ql` command, and the printer's full reply.
`--label` and `--rotate` override the saved settings for one run, so alternatives can be tried
without saving anything.
**Continuous versus die-cut matters.** `62` means a continuous roll cut to length; `62x100` means
pre-cut labels. Sending one id to the other kind of roll is refused as a wrong roll, and this is
the most common cause of that error after the two-colour setting.
**Diagnostics.** The site card has a **Diagnostics** button showing exactly what would be sent,
including the `brother_ql` command line, so it can be run by hand on the host. A failed test print
shows the printer's own words rather than a summary.
**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
@@ -351,25 +251,9 @@ HOST_PORT=8443
HTTPS_PUBLIC_PORT=8443 HTTPS_PUBLIC_PORT=8443
``` ```
An address that isn't listed produces a browser warning. An address that isn't listed produces a browser warning. Change the list and restart; the
certificate reissues itself automatically, and devices that already trust the authority accept
After changing anything in `.env`, bring the container back with **`docker compose up -d`**, not it without any further work.
`docker compose restart`. Restart reuses the running container along with the environment it
started with, so the edit appears to do nothing; `up -d` recreates it and picks the new values
up. Confirm with:
```bash
docker compose exec visitor-signin printenv HTTPS_HOSTNAMES
docker compose logs --tail=20 visitor-signin | grep tls
```
The log should say `renewing the server certificate: HTTPS_HOSTNAMES changed` and then list every
name it now covers. If it lists only `localhost`, `visitors.local` and a `172.x` address, the
variable never reached the container — those are the defaults plus the container's own docker
bridge address.
Reissuing does **not** touch the certificate authority, so devices that already trust it keep
working and no MDM profile needs redeploying.
The kiosk is then at `https://visitors.local:8443`, admin at `https://visitors.local:8443/admin`. The kiosk is then at `https://visitors.local:8443`, admin at `https://visitors.local:8443/admin`.
+1 -6
View File
@@ -7,17 +7,12 @@
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"version": "node scripts/version.mjs", "gen-secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\""
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
"print-test": "node scripts/print-test.mjs",
"printer-status": "node scripts/printer-status.mjs",
"print-probe": "node scripts/print-probe.mjs"
}, },
"engines": { "engines": {
"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",
-45
View File
@@ -400,48 +400,3 @@ tr.row-bad td { background: #fdf0f2; }
vertical-align: -2px; vertical-align: -2px;
margin-right: 2px; margin-right: 2px;
} }
pre.raw {
margin: 0 0 14px;
padding: 12px;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: 3px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
max-height: 320px;
overflow: auto;
}
/* The recommended download should not look identical to the two fallbacks. */
.sys-actions .primary-link {
border-color: var(--deep);
background: var(--deep);
color: #fff;
font-weight: 600;
}
/* --------------------------------------------------- photo halftoning */
.photo-tuning {
margin: 12px 0 16px;
padding: 14px;
border: 1px solid var(--rule);
border-radius: 3px;
background: var(--paper);
}
.photo-tuning input[type="range"] { width: 100%; }
.photo-tuning .modal-field span b { font-variant-numeric: tabular-nums; }
.photo-tuning .hint { margin: 10px 0; }
.photo-preview {
display: block;
width: 100%;
margin-top: 12px;
border: 1px solid var(--rule);
border-radius: 3px;
background: #fff;
}
+14 -230
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,9 +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>` : ''}
${s.printer.enabled ? `<button class="ghost" data-print-diag="${s.id}">Diagnostics</button>` : ''}
</div> </div>
</div> </div>
<dl class="site-meta"> <dl class="site-meta">
@@ -700,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.label === '62red' ? 'black and red roll' : 'black roll'
}${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'} ·
@@ -736,48 +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-print-diag]').forEach((btn) =>
btn.addEventListener('click', async () => {
const d = await api(`/sites/${btn.dataset.printDiag}/printer-diagnostics`);
openModal(
'Printer diagnostics',
`<pre class="raw">${esc(JSON.stringify(d, null, 2))}</pre>`,
null,
{ saveLabel: 'Close', hideCancel: true }
);
})
);
$$('[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) {
// Show the printer's own words as well as the summary, because a refusal
// usually names the reason and the summary cannot cover every case.
const raw = err.payload?.raw;
openModal(
'The printer refused the job',
`<p>${esc(err.message)}</p>
${err.payload?.command ? `<p class="hint">Command:</p><pre class="raw">${esc(err.payload.command)}</pre>` : ''}
${raw ? `<p class="hint">What the printer and brother_ql said:</p><pre class="raw">${esc(raw)}</pre>` : ''}`,
null,
{ saveLabel: 'Close', hideCancel: true }
);
} finally {
btn.disabled = false;
btn.textContent = 'Test print';
loadSites();
}
})
);
} }
/** /**
@@ -786,29 +713,16 @@ async function loadSites() {
* 62 mm is for a different printer. * 62 mm is for a different printer.
*/ */
const LABEL_PRESETS = [ const LABEL_PRESETS = [
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true, roll: '62' }, { id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true },
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true, roll: '62' }, { id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true },
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, roll: '62red', accent: true }, { id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, accent: true },
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false, roll: '62' }, { id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false },
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false, roll: '62' }, { id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false },
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false, roll: '62' }, { id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false },
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true, roll: '62' }, { id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true },
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true, roll: '62' }, { id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true },
]; ];
/**
* Which preset the saved settings correspond to. Dimensions alone are ambiguous —
* DK-22205 and DK-22251 are both 62 x 90 — so the roll type decides between them.
*/
function matchingPreset(site) {
return LABEL_PRESETS.find(
(p) =>
Number(site.badge.widthMm) === p.w &&
Number(site.badge.heightMm) === p.h &&
p.roll === (site.printer.label === '62red' ? '62red' : '62')
);
}
function openSiteModal(site) { function openSiteModal(site) {
openModal( openModal(
`Edit ${site.name}`, `Edit ${site.name}`,
@@ -822,70 +736,20 @@ function openSiteModal(site) {
<option value="">Custom size</option> <option value="">Custom size</option>
${LABEL_PRESETS.map( ${LABEL_PRESETS.map(
(p) => (p) =>
`<option value="${p.id}" ${matchingPreset(site)?.id === p.id ? 'selected' : ''}>${esc(p.label)}</option>` `<option value="${p.id}" ${
Number(site.badge.widthMm) === p.w && Number(site.badge.heightMm) === p.h ? 'selected' : ''
}>${esc(p.label)}</option>`
).join('')} ).join('')}
</select></label> </select></label>
<p class="hint">A shortcut that fills in the boxes below. The size, the roll type and the
colour option are what actually get saved.</p>
<div class="modal-row"> <div class="modal-row">
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')} ${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')} ${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
</div> </div>
<p class="hint" id="badge-warning" hidden></p> <p class="hint" id="badge-warning" hidden></p>
<label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label> <label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
<div class="photo-tuning">
<label class="modal-field"><span>Photo rendering</span>
<select name="photoMode" id="photo-mode">
<option value="dither" ${site.photo.mode === 'dither' ? 'selected' : ''}>Error diffusion — best for faces</option>
<option value="threshold" ${site.photo.mode === 'threshold' ? 'selected' : ''}>Hard threshold — crisp, loses detail</option>
<option value="none" ${site.photo.mode === 'none' ? 'selected' : ''}>Leave it to the printer driver</option>
</select></label>
<label class="modal-field"><span>Threshold <b id="photo-threshold-value">${site.photo.threshold}</b>%</span>
<input type="range" name="photoThreshold" id="photo-threshold" min="5" max="95" step="5" value="${site.photo.threshold}"></label>
<label class="modal-field"><span>Contrast <b id="photo-contrast-value">${site.photo.contrast}</b></span>
<input type="range" name="photoContrast" id="photo-contrast" min="-50" max="100" step="10" value="${site.photo.contrast}"></label>
<button type="button" class="ghost" id="photo-preview-btn">Preview the photo settings</button>
<p class="hint">The printer has one bit per dot, so a photo has to become pure black and
white. A hard threshold turns a face into solid blocks; error diffusion scatters the
rounding error into neighbouring dots and keeps the tones readable. Higher threshold means
darker. The preview shows the most recent real visitor photo.</p>
<img id="photo-preview" class="photo-preview" alt="" hidden>
</div>
<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" id="accent-note"></p> <p class="hint">Red needs a two-colour roll such as the Brother DK-22251. On any other
<p class="hint">Two-colour printing is 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>
<label class="modal-field"><span>Roll loaded in the printer</span>
<select name="printerLabel">
<option value="62" ${site.printer.label !== '62red' ? 'selected' : ''}>62 mm continuous, black only</option>
<option value="62red" ${site.printer.label === '62red' ? 'selected' : ''}>62 mm continuous, black and red (DK-22251)</option>
</select></label>
<p class="hint">This must match the roll actually in the machine, and it matters in both
directions. A two-colour job on a plain roll is refused as <em>Wrong Roll Type</em>; a
monochrome job on a DK-22251 is refused with <em>Black/Red on White paper is installed
now</em>. The DK-22251 needs the two-colour option even when the badge is entirely black.</p>
<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">
@@ -939,19 +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,
label: data.printerLabel,
},
photo: {
mode: data.photoMode,
threshold: Number(data.photoThreshold),
contrast: Number(data.photoContrast),
},
branding: { branding: {
brand: data.brand || null, brand: data.brand || null,
signout: data.signout || null, signout: data.signout || null,
@@ -1060,63 +911,6 @@ function wireBannerEditor() {
[pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast)); [pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast));
showContrast(); showContrast();
// Live readouts for the halftone sliders, and a preview on demand. The preview
// is not automatic: it re-renders a real photo five times and is not free.
const modeSel = $('#photo-mode');
const thr = $('#photo-threshold');
const con = $('#photo-contrast');
const refreshLabels = () => {
$('#photo-threshold-value').textContent = thr.value;
$('#photo-contrast-value').textContent = con.value;
const off = modeSel.value === 'none';
thr.disabled = off;
con.disabled = off;
};
[thr, con].forEach((el) => el?.addEventListener('input', refreshLabels));
modeSel?.addEventListener('change', refreshLabels);
refreshLabels();
$('#photo-preview-btn')?.addEventListener('click', () => {
const img = $('#photo-preview');
const params = new URLSearchParams({
mode: modeSel.value,
threshold: thr.value,
contrast: con.value,
t: Date.now(),
});
img.src = `/admin/api/sites/${site.id}/photo-preview?${params}`;
img.hidden = false;
});
// Red is only possible on the two-colour roll, so say so as the two settings change.
const accentBox = $('#badge-accent');
const rollSelect = $('#modal-form [name="printerLabel"]');
const accentNote = $('#accent-note');
const showAccentNote = () => {
if (!accentNote) return;
if (!accentBox?.checked) {
accentNote.hidden = false;
accentNote.className = 'hint';
accentNote.textContent = 'Everything prints black.';
return;
}
if (rollSelect?.value === '62red') {
accentNote.hidden = false;
accentNote.className = 'hint';
accentNote.textContent = 'The heading and the no-check warning will print red.';
} else {
accentNote.hidden = false;
accentNote.className = 'hint warn';
accentNote.textContent =
'The roll selected below cannot print red, so these will come out black. Load a DK-22251 and change the roll setting to use colour.';
}
};
accentBox?.addEventListener('change', showAccentNote);
rollSelect?.addEventListener('change', showAccentNote);
showAccentNote();
$('#banner-file').addEventListener('change', (event) => { $('#banner-file').addEventListener('change', (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) return; if (!file) return;
@@ -1171,14 +965,6 @@ function wireBadgePreset() {
height.value = preset.h; height.value = preset.h;
$('#badge-photo').checked = preset.photo; $('#badge-photo').checked = preset.photo;
$('#badge-accent').checked = Boolean(preset.accent); $('#badge-accent').checked = Boolean(preset.accent);
// The preset knows which roll it describes, so keep the two in step. Picking
// the DK-22251 preset while the roll stayed on "black only" is what makes the
// printer refuse the job.
const roll = $('#modal-form [name="printerLabel"]');
if (roll && preset.roll) {
roll.value = preset.roll;
roll.dispatchEvent(new Event('change'));
}
check(); check();
}); });
@@ -1487,9 +1273,7 @@ function renderTls(tls) {
itself before it lapses, and devices that trust the authority keep working without being itself before it lapses, and devices that trust the authority keep working without being
touched again.</p> touched again.</p>
<div class="sys-actions"> <div class="sys-actions">
<a class="ghost primary-link" href="/admin/api/tls/ca-bundle.zip" download>Download all certificates (.zip)</a> <a class="ghost" href="/admin/api/tls/ca.crt" download>Download the CA certificate</a>
<a class="ghost" href="/admin/api/tls/ca.crt" download>.crt only</a>
<a class="ghost" href="/admin/api/tls/ca.cer" download>.cer only</a>
<button class="ghost owner-only" id="renew-cert">Renew the server certificate</button> <button class="ghost owner-only" id="renew-cert">Renew the server certificate</button>
<button class="ghost danger owner-only" id="new-ca">Start a new authority</button> <button class="ghost danger owner-only" id="new-ca">Start a new authority</button>
</div>`; </div>`;
+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);
+19 -87
View File
@@ -1,110 +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 is called directly and $LASTEXITCODE checked straight afterwards. Wrapping if (-not (Test-Path 'package.json')) {
# it in a function does not work: a PowerShell function returns everything written Write-Error 'Run this from inside the visitor-signin folder.'
# to the output stream, so the caller receives git's console output as well as the
# exit code, and comparing that array against 0 reports failure every time.
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
} }
# 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
# git refuses to commit without an identity, and says so in a way that is easy to
# miss among its other output.
$who = git config user.email
if (-not $who) { $who = git config --global user.email }
if (-not $who) {
Write-Host 'Git does not know who you are. Set that once:' -ForegroundColor Yellow
Write-Host ' git config --global user.email "you@example.com"'
Write-Host ' git config --global user.name "Your Name"'
Fail 'Nothing was committed.'
}
if (-not (Test-Path '.git')) { if (-not (Test-Path '.git')) {
Write-Host 'Setting up a new local repository...'
git init -b main git init -b main
if ($LASTEXITCODE -ne 0) { Fail 'git init failed.' }
}
if ((git remote) -match '^origin$') { git remote set-url origin $Remote }
else { git remote add origin $Remote }
# Whatever branch is checked out, not a hard-coded one. Pushing 'main' while the
# work sits on 'deploy' reports "Everything up-to-date" and sends nothing.
$branch = (git rev-parse --abbrev-ref HEAD).Trim()
if (-not $branch -or $branch -eq 'HEAD') { Fail 'No branch is checked out here.' }
Write-Host "Branch: $branch" -ForegroundColor Cyan
# ------------------------------------------- an unfinished rebase blocks everything
$gitDir = git rev-parse --git-dir 2>$null
if ($gitDir) {
$stuck = @('rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD') |
Where-Object { Test-Path (Join-Path $gitDir $_) }
if ($stuck) {
Write-Host ''
Write-Host 'There is an unfinished rebase or merge here.' -ForegroundColor Red
Write-Host ' git rebase --abort throw it away, back to how things were'
Write-Host ' git status see which files need attention'
Write-Host ' git rebase --continue after fixing those files'
Fail 'Nothing was done.'
}
}
# ---------------------------------------------------------------- commit
git add -A
if (git status --porcelain) {
$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')" }
git commit -m $message
if ($LASTEXITCODE -ne 0) { Fail 'git commit failed. The message above says why.' }
Write-Host 'Committed.' -ForegroundColor Green
} else { } else {
Write-Host 'Nothing new to commit.' -ForegroundColor Yellow Write-Host 'This folder is already a git repo, adding a commit to it.'
} }
# ------------------------------------------------------- catch up, then push git add .
$message = 'Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA'
git commit -m $message
git fetch origin if (git remote | Select-String -Quiet '^origin$') {
if ($LASTEXITCODE -ne 0) { Fail 'Could not reach Gitea. Check the network and your sign in details.' } git remote set-url origin $Remote
if (git ls-remote --heads origin $branch) {
$behind = git rev-list --count "HEAD..origin/$branch" 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..."
git pull --rebase origin $branch
if ($LASTEXITCODE -ne 0) {
Write-Host ''
Write-Host 'The two histories could not be joined automatically.' -ForegroundColor Red
Write-Host " git log --oneline HEAD..origin/$branch what is on the server"
Write-Host " git push --force-with-lease origin $branch if this folder is the good copy"
Fail 'Nothing was sent.'
}
}
} else { } else {
Write-Host "Branch '$branch' is not on the server yet; it will be created." git remote add origin $Remote
} }
git push -u origin $branch git push -u origin main
if ($LASTEXITCODE -ne 0) { Fail 'The push was rejected. The message above says why. Nothing was sent.' }
Write-Host '' Write-Host ''
Write-Host "Pushed $branch successfully." -ForegroundColor Green Write-Host 'Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin' -ForegroundColor Green
git log --oneline -1 Write-Host 'Sign in with your Gitea username and password, or a token as the password.'
Write-Host 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin'
+11 -84
View File
@@ -1,93 +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."
command -v git >/dev/null || fail "Git is not installed."
git config --global core.autocrlf input >/dev/null 2>&1 || true
if [ -z "$(git config user.email || git config --global user.email)" ]; then
cat >&2 <<'MSG'
Git does not know who you are. Set that once:
git config --global user.email "you@example.com"
git config --global user.name "Your Name"
MSG
exit 1 exit 1
fi fi
[ -d .git ] || git init -b main || fail "git init failed." git init -b main
git add .
if git remote | grep -qx origin; then git commit -m "Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA"
git remote set-url origin "$REMOTE" git remote add origin "$REMOTE" 2>/dev/null || git remote set-url origin "$REMOTE"
else git push -u origin main
git remote add origin "$REMOTE"
fi
# Whatever branch is checked out, not a hard-coded one.
BRANCH=$(git rev-parse --abbrev-ref HEAD)
[ -n "$BRANCH" ] && [ "$BRANCH" != "HEAD" ] || fail "No branch is checked out here."
echo "Branch: $BRANCH"
# An unfinished rebase or merge blocks everything below, and git's own error is
# easy to mistake for a push problem.
GIT_DIR_PATH=$(git rev-parse --git-dir 2>/dev/null || echo .git)
for marker in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD; do
if [ -e "$GIT_DIR_PATH/$marker" ]; then
cat >&2 <<'MSG'
There is an unfinished rebase or merge in this folder.
Nothing else can happen until it is settled:
git rebase --abort throw the attempt away, back to how things were
git status see which files still need attention
git rebase --continue after fixing the files git listed
If unsure, "git rebase --abort" is the safe one.
MSG
exit 1
fi
done
git add -A
if [ -n "$(git status --porcelain)" ]; then
read -r -p "Describe this change (enter for a dated default): " MSG
[ -n "$MSG" ] || MSG="Update $(date '+%Y-%m-%d %H:%M')"
git commit -m "$MSG" || fail "git commit failed."
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 "$BRANCH" | grep -q "$BRANCH"; then
BEHIND=$(git rev-list --count "HEAD..origin/$BRANCH" 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 "$BRANCH"; 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/$BRANCH
If that is nothing you need, and this folder is the good copy:
git push --force-with-lease origin $BRANCH
MSG
exit 1
fi
fi
fi
git push -u origin "$BRANCH" || fail "The push was rejected. Read the message above. Nothing was sent."
echo echo
echo "Pushed $BRANCH successfully." echo "Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
git log --oneline -1
echo "https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
+2 -4
View File
@@ -25,10 +25,8 @@ fi
if docker compose ps --status running 2>/dev/null | grep -q visitor-signin; then if docker compose ps --status running 2>/dev/null | grep -q visitor-signin; then
docker compose exec -T visitor-signin node scripts/make-cert.mjs $FORCE docker compose exec -T visitor-signin node scripts/make-cert.mjs $FORCE
echo "Recreating the container so the new certificate is served..." echo "Restarting so the new certificate is served..."
# up -d rather than restart: restart keeps the environment the container was docker compose restart visitor-signin
# started with, so an edited .env would be ignored.
docker compose up -d visitor-signin
else else
node scripts/make-cert.mjs $FORCE node scripts/make-cert.mjs $FORCE
fi fi
-100
View File
@@ -1,100 +0,0 @@
/**
* Tries roll ids one at a time, waiting for you to say what came out.
*
* docker compose exec -it visitor-signin node scripts/print-probe.mjs
*
* Note the -it: this asks questions, so the container needs a terminal attached.
*
* Start with printer-status.mjs — if the printer answers, it tells you the right
* id outright and this is unnecessary. Use this when the printer will not report
* its status, or when it does and the job is still refused.
*/
import fs from 'node:fs';
import readline from 'node:readline/promises';
import { execFileSync } from 'node:child_process';
import db from '../src/db.js';
import config from '../src/config.js';
import * as printer from '../src/printer.js';
// Ordered by how likely each is on a 62 mm machine, cheapest guesses first.
const CANDIDATES = [
['62', '62 mm continuous, black only'],
['62x100', '62 x 100 mm die-cut'],
['62red', '62 mm continuous, black and red (DK-22251)'],
['62x29', '62 x 29 mm die-cut'],
['29', '29 mm continuous'],
['29x90', '29 x 90 mm die-cut'],
['38', '38 mm continuous'],
['50', '50 mm continuous'],
['54', '54 mm continuous'],
];
const site = db
.prepare('SELECT * FROM sites WHERE printer_host IS NOT NULL ORDER BY id LIMIT 1')
.get();
if (!site) {
console.error('No site has a printer address set.');
process.exit(1);
}
const target = `tcp://${site.printer_host}:${site.printer_port || 9100}`;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
console.log(`\n Printer: ${site.printer_model || 'QL-820NWB'} at ${target}`);
console.log(' One label will be sent per attempt. After each, say whether anything came out.');
console.log(' Press Ctrl+C at any point to stop.\n');
const results = [];
for (const [label, description] of CANDIDATES) {
const answer = (await rl.question(` Try "${label}" (${description})? [Y/n/q] `)).trim().toLowerCase();
if (answer === 'q') break;
if (answer === 'n') {
results.push([label, 'skipped']);
continue;
}
const png = await printer.renderBadgePng(printer.sampleVisit(site), { ...site, printer_label: label });
const file = '/tmp/probe.png';
fs.writeFileSync(file, png);
let sent = true;
let detail = '';
try {
execFileSync(
config.printing.command,
[
'--backend', 'network',
'--model', site.printer_model || 'QL-820NWB',
'--printer', target,
'print', '--label', label, ...(label === '62red' ? ['--red'] : []), file,
],
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: config.printing.timeoutMs }
);
} catch (err) {
sent = false;
detail = `${err.stdout || ''}${err.stderr || ''}`.trim().split('\n').pop() || err.message;
}
if (!sent) {
console.log(` could not send: ${detail}\n`);
results.push([label, `send failed: ${detail}`]);
continue;
}
const came = (await rl.question(' Did a label print? [y/N] ')).trim().toLowerCase();
if (came === 'y') {
results.push([label, 'PRINTED']);
console.log(`\n That is the one. Set "Roll loaded in the printer" so it sends ${label}.\n`);
break;
}
results.push([label, 'nothing came out']);
console.log(' Clear the error on the printer (open and close the cover) before the next try.\n');
}
rl.close();
console.log(' Summary');
for (const [label, outcome] of results) console.log(` ${label.padEnd(8)} ${outcome}`);
console.log('');
-107
View File
@@ -1,107 +0,0 @@
/**
* Renders a badge and prints it, showing everything on the way through.
*
* For working out why a printer will not accept a job, without going through the
* kiosk or the admin console. Run inside the container:
*
* docker compose exec visitor-signin node scripts/print-test.mjs
* docker compose exec visitor-signin node scripts/print-test.mjs --label 62x100
* docker compose exec visitor-signin node scripts/print-test.mjs --dry
*
* Options:
* --site N which site to use (default: the first one)
* --label ID override the roll id sent to the printer, without saving it
* --rotate DEG override the rotation, without saving it
* --dry render only, do not print
*/
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import db from '../src/db.js';
import config from '../src/config.js';
import * as printer from '../src/printer.js';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
? process.argv[i + 1]
: fallback;
}
const has = (name) => process.argv.includes(`--${name}`);
const siteId = Number(arg('site', 0));
const site = siteId
? db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId)
: db.prepare('SELECT * FROM sites ORDER BY id LIMIT 1').get();
if (!site) {
console.error('No sites exist yet.');
process.exit(1);
}
if (arg('label')) site.printer_label = arg('label');
if (arg('rotate')) site.printer_rotate = Number(arg('rotate'));
const label = printer.labelFor(site);
const target = `tcp://${site.printer_host}:${site.printer_port || 9100}`;
console.log('');
console.log(` site ${site.name}`);
console.log(` printer ${site.printer_model || 'QL-820NWB'} at ${target}`);
console.log(` server printing ${site.printer_enabled ? 'on' : 'OFF — the kiosk would print instead'}`);
console.log(` badge ${site.badge_width_mm} x ${site.badge_height_mm} mm, rotate ${site.printer_rotate || 0}`);
console.log(` roll setting ${site.printer_label || '(unset)'} -> --label ${label}`);
console.log(` red requested ${Boolean(site.badge_accent)}, will print ${printer.accentWillPrintRed(site)}`);
const png = await printer.renderBadgePng(printer.sampleVisit(site), site);
const file = '/tmp/print-test.png';
fs.writeFileSync(file, png);
console.log(` rendered ${png.readUInt32BE(16)} x ${png.readUInt32BE(20)} dots -> ${file}`);
if (!site.printer_host) {
console.error('\n No printer address set for this site. Set one in Admin -> Sites -> Edit.');
process.exit(1);
}
if (has('dry')) {
console.log('\n --dry given, so nothing was sent.\n');
process.exit(0);
}
const args = [
'--backend', 'network',
'--model', site.printer_model || 'QL-820NWB',
'--printer', target,
'print',
'--label', label,
];
// Required on black/red tape even when the badge is entirely black.
if (label === '62red') args.push('--red');
args.push(file);
console.log('');
console.log(` running: ${config.printing.command} ${args.join(' ')}`);
console.log('');
try {
const out = execFileSync(config.printing.command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: config.printing.timeoutMs,
});
console.log(out.trim() || ' (no output)');
console.log('\n Sent. If nothing came out, the printer rejected it silently — check its display.\n');
} catch (err) {
console.error(' FAILED\n');
console.error(`${err.stdout || ''}${err.stderr || ''}`.trim() || err.message);
console.error('');
console.error(' Roll ids this printer understands:');
console.error(' 62 62 mm continuous, black only');
console.error(' 62red 62 mm continuous, black and red (DK-22251)');
console.error(' 62x100 62 x 100 mm DIE-CUT (pre-cut labels, not a continuous roll)');
console.error(' 62x29 62 x 29 mm die-cut');
console.error('');
console.error(' A continuous roll sent a die-cut id, or the reverse, is refused as a wrong roll.');
console.error(' Try: node scripts/print-test.mjs --label 62x100');
console.error('');
process.exit(1);
}
-172
View File
@@ -1,172 +0,0 @@
/**
* Asks the printer what it actually has loaded, and what it is complaining about.
*
* docker compose exec visitor-signin node scripts/printer-status.mjs
* docker compose exec visitor-signin node scripts/printer-status.mjs --host 10.0.0.5
*
* brother_ql's network backend only writes to the socket; it never reads, which is
* why a refused job still looks like a success. The Brother raster protocol has a
* status request that returns a 32 byte block describing the media in the machine
* and any error, so we ask directly.
*/
import net from 'node:net';
import db from '../src/db.js';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
? process.argv[i + 1]
: fallback;
}
const site = arg('site')
? db.prepare('SELECT * FROM sites WHERE id = ?').get(Number(arg('site')))
: db.prepare('SELECT * FROM sites WHERE printer_host IS NOT NULL ORDER BY id LIMIT 1').get();
const host = arg('host', site?.printer_host);
const port = Number(arg('port', site?.printer_port || 9100));
if (!host) {
console.error('No printer address. Set one in Admin -> Sites, or pass --host.');
process.exit(1);
}
/* ------------------------------------------------------------- decoding */
const MEDIA_TYPES = {
0x00: 'no media loaded',
0x0a: 'continuous roll',
0x0b: 'die-cut labels',
0x4a: 'continuous roll (cleaning)',
0x4b: 'die-cut labels (cleaning)',
};
const ERRORS_1 = [
[0x01, 'no media loaded'],
[0x02, 'end of media'],
[0x04, 'cutter jam'],
[0x08, 'weak batteries'],
[0x10, 'printer in use'],
[0x80, 'printer turned off'],
];
const ERRORS_2 = [
[0x01, 'wrong media — the job does not match the roll that is loaded'],
[0x04, 'expansion buffer full'],
[0x08, 'communication error'],
[0x10, 'communication buffer full'],
[0x20, 'cover is open'],
[0x40, 'cancel key pressed'],
[0x80, 'media cannot be fed'],
];
function decode(buf) {
if (buf.length < 32) return { error: `Short reply (${buf.length} bytes).` };
const mediaWidth = buf[10];
const mediaType = buf[11];
const mediaLength = buf[17];
return {
mediaWidthMm: mediaWidth,
mediaLengthMm: mediaLength,
mediaType: MEDIA_TYPES[mediaType] || `unknown (0x${mediaType.toString(16)})`,
mediaTypeRaw: mediaType,
errors: [
...ERRORS_1.filter(([bit]) => buf[8] & bit).map(([, text]) => text),
...ERRORS_2.filter(([bit]) => buf[9] & bit).map(([, text]) => text),
],
raw: buf.subarray(0, 32).toString('hex').replace(/(..)/g, '$1 ').trim(),
};
}
/** The label id brother_ql should be given, worked out from what is loaded. */
function suggestLabel(status) {
if (status.mediaTypeRaw === 0x00) return null;
const continuous = status.mediaTypeRaw === 0x0a || status.mediaTypeRaw === 0x4a;
if (continuous) {
return String(status.mediaWidthMm); // 62, 29, 12 ...
}
return status.mediaLengthMm
? `${status.mediaWidthMm}x${status.mediaLengthMm}`
: `${status.mediaWidthMm} (die-cut, length unknown)`;
}
/* --------------------------------------------------------------- asking */
console.log(`\n Asking ${host}:${port} what it has loaded...\n`);
const socket = net.createConnection({ host, port, timeout: 8000 });
const chunks = [];
socket.on('connect', () => {
// 200 null bytes clears any half-finished job, then initialise, then ask.
socket.write(Buffer.alloc(200, 0x00));
socket.write(Buffer.from([0x1b, 0x40]));
socket.write(Buffer.from([0x1b, 0x69, 0x53]));
// Some firmware only answers once it is in raster mode, so ask again that way
// before giving up.
setTimeout(() => {
if (!chunks.length && !socket.destroyed) {
socket.write(Buffer.from([0x1b, 0x69, 0x61, 0x01]));
socket.write(Buffer.from([0x1b, 0x69, 0x53]));
}
}, 1500);
});
socket.on('data', (d) => {
chunks.push(d);
if (Buffer.concat(chunks).length >= 32) socket.end();
});
socket.on('timeout', () => {
socket.destroy();
if (!chunks.length) {
console.error(' The printer accepted the connection but sent nothing back.');
console.error('');
console.error(' Many Brother network print servers are write-only on port 9100: they accept');
console.error(' jobs but never report status, even though the same printer answers happily');
console.error(' over USB. If this is one of them, no amount of asking will help.');
console.error('');
console.error(' Read the printer instead from:');
console.error(' - the display on the machine itself');
console.error(' - Brother Status Monitor, on a PC with the printer installed');
console.error(' - the printer\'s own web page, at http://' + host + '/');
console.error('');
console.error(' Status Monitor in particular gives the exact reason a job was refused,');
console.error(' which is more than brother_ql can tell you — its network backend never');
console.error(' reads the socket, so it reports success whatever the printer does.');
console.error('');
process.exit(1);
}
});
socket.on('error', (err) => {
console.error(` Could not reach it: ${err.message}\n`);
process.exit(1);
});
socket.on('close', () => {
const buf = Buffer.concat(chunks);
if (!buf.length) process.exit(1);
const status = decode(buf);
if (status.error) {
console.error(` ${status.error}\n raw: ${buf.toString('hex')}\n`);
process.exit(1);
}
console.log(` media loaded ${status.mediaWidthMm} mm ${status.mediaType}`);
if (status.mediaLengthMm) console.log(` label length ${status.mediaLengthMm} mm`);
console.log(` errors ${status.errors.length ? status.errors.join('; ') : 'none reported'}`);
console.log(` raw status ${status.raw}`);
const suggested = suggestLabel(status);
console.log('');
if (!suggested) {
console.log(' No media detected. Open and close the cover to make it re-read the roll.');
} else {
console.log(` Use this roll id: --label ${suggested}`);
console.log(` Try it with: node scripts/print-test.mjs --label ${suggested}`);
}
console.log('');
});
-32
View File
@@ -1,32 +0,0 @@
// Reports what this container is actually running, so an upgrade that did not
// land can be spotted without guesswork.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(here, '..');
const FEATURES = [
['server-side printing', 'src/printer.js', null],
['roll type is a separate setting', 'src/routes/admin.js', 'printer_label'],
['roll type in the console', 'public/js/admin.js', 'printerLabel'],
['company field', 'src/routes/kiosk.js', 'company'],
['separate login page', 'public/login.html', 'step-password'],
['per-site branding', 'src/branding.js', null],
['CA in DER form for Jamf', 'src/tls.js', 'caCertificateDer'],
];
console.log('');
for (const [name, file, needle] of FEATURES) {
const full = path.join(root, file);
let state = 'MISSING';
if (fs.existsSync(full)) {
state = !needle || fs.readFileSync(full, 'utf8').includes(needle) ? 'present' : 'OLD VERSION';
}
console.log(` ${state.padEnd(12)} ${name}`);
}
console.log('');
console.log('Anything not "present" means this container is running older code.');
console.log('On the docker host: git pull && docker compose up -d --build');
console.log('');
-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),
-24
View File
@@ -22,15 +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,
printer_label TEXT NOT NULL DEFAULT '62',
photo_mode TEXT NOT NULL DEFAULT 'dither',
photo_threshold INTEGER NOT NULL DEFAULT 50,
photo_contrast INTEGER NOT NULL DEFAULT 20,
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',
@@ -181,21 +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');
// Which roll is physically loaded. Kept separate from the red styling option:
// the printer refuses a two-colour job on a plain roll, so guessing the media
// from a design setting means a wrong-roll error nobody can explain.
addColumn('sites', 'printer_label', "TEXT NOT NULL DEFAULT '62'");
// How the photo is reduced to the printer's one bit per dot. A plain threshold
// turns a face into solid blocks; error diffusion keeps the tones readable.
addColumn('sites', 'photo_mode', "TEXT NOT NULL DEFAULT 'dither'");
addColumn('sites', 'photo_threshold', 'INTEGER NOT NULL DEFAULT 50');
addColumn('sites', 'photo_contrast', 'INTEGER NOT NULL DEFAULT 20');
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)');
-546
View File
@@ -1,546 +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 media id for the roll that is actually loaded.
*
* This is set explicitly rather than inferred from the red styling option. A
* two-colour job sent to a plain roll is rejected by the printer with "Wrong
* Roll Type", which gives no clue that a colour checkbox caused it.
*/
export const ROLL_TYPES = {
'62': '62 mm continuous, black only',
'62red': '62 mm continuous, black and red (DK-22251)',
};
export function labelFor(site) {
const label = site?.printer_label;
return Object.hasOwn(ROLL_TYPES, label) ? label : '62';
}
/** Red ink only exists on the two-colour roll; anywhere else it prints dark grey. */
export function accentWillPrintRed(site) {
return Boolean(site?.badge_accent) && labelFor(site) === '62red';
}
/* ------------------------------------------------- photo halftoning */
export const PHOTO_MODES = {
dither: 'Error diffusion — best for faces',
threshold: 'Hard threshold — crisp, loses detail',
none: 'Leave it to the printer driver',
};
/**
* Reduces a photo to the one bit per dot the printer actually has.
*
* Done here rather than left to brother_ql so the preview and the label agree,
* and because the default is a plain threshold: every pixel darker than the cut
* becomes solid black, which turns a face into a few featureless blobs. Error
* diffusion spreads the rounding error into neighbouring pixels instead, so mid
* tones survive as a pattern of dots.
*/
function halftone(ctx, x, y, size, { mode = 'dither', threshold = 50, contrast = 20 } = {}) {
if (mode === 'none') return;
const image = ctx.getImageData(x, y, size, size);
const { data, width, height } = image;
const cut = Math.max(1, Math.min(99, threshold)) * 2.55;
// Standard contrast curve, pivoting on mid grey. Webcam photos are flat and
// flatten further when reduced to two tones, so a little lift helps.
const c = Math.max(-100, Math.min(100, contrast));
const factor = (259 * (c + 255)) / (255 * (259 - c));
// Greyscale first, into a float buffer so the diffused error does not clip.
const grey = new Float32Array(width * height);
for (let i = 0; i < width * height; i += 1) {
const r = data[i * 4];
const g = data[i * 4 + 1];
const b = data[i * 4 + 2];
const luma = 0.299 * r + 0.587 * g + 0.114 * b;
grey[i] = Math.max(0, Math.min(255, factor * (luma - 128) + 128));
}
for (let py = 0; py < height; py += 1) {
for (let px = 0; px < width; px += 1) {
const i = py * width + px;
const old = grey[i];
const next = old < cut ? 0 : 255;
grey[i] = next;
if (mode === 'dither') {
// Floyd-Steinberg: push the rounding error to pixels not yet visited.
const err = old - next;
const spread = (dx, dy, weight) => {
const nx = px + dx;
const ny = py + dy;
if (nx < 0 || nx >= width || ny >= height) return;
grey[ny * width + nx] += err * weight;
};
spread(1, 0, 7 / 16);
spread(-1, 1, 3 / 16);
spread(0, 1, 5 / 16);
spread(1, 1, 1 / 16);
}
}
}
for (let i = 0; i < width * height; i += 1) {
const v = grey[i] < 128 ? 0 : 255;
data[i * 4] = v;
data[i * 4 + 1] = v;
data[i * 4 + 2] = v;
data[i * 4 + 3] = 255;
}
ctx.putImageData(image, x, y);
}
export function photoSettings(site) {
return {
mode: Object.hasOwn(PHOTO_MODES, site?.photo_mode) ? site.photo_mode : 'dither',
threshold: Number.isFinite(Number(site?.photo_threshold)) ? Number(site.photo_threshold) : 50,
contrast: Number.isFinite(Number(site?.photo_contrast)) ? Number(site.photo_contrast) : 20,
};
}
/**
* A comparison sheet: the same photo at several settings, side by side, so the
* right one can be chosen by eye rather than by guessing at numbers.
*/
export async function photoPreviewPng(photoPath, current = {}) {
const tile = 260;
const gap = 18;
const caption = 46;
const settings = photoSettings(current);
const variants = [
{ label: `Current: ${PHOTO_MODES[settings.mode].split(' —')[0]} ${settings.threshold}%, contrast ${settings.contrast}`, ...settings },
{ label: 'Dither, threshold 50, contrast 0', mode: 'dither', threshold: 50, contrast: 0 },
{ label: 'Dither, threshold 50, contrast 30', mode: 'dither', threshold: 50, contrast: 30 },
{ label: 'Dither, threshold 60, contrast 30', mode: 'dither', threshold: 60, contrast: 30 },
{ label: 'Hard threshold 50', mode: 'threshold', threshold: 50, contrast: 0 },
];
const canvas = createCanvas(
gap + variants.length * (tile + gap),
gap + tile + caption
);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const abs = photoAbsolutePath(photoPath);
let image = null;
if (abs) {
try {
image = await loadImage(abs);
} catch {
image = null;
}
}
for (const [index, variant] of variants.entries()) {
const x = gap + index * (tile + gap);
if (image) {
ctx.drawImage(image, x, gap, tile, tile);
halftone(ctx, x, gap, tile, variant);
} else {
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(x, gap, tile, tile);
ctx.fillStyle = '#555555';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('no photo yet', x + tile / 2, gap + tile / 2);
}
ctx.strokeStyle = '#999999';
ctx.lineWidth = 1;
ctx.strokeRect(x + 0.5, gap + 0.5, tile, tile);
ctx.fillStyle = index === 0 ? '#0b4f4a' : '#222222';
ctx.font = `${index === 0 ? 'bold ' : ''}13px sans-serif`;
ctx.textAlign = 'center';
for (const [line, text] of variant.label.split(', contrast').entries()) {
ctx.fillText(
line === 0 ? text : `contrast${text}`,
x + tile / 2,
gap + tile + 20 + line * 16
);
}
}
return canvas.toBuffer('image/png');
}
/* ------------------------------------------------------------ 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);
halftone(ctx, x, cursorY, photoSize, photoSettings(site));
}
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);
halftone(ctx, pad, y, photoSize, photoSettings(site));
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');
// Only paint red when the loaded roll can actually print it.
const accent = accentWillPrintRed(site);
// 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.';
}
if (/Black\/Red|Monochrome media/i.test(output)) {
return 'The printer has a black/red roll loaded but received a monochrome job. Set "Roll loaded in the printer" to the DK-22251 option so the job is built for two-colour tape.';
}
if (/wrong roll|WrongMedia|media/i.test(last)) {
return 'The printer says the roll is wrong. Check "Roll loaded in the printer" matches what is actually in the machine — a black and red job is refused on a plain roll.';
}
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}`;
const label = labelFor(site);
const args = [
'--backend', 'network',
'--model', site.printer_model || 'QL-820NWB',
'--printer', target,
'print',
'--label', label,
];
// brother_ql: "You must use this option when printing on black/red tape, even
// when not printing red." Without it the job is built as monochrome, the
// printer sees two-colour media, and refuses the whole thing.
if (label === '62red') args.push('--red');
args.push(file);
try {
await runBrotherQl(args, 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);
const wrapped = new Error(friendly);
wrapped.raw = err.message;
wrapped.command = `${config.printing.command} ${args.join(' ')}`;
throw wrapped;
} 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 -181
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,15 +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 || {};
const photoCfg = req.body?.photo || {};
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 = ?, printer_label = ?,
photo_mode = ?, photo_threshold = ?, photo_contrast = ? 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,
@@ -425,28 +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,
printerCfg.label !== undefined
? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62')
: site.printer_label,
photoCfg.mode !== undefined
? (Object.hasOwn(printer.PHOTO_MODES, photoCfg.mode) ? photoCfg.mode : 'dither')
: site.photo_mode,
photoCfg.threshold !== undefined
? Math.min(95, Math.max(5, Number(photoCfg.threshold) || 50))
: site.photo_threshold,
photoCfg.contrast !== undefined
? Math.min(100, Math.max(-100, Number(photoCfg.contrast) || 0))
: site.photo_contrast
, 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)));
@@ -488,121 +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) {
// 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)}${printer.labelFor(site) === '62red' ? ' --red' : ''} 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 });
}
});
/** The same photo at several halftone settings, to choose between by eye. */
router.get('/sites/:id/photo-preview', 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.');
// Whatever real photo is closest to hand: the most recent visit at this site,
// then any stored recurring visitor photo.
const recent =
db
.prepare(
'SELECT photo_path FROM visits WHERE site_id = ? AND photo_path IS NOT NULL ORDER BY id DESC LIMIT 1'
)
.get(site.id) ||
db.prepare('SELECT photo_path FROM frequent_visitors WHERE photo_path IS NOT NULL LIMIT 1').get();
const overrides = {
photo_mode: req.query.mode || site.photo_mode,
photo_threshold: req.query.threshold || site.photo_threshold,
photo_contrast: req.query.contrast || site.photo_contrast,
};
try {
const png = await printer.photoPreviewPng(recent?.photo_path || null, overrides);
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 preview: ${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.');
@@ -1299,38 +1153,6 @@ router.get('/tls/ca.crt', (req, res) => {
res.send(ca); 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}`);
}
});
/** All encodings plus instructions, as one archive browsers will actually download. */
router.get('/tls/ca-bundle.zip', (req, res) => {
try {
const zip = tls.caBundleZip();
if (!zip) return res.status(404).send('No certificate authority has been generated yet.');
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="visitor-signin-certificates.zip"');
res.setHeader('Content-Length', zip.length);
res.send(zip);
} catch (err) {
res.status(500).send(`Could not build the bundle: ${err.message}`);
}
});
router.post('/tls/renew', requireOwner, (req, res) => { router.post('/tls/renew', requireOwner, (req, res) => {
try { try {
// A brand new CA means every kiosk device has to trust it again, so it is // A brand new CA means every kiosk device has to trust it again, so it is
+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);
+1 -52
View File
@@ -72,19 +72,7 @@ app.get('/admin/login', (req, res) => {
// Nobody should land on the raw filenames; keep one address per page. // Nobody should land on the raw filenames; keep one address per page.
app.get(['/admin.html', '/login.html'], (req, res) => res.redirect('/admin')); app.get(['/admin.html', '/login.html'], (req, res) => res.redirect('/admin'));
// no-cache still allows a 304 on an unchanged file, but forces the browser to ask app.use(express.static(publicDir, { extensions: ['html'], index: false }));
// first. Without it a cached admin.js survives an upgrade and the console keeps
// running yesterday's code against today's server, which is impossible to diagnose
// from the outside.
app.use(
express.static(publicDir, {
extensions: ['html'],
index: false,
setHeaders: (res, filePath) => {
if (/\.(html|js|css)$/.test(filePath)) res.setHeader('Cache-Control', 'no-cache');
},
})
);
app.get('/favicon.ico', (req, res) => res.redirect(301, '/favicon.svg')); app.get('/favicon.ico', (req, res) => res.redirect(301, '/favicon.svg'));
app.use((req, res) => res.status(404).sendFile(path.join(publicDir, 'index.html'))); app.use((req, res) => res.status(404).sendFile(path.join(publicDir, 'index.html')));
@@ -133,45 +121,6 @@ function startRedirectServer() {
http http
.createServer((req, res) => { .createServer((req, res) => {
// A zip, because browsers block bare certificate downloads.
if (req.url === '/ca.zip') {
try {
const zip = tls.caBundleZip();
if (!zip) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('No certificate authority has been generated yet.');
}
res.writeHead(200, {
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="visitor-signin-certificates.zip"',
'Content-Length': zip.length,
});
return res.end(zip);
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
return res.end(`Could not build the bundle: ${err.message}`);
}
}
// DER for Apple tooling, PEM for everything else.
if (req.url === '/ca.cer' || req.url === '/ca.der') {
try {
const der = tls.caCertificateDer();
if (!der) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('No certificate authority has been generated yet.');
}
res.writeHead(200, {
'Content-Type': 'application/pkix-cert',
'Content-Disposition': 'attachment; filename="visitor-signin-ca.cer"',
});
return res.end(der);
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
return res.end(`Could not convert the certificate: ${err.message}`);
}
}
if (req.url === '/ca.crt' || req.url === '/ca.pem') { if (req.url === '/ca.crt' || req.url === '/ca.pem') {
const ca = tls.caCertificate(); const ca = tls.caCertificate();
if (!ca) { if (!ca) {
-13
View File
@@ -59,19 +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,
label: site.printer_label || '62',
},
photo: {
mode: site.photo_mode || 'dither',
threshold: site.photo_threshold ?? 50,
contrast: site.photo_contrast ?? 20,
},
badge: { badge: {
enabled: Boolean(site.badge_enabled), enabled: Boolean(site.badge_enabled),
widthMm: site.badge_width_mm, widthMm: site.badge_width_mm,
-100
View File
@@ -4,7 +4,6 @@ import os from 'node:os';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import config from './config.js'; import config from './config.js';
import { createZip } from './zip.js';
/** /**
* Certificates for an internal-only kiosk. * Certificates for an internal-only kiosk.
@@ -195,18 +194,6 @@ export function ensureCertificates({ force = false } = {}) {
fs.writeFileSync(p.names, JSON.stringify(config.https.hostnames)); fs.writeFileSync(p.names, JSON.stringify(config.https.hostnames));
} }
// A very common mistake is editing .env and then using `docker compose restart`,
// which reuses the old environment. The symptom is a certificate covering only
// the defaults, so say so rather than letting it fail later in a browser.
const configured = config.https.hostnames;
if (configured.length === 1 && configured[0] === 'visitors.local') {
console.warn(
'[tls] HTTPS_HOSTNAMES is at its default. If you set it in .env, bring the container\n' +
' back with "docker compose up -d" rather than "docker compose restart" — restart\n' +
' keeps the environment the container started with.'
);
}
return { return {
key: fs.readFileSync(p.key), key: fs.readFileSync(p.key),
cert: fs.readFileSync(p.cert), cert: fs.readFileSync(p.cert),
@@ -238,93 +225,6 @@ export function describe() {
}; };
} }
/**
* The CA in DER form.
*
* The .crt on disk is PEM: base64 text between BEGIN/END lines. Apple's tooling,
* and therefore Jamf Pro's certificate payload, wants the raw binary DER instead
* and rejects the file on its extension. Same certificate, different wrapper.
*/
export function caCertificateDer() {
const p = paths();
if (!fs.existsSync(p.caCert)) return null;
return openssl(['x509', '-in', p.caCert, '-outform', 'der']);
}
/**
* Every form of the authority certificate in one archive, with instructions.
*
* Browsers increasingly refuse to download a bare .crt or .cer as a dangerous
* file type, which leaves no way to get the certificate onto a device. A zip is
* accepted, and carrying all the encodings means whichever tool is being fed —
* Jamf, Windows, Android — has the one it wants.
*/
export function caBundleZip() {
const p = paths();
if (!fs.existsSync(p.caCert)) return null;
const pem = fs.readFileSync(p.caCert);
const der = caCertificateDer();
const info = describe();
const readme = [
`${config.siteName} — certificate authority`,
'='.repeat(60),
'',
'Install ONE of these on each device. They are the same certificate in',
'different encodings; which one you need depends on the tool.',
'',
' visitor-signin-ca.cer binary DER. Jamf Pro, Apple Configurator, iOS, macOS.',
' visitor-signin-ca.crt PEM text. Windows, Android, Chromebook, Linux.',
' visitor-signin-ca.pem identical to the .crt, for tools expecting .pem.',
'',
'Fingerprint (SHA-256)',
` ${info.ca?.fingerprint || 'unknown'}`,
'',
'Check this matches what the device shows before trusting it.',
'',
'Valid until',
` ${info.ca?.validTo || 'unknown'}`,
'',
'The server certificate currently covers',
` ${(info.server?.names || ['unknown']).join('\n ')}`,
'',
'Installing',
'----------',
'Jamf Pro Devices > Configuration Profiles > New > Certificate payload.',
' Upload the .cer, scope to the kiosk devices, save. A root',
' certificate delivered by MDM is trusted for TLS automatically.',
'',
'Windows Double-click the .crt > Install Certificate > Local Machine >',
' Place all certificates in the following store > Trusted Root',
' Certification Authorities.',
'',
'Android Settings > Security > Encryption & credentials > Install a',
' certificate > CA certificate, then pick the .crt. Chrome on',
' Android will not accept a certificate for a bare IP address,',
' so reach the kiosk by hostname.',
'',
'Chromebook Settings > Privacy and security > Security > Manage',
' certificates > Authorities > Import, then pick the .crt.',
'',
'iOS by hand Open the .crt in Safari, allow the profile, install it under',
' Settings > General > VPN & Device Management, THEN turn it on',
' under Settings > General > About > Certificate Trust Settings.',
' Both steps are needed when installing by hand.',
'',
'Renewing the server certificate does not change this authority, so devices',
'only need this done once.',
'',
].join('\n');
return createZip([
{ name: 'visitor-signin-ca.cer', data: der },
{ name: 'visitor-signin-ca.crt', data: pem },
{ name: 'visitor-signin-ca.pem', data: pem },
{ name: 'README.txt', data: readme },
]);
}
export function caCertificate() { export function caCertificate() {
const p = paths(); const p = paths();
return fs.existsSync(p.caCert) ? fs.readFileSync(p.caCert) : null; return fs.existsSync(p.caCert) ? fs.readFileSync(p.caCert) : null;
-108
View File
@@ -1,108 +0,0 @@
import zlib from 'node:zlib';
/**
* A small ZIP writer, so a bundle of certificates can be offered as a single
* download. Browsers increasingly refuse .crt and .cer files as dangerous types,
* and a zip is accepted where the bare certificate is not.
*
* Only what is needed here: a handful of small files, no directories, no
* encryption, no zip64. Written directly rather than pulling in a dependency for
* sixty lines of header packing.
*/
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
return table;
})();
function crc32(buffer) {
let crc = -1;
for (const byte of buffer) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ byte) & 0xff];
return (crc ^ -1) >>> 0;
}
/** MS-DOS packs the date and time into two 16 bit words, with two second resolution. */
function dosStamp(date) {
const time =
(date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
const day = ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
return { time, day };
}
/**
* @param {Array<{name: string, data: Buffer|string}>} files
* @returns {Buffer} the complete archive
*/
export function createZip(files) {
const now = new Date();
const { time, day } = dosStamp(now);
const locals = [];
const central = [];
let offset = 0;
for (const file of files) {
const name = Buffer.from(file.name, 'utf8');
const raw = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data, 'utf8');
const compressed = zlib.deflateRawSync(raw);
// Storing uncompressed is allowed and is smaller for data that does not shrink.
const useDeflate = compressed.length < raw.length;
const data = useDeflate ? compressed : raw;
const method = useDeflate ? 8 : 0;
const crc = crc32(raw);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0); // local file header signature
localHeader.writeUInt16LE(20, 4); // version needed
localHeader.writeUInt16LE(0, 6); // flags
localHeader.writeUInt16LE(method, 8);
localHeader.writeUInt16LE(time, 10);
localHeader.writeUInt16LE(day, 12);
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(data.length, 18);
localHeader.writeUInt32LE(raw.length, 22);
localHeader.writeUInt16LE(name.length, 26);
localHeader.writeUInt16LE(0, 28); // extra field length
locals.push(localHeader, name, data);
const centralHeader = Buffer.alloc(46);
centralHeader.writeUInt32LE(0x02014b50, 0); // central directory signature
centralHeader.writeUInt16LE(20, 4); // version made by
centralHeader.writeUInt16LE(20, 6); // version needed
centralHeader.writeUInt16LE(0, 8);
centralHeader.writeUInt16LE(method, 10);
centralHeader.writeUInt16LE(time, 12);
centralHeader.writeUInt16LE(day, 14);
centralHeader.writeUInt32LE(crc, 16);
centralHeader.writeUInt32LE(data.length, 20);
centralHeader.writeUInt32LE(raw.length, 24);
centralHeader.writeUInt16LE(name.length, 28);
centralHeader.writeUInt16LE(0, 30); // extra
centralHeader.writeUInt16LE(0, 32); // comment
centralHeader.writeUInt16LE(0, 34); // disk number
centralHeader.writeUInt16LE(0, 36); // internal attributes
centralHeader.writeUInt32LE(0, 38); // external attributes
centralHeader.writeUInt32LE(offset, 42); // offset of local header
central.push(centralHeader, name);
offset += localHeader.length + name.length + data.length;
}
const centralBuffer = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0); // end of central directory
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(centralBuffer.length, 12);
end.writeUInt32LE(offset, 16);
end.writeUInt16LE(0, 20); // comment length
return Buffer.concat([...locals, centralBuffer, end]);
}
-3
View File
@@ -1,3 +0,0 @@
b1a7933 (HEAD -> deploy) Printer Debug 2
eb98658 Printing Debug
8bc7179 (origin/deploy) Server-side printing, roll type setting, cache headers