Public Access
Compare commits
13
Commits
main
..
c3ce46913d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3ce46913d | ||
|
|
e6731dbdfa | ||
|
|
8c97c314e0 | ||
|
|
ff9f51eaf9 | ||
|
|
74792b2764 | ||
|
|
c21123885d | ||
|
|
cccba98d97 | ||
|
|
a7564b9033 | ||
|
|
9da7e88eb3 | ||
|
|
a011587d66 | ||
|
|
14678e13e8 | ||
|
|
b23ad422d0 | ||
|
|
ed77493817 |
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -179,45 +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 |
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**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
|
||||||
|
|
||||||
@@ -277,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`.
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@napi-rs/canvas": "^1.0.8",
|
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
|
|||||||
+3
-122
@@ -184,7 +184,6 @@ async function loadOnsite() {
|
|||||||
<td class="mono">${stamp(v.signedInAt)}</td>
|
<td class="mono">${stamp(v.signedInAt)}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="ghost" data-badge="${v.id}">Badge</button>
|
<button class="ghost" data-badge="${v.id}">Badge</button>
|
||||||
<button class="ghost" data-print="${v.id}">Print</button>
|
|
||||||
<button class="ghost" data-signout="${v.id}">Sign out</button>
|
<button class="ghost" data-signout="${v.id}">Sign out</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`
|
</tr>`
|
||||||
@@ -202,19 +201,6 @@ async function loadOnsite() {
|
|||||||
$$('[data-badge]').forEach((btn) =>
|
$$('[data-badge]').forEach((btn) =>
|
||||||
btn.addEventListener('click', () => window.open(`/admin/api/badge/${btn.dataset.badge}`, '_blank'))
|
btn.addEventListener('click', () => window.open(`/admin/api/badge/${btn.dataset.badge}`, '_blank'))
|
||||||
);
|
);
|
||||||
$$('[data-print]').forEach((btn) =>
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
await api(`/visits/${btn.dataset.print}/print`, { method: 'POST' });
|
|
||||||
toast('Sent to the printer.');
|
|
||||||
} catch (err) {
|
|
||||||
toast(err.message, true);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#refresh-onsite').addEventListener('click', () => loadOnsite());
|
$('#refresh-onsite').addEventListener('click', () => loadOnsite());
|
||||||
@@ -687,8 +673,6 @@ async function loadSites() {
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="ghost" data-edit-site="${s.id}">Edit</button>
|
<button class="ghost" data-edit-site="${s.id}">Edit</button>
|
||||||
<button class="ghost" data-preview-badge="${s.id}">Preview badge</button>
|
<button class="ghost" data-preview-badge="${s.id}">Preview badge</button>
|
||||||
<button class="ghost" data-bitmap="${s.id}">Bitmap preview</button>
|
|
||||||
${s.printer.enabled ? `<button class="ghost" data-test-print="${s.id}">Test print</button>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl class="site-meta">
|
<dl class="site-meta">
|
||||||
@@ -699,20 +683,6 @@ async function loadSites() {
|
|||||||
: 'Off'
|
: 'Off'
|
||||||
}</dd>
|
}</dd>
|
||||||
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''}
|
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''}
|
||||||
<dt>Printer</dt>
|
|
||||||
<dd>${
|
|
||||||
s.printer.enabled && s.printer.host
|
|
||||||
? `${esc(s.printer.model)} at ${esc(s.printer.host)}:${s.printer.port}, ${
|
|
||||||
s.printer.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'} ·
|
||||||
@@ -735,27 +705,6 @@ async function loadSites() {
|
|||||||
window.open(`/admin/api/sites/${btn.dataset.previewBadge}/badge-preview`, '_blank')
|
window.open(`/admin/api/sites/${btn.dataset.previewBadge}/badge-preview`, '_blank')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
$$('[data-bitmap]').forEach((btn) =>
|
|
||||||
btn.addEventListener('click', () =>
|
|
||||||
window.open(`/admin/api/sites/${btn.dataset.bitmap}/badge-bitmap`, '_blank')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
$$('[data-test-print]').forEach((btn) =>
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Printing…';
|
|
||||||
try {
|
|
||||||
await api(`/sites/${btn.dataset.testPrint}/test-print`, { method: 'POST' });
|
|
||||||
toast('Sent to the printer.');
|
|
||||||
} catch (err) {
|
|
||||||
toast(err.message, true);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Test print';
|
|
||||||
loadSites();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -799,38 +748,8 @@ function openSiteModal(site) {
|
|||||||
<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>
|
||||||
<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. Send a two-colour job to a
|
|
||||||
plain roll and the printer answers <em>Wrong Roll Type</em> and prints nothing.</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">
|
||||||
@@ -884,14 +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,
|
|
||||||
},
|
|
||||||
branding: {
|
branding: {
|
||||||
brand: data.brand || null,
|
brand: data.brand || null,
|
||||||
signout: data.signout || null,
|
signout: data.signout || null,
|
||||||
@@ -1000,35 +911,6 @@ function wireBannerEditor() {
|
|||||||
[pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast));
|
[pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast));
|
||||||
showContrast();
|
showContrast();
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -1391,8 +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" href="/admin/api/tls/ca.crt" download>CA certificate (.crt)</a>
|
<a class="ghost" href="/admin/api/tls/ca.crt" download>Download the CA certificate</a>
|
||||||
<a class="ghost" href="/admin/api/tls/ca.cer" download>CA certificate (.cer, for Jamf and Apple)</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
@@ -382,12 +382,9 @@ async function submitSignIn() {
|
|||||||
});
|
});
|
||||||
stopCamera();
|
stopCamera();
|
||||||
$('#done-in-message').textContent = `You're all set, ${result.firstName}.`;
|
$('#done-in-message').textContent = `You're all set, ${result.firstName}.`;
|
||||||
const printing = result.serverPrinted || result.badgeUrl;
|
$('#done-in-detail').textContent = result.badgeUrl
|
||||||
$('#done-in-detail').textContent = printing
|
|
||||||
? `${result.hostName} has been recorded as your host. Your badge is printing — please wear it, and sign out when you leave.`
|
? `${result.hostName} has been recorded as your host. Your badge is printing — please wear it, and sign out when you leave.`
|
||||||
: `${result.hostName} has been recorded as your host. Please sign out when you leave.`;
|
: `${result.hostName} has been recorded as your host. Please sign out when you leave.`;
|
||||||
// With server printing the badge is already coming out of the label printer,
|
|
||||||
// so the kiosk neither prints nor offers to.
|
|
||||||
lastBadgeUrl = result.badgeUrl;
|
lastBadgeUrl = result.badgeUrl;
|
||||||
$('#reprint-badge').hidden = !result.badgeUrl;
|
$('#reprint-badge').hidden = !result.badgeUrl;
|
||||||
if (result.badgeUrl) printBadge(result.badgeUrl);
|
if (result.badgeUrl) printBadge(result.badgeUrl);
|
||||||
|
|||||||
+20
-90
@@ -1,112 +1,42 @@
|
|||||||
# Pushes this folder to the Gitea repo.
|
# Pushes this folder into the Gitea repo created for it.
|
||||||
#
|
# Run from PowerShell, inside the visitor-signin folder:
|
||||||
# Run from PowerShell inside the visitor-signin folder:
|
|
||||||
# .\push-to-gitea.ps1
|
# .\push-to-gitea.ps1
|
||||||
# If Windows blocks it:
|
# If Windows blocks it: powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1
|
||||||
# powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1
|
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
$Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git'
|
$Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git'
|
||||||
|
|
||||||
# git reports failure through its exit code, not as a PowerShell error, so every
|
if (-not (Test-Path 'package.json')) {
|
||||||
# call has to be checked. Without this the script reports success after a
|
Write-Error 'Run this from inside the visitor-signin folder.'
|
||||||
# rejected push, which is exactly what it used to do.
|
|
||||||
function Invoke-Git {
|
|
||||||
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments)
|
|
||||||
& git @Arguments
|
|
||||||
return $LASTEXITCODE
|
|
||||||
}
|
|
||||||
|
|
||||||
function Fail($message) {
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host $message -ForegroundColor Red
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Test-Path 'package.json')) { Fail 'Run this from inside the visitor-signin folder.' }
|
|
||||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||||
Fail 'Git is not installed or not on PATH. Get it from https://git-scm.com/download/win'
|
Write-Error 'Git is not installed or not on PATH. Install it from https://git-scm.com/download/win'
|
||||||
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Keeps line endings sane between Windows and the Ubuntu docker host.
|
# Keep line endings sane between Windows and the Ubuntu docker host.
|
||||||
git config --global core.autocrlf input | Out-Null
|
git config --global core.autocrlf input | Out-Null
|
||||||
|
|
||||||
if (-not (Test-Path '.git')) {
|
if (-not (Test-Path '.git')) {
|
||||||
Write-Host 'Setting up a new local repository...'
|
git init -b main
|
||||||
if ((Invoke-Git init -b main) -ne 0) { Fail 'git init failed.' }
|
} else {
|
||||||
|
Write-Host 'This folder is already a git repo, adding a commit to it.'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((git remote) -match '^origin$') {
|
git add .
|
||||||
|
$message = 'Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA'
|
||||||
|
git commit -m $message
|
||||||
|
|
||||||
|
if (git remote | Select-String -Quiet '^origin$') {
|
||||||
git remote set-url origin $Remote
|
git remote set-url origin $Remote
|
||||||
} else {
|
} else {
|
||||||
git remote add origin $Remote
|
git remote add origin $Remote
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------- finish what was started
|
git push -u origin main
|
||||||
|
|
||||||
# A rebase or merge left half-done blocks everything that follows, and the error
|
|
||||||
# git gives is easy to mistake for a push problem. Catch it here and say plainly
|
|
||||||
# what to do.
|
|
||||||
$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 in this folder.' -ForegroundColor Red
|
|
||||||
Write-Host 'Nothing else can happen until it is settled. Your options:' -ForegroundColor Yellow
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host ' git rebase --abort throw the attempt away and go back to how things were'
|
|
||||||
Write-Host ' git status see which files still need attention'
|
|
||||||
Write-Host ' git rebase --continue after fixing the files git listed'
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host 'If you are unsure, "git rebase --abort" is the safe one. It puts the' -ForegroundColor Yellow
|
|
||||||
Write-Host 'folder back exactly as it was before the rebase started.' -ForegroundColor Yellow
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- commit
|
|
||||||
|
|
||||||
git add -A
|
|
||||||
$pending = git status --porcelain
|
|
||||||
if ($pending) {
|
|
||||||
$message = Read-Host 'Describe this change (press enter for a dated default)'
|
|
||||||
if (-not $message) { $message = "Update $(Get-Date -Format 'yyyy-MM-dd HH:mm')" }
|
|
||||||
if ((Invoke-Git commit -m $message) -ne 0) { Fail 'git commit failed.' }
|
|
||||||
Write-Host 'Committed.' -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host 'No file changes to commit. Checking for anything unpushed...' -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------- catch up, then push
|
|
||||||
|
|
||||||
Write-Host 'Checking what is on the server...'
|
|
||||||
if ((Invoke-Git fetch origin) -ne 0) {
|
|
||||||
Fail 'Could not reach Gitea. Check the network and your sign in details.'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (git ls-remote --heads origin main) {
|
|
||||||
$behind = (git rev-list --count HEAD..origin/main 2>$null)
|
|
||||||
if ($behind -and [int]$behind -gt 0) {
|
|
||||||
Write-Host "The server has $behind commit(s) this folder does not. Replaying your work on top..."
|
|
||||||
if ((Invoke-Git pull --rebase origin main) -ne 0) {
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host 'The two histories could not be joined automatically.' -ForegroundColor Red
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host 'See what is on the server that you do not have:' -ForegroundColor Yellow
|
|
||||||
Write-Host ' git log --oneline HEAD..origin/main'
|
|
||||||
Write-Host ''
|
|
||||||
Write-Host 'If that is nothing you need, and this folder is the good copy:' -ForegroundColor Yellow
|
|
||||||
Write-Host ' git push --force-with-lease origin main'
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((Invoke-Git push -u origin main) -ne 0) {
|
|
||||||
Fail 'The push was rejected. Read the message above. Nothing was sent.'
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host 'Pushed successfully.' -ForegroundColor Green
|
Write-Host 'Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin' -ForegroundColor Green
|
||||||
Write-Host 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin'
|
Write-Host 'Sign in with your Gitea username and password, or a token as the password.'
|
||||||
|
|||||||
+11
-69
@@ -1,78 +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
|
|
||||||
|
|
||||||
[ -d .git ] || git init -b main || fail "git init failed."
|
|
||||||
|
|
||||||
if git remote | grep -qx origin; then
|
|
||||||
git remote set-url origin "$REMOTE"
|
|
||||||
else
|
|
||||||
git remote add origin "$REMOTE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 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
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
|
||||||
|
|
||||||
git add -A
|
git init -b main
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
git add .
|
||||||
read -r -p "Describe this change (enter for a dated default): " MSG
|
git commit -m "Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA"
|
||||||
[ -n "$MSG" ] || MSG="Update $(date '+%Y-%m-%d %H:%M')"
|
git remote add origin "$REMOTE" 2>/dev/null || git remote set-url origin "$REMOTE"
|
||||||
git commit -m "$MSG" || fail "git commit failed."
|
git push -u origin main
|
||||||
echo "Committed."
|
|
||||||
else
|
|
||||||
echo "No file changes to commit. Checking for anything unpushed..."
|
|
||||||
fi
|
|
||||||
|
|
||||||
git fetch origin || fail "Could not reach Gitea."
|
|
||||||
|
|
||||||
if git ls-remote --heads origin main | grep -q main; then
|
|
||||||
BEHIND=$(git rev-list --count HEAD..origin/main 2>/dev/null || echo 0)
|
|
||||||
if [ "$BEHIND" -gt 0 ]; then
|
|
||||||
echo "The server has $BEHIND commit(s) this folder does not. Replaying your work on top..."
|
|
||||||
if ! git pull --rebase origin main; then
|
|
||||||
cat >&2 <<'MSG'
|
|
||||||
|
|
||||||
The two histories could not be joined automatically.
|
|
||||||
|
|
||||||
See what is on the server that you do not have:
|
|
||||||
git log --oneline HEAD..origin/main
|
|
||||||
|
|
||||||
If that is nothing you need, and this folder is the good copy:
|
|
||||||
git push --force-with-lease origin main
|
|
||||||
MSG
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
git push -u origin main || fail "The push was rejected. Read the message above. Nothing was sent."
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Pushed successfully."
|
echo "Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
|
||||||
echo "https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
|
|
||||||
|
|||||||
+2
-4
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +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',
|
|
||||||
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',
|
||||||
@@ -178,16 +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'");
|
|
||||||
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)');
|
||||||
|
|||||||
-381
@@ -1,381 +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';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ rendering */
|
|
||||||
|
|
||||||
function wrapText(ctx, text, maxWidth, maxLines) {
|
|
||||||
const words = String(text || '').split(/\s+/).filter(Boolean);
|
|
||||||
const lines = [];
|
|
||||||
let line = '';
|
|
||||||
for (const word of words) {
|
|
||||||
const candidate = line ? `${line} ${word}` : word;
|
|
||||||
if (ctx.measureText(candidate).width <= maxWidth || !line) {
|
|
||||||
line = candidate;
|
|
||||||
} else {
|
|
||||||
lines.push(line);
|
|
||||||
line = word;
|
|
||||||
if (lines.length === maxLines - 1) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (line) lines.push(line);
|
|
||||||
return lines.slice(0, maxLines);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws the badge at its designed size in dots. Mirrors the browser badge so the
|
|
||||||
* preview and the printed label agree.
|
|
||||||
*/
|
|
||||||
async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY = null) {
|
|
||||||
const unit = Math.min(widthDots, heightDots);
|
|
||||||
const pad = Math.round(unit * 0.07);
|
|
||||||
const black = '#000000';
|
|
||||||
const red = accent ? '#ff0000' : '#000000';
|
|
||||||
|
|
||||||
if (startY !== null) {
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.fillRect(0, 0, widthDots, heightDots);
|
|
||||||
}
|
|
||||||
|
|
||||||
const portrait = heightDots >= widthDots * 1.2;
|
|
||||||
const nameSize = Math.max(mm(3.2), Math.round(unit * (portrait ? 0.105 : 0.115)));
|
|
||||||
const bodySize = Math.max(mm(2.0), Math.round(unit * (portrait ? 0.055 : 0.062)));
|
|
||||||
|
|
||||||
let photo = null;
|
|
||||||
const abs = site.badge_show_photo ? photoAbsolutePath(visit.photo_path) : null;
|
|
||||||
if (abs) {
|
|
||||||
try {
|
|
||||||
photo = await loadImage(abs);
|
|
||||||
} catch {
|
|
||||||
photo = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const photoSize = photo ? Math.round(unit * (portrait ? 0.52 : 0.5)) : 0;
|
|
||||||
let cursorY = startY === null ? pad : startY;
|
|
||||||
let textLeft = pad;
|
|
||||||
let textWidth = widthDots - pad * 2;
|
|
||||||
|
|
||||||
const paint = startY !== null;
|
|
||||||
|
|
||||||
if (photo) {
|
|
||||||
if (portrait) {
|
|
||||||
const x = Math.round((widthDots - photoSize) / 2);
|
|
||||||
if (paint) ctx.drawImage(photo, x, cursorY, photoSize, photoSize);
|
|
||||||
if (paint) {
|
|
||||||
ctx.strokeStyle = black;
|
|
||||||
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
|
|
||||||
ctx.strokeRect(x, cursorY, photoSize, photoSize);
|
|
||||||
}
|
|
||||||
cursorY += photoSize + Math.round(unit * 0.05);
|
|
||||||
} else {
|
|
||||||
const y = Math.round((heightDots - photoSize) / 2);
|
|
||||||
if (paint) {
|
|
||||||
ctx.drawImage(photo, pad, y, photoSize, photoSize);
|
|
||||||
ctx.strokeStyle = black;
|
|
||||||
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
|
|
||||||
ctx.strokeRect(pad, y, photoSize, photoSize);
|
|
||||||
}
|
|
||||||
textLeft = pad + photoSize + Math.round(unit * 0.05);
|
|
||||||
textWidth = widthDots - textLeft - pad;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.textBaseline = 'top';
|
|
||||||
ctx.textAlign = portrait ? 'center' : 'left';
|
|
||||||
const centreX = portrait ? widthDots / 2 : textLeft;
|
|
||||||
|
|
||||||
// Site name, with a rule under it.
|
|
||||||
ctx.fillStyle = red;
|
|
||||||
ctx.font = `${Math.round(bodySize * 0.8)}px ${FONT}`;
|
|
||||||
if (paint) ctx.fillText(`${site.name.toUpperCase()} · VISITOR`, centreX, cursorY, textWidth);
|
|
||||||
cursorY += Math.round(bodySize * 0.8 * 1.3);
|
|
||||||
if (paint) ctx.fillRect(textLeft, cursorY, textWidth, Math.max(2, Math.round(mm(0.35))));
|
|
||||||
cursorY += Math.round(unit * 0.04);
|
|
||||||
|
|
||||||
// Name, wrapped to at most two lines.
|
|
||||||
ctx.fillStyle = black;
|
|
||||||
ctx.font = `bold ${nameSize}px ${FONT}`;
|
|
||||||
const nameLines = wrapText(ctx, `${visit.first_name} ${visit.last_name}`, textWidth, 2);
|
|
||||||
for (const line of nameLines) {
|
|
||||||
if (paint) ctx.fillText(line, centreX, cursorY, textWidth);
|
|
||||||
cursorY += Math.round(nameSize * 1.05);
|
|
||||||
}
|
|
||||||
cursorY += Math.round(unit * 0.04);
|
|
||||||
|
|
||||||
// Detail rows.
|
|
||||||
const timeIn = new Date(visit.signed_in_at);
|
|
||||||
const rows = [
|
|
||||||
`Visiting ${visit.host_name}`,
|
|
||||||
`In at ${timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })} on ${timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' })}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
ctx.font = `${bodySize}px ${FONT}`;
|
|
||||||
ctx.fillStyle = black;
|
|
||||||
for (const row of rows) {
|
|
||||||
for (const line of wrapText(ctx, row, textWidth, 2)) {
|
|
||||||
if (paint) ctx.fillText(line, centreX, cursorY, textWidth);
|
|
||||||
cursorY += Math.round(bodySize * 1.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check status: boxed and in the accent colour when they hold nothing.
|
|
||||||
if (visit.check_type === 'NONE') {
|
|
||||||
const label = 'No WWCC / VIT';
|
|
||||||
ctx.font = `bold ${Math.round(bodySize * 0.95)}px ${FONT}`;
|
|
||||||
const w = ctx.measureText(label).width + bodySize;
|
|
||||||
const x = portrait ? Math.round((widthDots - w) / 2) : textLeft;
|
|
||||||
const h = Math.round(bodySize * 1.5);
|
|
||||||
if (paint) {
|
|
||||||
ctx.strokeStyle = red;
|
|
||||||
ctx.lineWidth = Math.max(2, Math.round(mm(0.35)));
|
|
||||||
ctx.strokeRect(x, cursorY, w, h);
|
|
||||||
ctx.fillStyle = red;
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.fillText(label, x + w / 2, cursorY + Math.round(bodySize * 0.25));
|
|
||||||
ctx.textAlign = portrait ? 'center' : 'left';
|
|
||||||
}
|
|
||||||
cursorY += h + Math.round(bodySize * 0.3);
|
|
||||||
} else {
|
|
||||||
ctx.fillStyle = black;
|
|
||||||
ctx.font = `${bodySize}px ${FONT}`;
|
|
||||||
if (paint) {
|
|
||||||
ctx.fillText(`${visit.check_type} ${visit.check_number || ''}`.trim(), centreX, cursorY, textWidth);
|
|
||||||
}
|
|
||||||
cursorY += Math.round(bodySize * 1.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (site.badge_note) {
|
|
||||||
ctx.fillStyle = black;
|
|
||||||
ctx.font = `${Math.round(bodySize * 0.85)}px ${FONT}`;
|
|
||||||
for (const line of wrapText(ctx, site.badge_note, textWidth, 2)) {
|
|
||||||
if (paint) ctx.fillText(line, centreX, cursorY, textWidth);
|
|
||||||
cursorY += Math.round(bodySize * 1.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cursorY - (startY === null ? pad : startY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Produces the PNG that gets sent to the printer.
|
|
||||||
*
|
|
||||||
* The bitmap is always 696 dots across, because that is the printer's fixed head
|
|
||||||
* width on a 62 mm roll. With rotation the badge is laid out along the length of
|
|
||||||
* the label instead and the finished image is turned, so the content still lands
|
|
||||||
* within those 696 dots.
|
|
||||||
*/
|
|
||||||
export async function renderBadgePng(visit, site) {
|
|
||||||
const rotate = Number(site.printer_rotate) || 0;
|
|
||||||
const lengthMm = Number(site.badge_height_mm) || 90;
|
|
||||||
const turned = rotate === 90 || rotate === 270;
|
|
||||||
|
|
||||||
const acrossDots = DOTS_ACROSS_62MM;
|
|
||||||
const alongDots = mm(lengthMm);
|
|
||||||
|
|
||||||
// Design canvas: swapped when the badge is laid out along the label.
|
|
||||||
const designW = turned ? alongDots : acrossDots;
|
|
||||||
const designH = turned ? acrossDots : alongDots;
|
|
||||||
|
|
||||||
const design = createCanvas(designW, designH);
|
|
||||||
const ctx = design.getContext('2d');
|
|
||||||
// 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 (/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}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await runBrotherQl(
|
|
||||||
[
|
|
||||||
'--backend', 'network',
|
|
||||||
'--model', site.printer_model || 'QL-820NWB',
|
|
||||||
'--printer', target,
|
|
||||||
'print',
|
|
||||||
'--label', labelFor(site),
|
|
||||||
file,
|
|
||||||
],
|
|
||||||
config.printing.timeoutMs
|
|
||||||
);
|
|
||||||
note(site.id, true, `Printed to ${site.printer_host}`);
|
|
||||||
return { ok: true, target };
|
|
||||||
} catch (err) {
|
|
||||||
const friendly = explainPrintError(err.message, site.printer_host);
|
|
||||||
note(site.id, false, friendly);
|
|
||||||
throw new Error(friendly);
|
|
||||||
} finally {
|
|
||||||
fs.rm(file, { force: true }, () => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A sample badge, for checking the printer and the layout without a real visit. */
|
|
||||||
export function sampleVisit(site) {
|
|
||||||
return {
|
|
||||||
id: 0,
|
|
||||||
first_name: 'Sample',
|
|
||||||
last_name: 'Visitor',
|
|
||||||
host_name: 'Jess Rogerson',
|
|
||||||
check_type: 'NONE',
|
|
||||||
check_number: null,
|
|
||||||
photo_path: null,
|
|
||||||
signed_in_at: new Date().toISOString(),
|
|
||||||
site_name: site.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function available() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
execFile(config.printing.command, ['--version'], (err) => resolve(!err));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+3
-95
@@ -8,7 +8,6 @@ import { decryptPin, encryptPin, generatePin, pinLookup } from '../pins.js';
|
|||||||
import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js';
|
import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js';
|
||||||
import * as sheets from '../sheets.js';
|
import * as sheets from '../sheets.js';
|
||||||
import * as tls from '../tls.js';
|
import * as tls from '../tls.js';
|
||||||
import * as printer from '../printer.js';
|
|
||||||
import * as users from '../users.js';
|
import * as users from '../users.js';
|
||||||
import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js';
|
import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js';
|
||||||
import {
|
import {
|
||||||
@@ -364,12 +363,7 @@ router.post('/users/:id/reset-2fa', requireOwner, (req, res) => {
|
|||||||
router.get('/sites', (req, res) => {
|
router.get('/sites', (req, res) => {
|
||||||
const scope = scopedSiteId(req);
|
const scope = scopedSiteId(req);
|
||||||
const rows = listSites().filter((s) => !scope || s.id === scope);
|
const rows = listSites().filter((s) => !scope || s.id === scope);
|
||||||
res.json(
|
res.json(rows.map(shapeSite));
|
||||||
rows.map((row) => ({
|
|
||||||
...shapeSite(row),
|
|
||||||
printerStatus: printer.printerStatus(row.id),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/sites', requireOwner, (req, res) => {
|
router.post('/sites', requireOwner, (req, res) => {
|
||||||
@@ -391,13 +385,11 @@ router.patch('/sites/:id', (req, res) => {
|
|||||||
|
|
||||||
const badge = req.body?.badge || {};
|
const badge = req.body?.badge || {};
|
||||||
const branding = req.body?.branding || {};
|
const branding = req.body?.branding || {};
|
||||||
const printerCfg = req.body?.printer || {};
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
||||||
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
||||||
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
|
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
|
||||||
banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?,
|
banner_height = ?, banner_align = ? WHERE id = ?`
|
||||||
printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ? WHERE id = ?`
|
|
||||||
).run(
|
).run(
|
||||||
clean(req.body?.name ?? site.name, 100) || site.name,
|
clean(req.body?.name ?? site.name, 100) || site.name,
|
||||||
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
||||||
@@ -423,19 +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
|
|
||||||
, 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)));
|
||||||
@@ -477,60 +457,6 @@ router.get('/sites/:id/banner', (req, res) => {
|
|||||||
res.sendFile(abs);
|
res.sendFile(abs);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* The exact bitmap that would be sent to the printer, so the layout and the
|
|
||||||
* rotation can be checked without using a label.
|
|
||||||
*/
|
|
||||||
router.get('/sites/:id/badge-bitmap', async (req, res) => {
|
|
||||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
|
||||||
if (!site) return res.status(404).send('Not found.');
|
|
||||||
try {
|
|
||||||
const visit = req.query.visitId
|
|
||||||
? db.prepare('SELECT * FROM visits WHERE id = ?').get(req.query.visitId)
|
|
||||||
: printer.sampleVisit(site);
|
|
||||||
if (!visit) return res.status(404).send('No such visit.');
|
|
||||||
const png = await printer.renderBadgePng(visit, site);
|
|
||||||
res.setHeader('Content-Type', 'image/png');
|
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
|
||||||
res.send(png);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).send(`Could not render the badge: ${err.message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/sites/:id/test-print', async (req, res) => {
|
|
||||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
|
||||||
if (!site) return res.status(404).json({ error: 'Not found.' });
|
|
||||||
try {
|
|
||||||
assertSiteAllowed(req, site.id);
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(403).json({ error: err.message });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await printer.printBadge(printer.sampleVisit(site), site);
|
|
||||||
res.json({ ok: true, ...result });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(400).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Reprints a real visitor's badge on the server's printer. */
|
|
||||||
router.post('/visits/:id/print', async (req, res) => {
|
|
||||||
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id);
|
|
||||||
if (!visit) return res.status(404).json({ error: 'Not found.' });
|
|
||||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(visit.site_id);
|
|
||||||
if (!site) return res.status(404).json({ error: 'That site no longer exists.' });
|
|
||||||
if (!printer.isConfigured(site)) {
|
|
||||||
return res.status(400).json({ error: 'Server printing is not turned on for this site.' });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await printer.printBadge(visit, site);
|
|
||||||
res.json({ ok: true });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(400).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/sites/:id/badge-preview', (req, res) => {
|
router.get('/sites/:id/badge-preview', (req, res) => {
|
||||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||||
if (!site) return res.status(404).send('Not found.');
|
if (!site) return res.status(404).send('Not found.');
|
||||||
@@ -1227,24 +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}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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
@@ -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);
|
||||||
|
|||||||
@@ -121,25 +121,6 @@ function startRedirectServer() {
|
|||||||
|
|
||||||
http
|
http
|
||||||
.createServer((req, res) => {
|
.createServer((req, res) => {
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -59,14 +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',
|
|
||||||
},
|
|
||||||
badge: {
|
badge: {
|
||||||
enabled: Boolean(site.badge_enabled),
|
enabled: Boolean(site.badge_enabled),
|
||||||
widthMm: site.badge_width_mm,
|
widthMm: site.badge_width_mm,
|
||||||
|
|||||||
-25
@@ -194,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),
|
||||||
@@ -237,19 +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']);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user