3 Commits
Author SHA1 Message Date
jessikitty d998b6731e PhotoDithering 2026-09-16 15:01:06 +10:00
jessikitty 3b2a399769 PrintFxv2 2026-09-16 11:29:52 +10:00
jessikitty 922ce99d25 CertZipDL 2026-09-16 10:05:57 +10:00
17 changed files with 933 additions and 72 deletions
+50
View File
@@ -179,6 +179,31 @@ 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** **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 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 outright: the printer shows **Wrong Roll Type** and nothing comes out. The setting is separate
@@ -216,6 +241,31 @@ different and only one will suit how you hang them.
type and the colour option. It is not itself a saved setting — the three fields it fills are what 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. 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: **Diagnostics from the command line.** When the console is not enough:
```bash ```bash
+3 -1
View File
@@ -9,7 +9,9 @@
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"version": "node scripts/version.mjs", "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" "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"
+30
View File
@@ -415,3 +415,33 @@ pre.raw {
max-height: 320px; max-height: 320px;
overflow: auto; 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;
}
+58 -4
View File
@@ -833,6 +833,24 @@ function openSiteModal(site) {
</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" id="accent-note"></p>
<p class="hint">Two-colour printing is much slower than black alone.</p> <p class="hint">Two-colour printing is much slower than black alone.</p>
@@ -847,8 +865,10 @@ function openSiteModal(site) {
<option value="62" ${site.printer.label !== '62red' ? 'selected' : ''}>62 mm continuous, black only</option> <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> <option value="62red" ${site.printer.label === '62red' ? 'selected' : ''}>62 mm continuous, black and red (DK-22251)</option>
</select></label> </select></label>
<p class="hint">This must match the roll actually in the machine. Send a two-colour job to a <p class="hint">This must match the roll actually in the machine, and it matters in both
plain roll and the printer answers <em>Wrong Roll Type</em> and prints nothing.</p> 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"> <div class="modal-row">
${field('Model', 'printerModel', site.printer.model)} ${field('Model', 'printerModel', site.printer.model)}
<label class="modal-field"><span>Rotation</span> <label class="modal-field"><span>Rotation</span>
@@ -927,6 +947,11 @@ function openSiteModal(site) {
rotate: Number(data.printerRotate) || 0, rotate: Number(data.printerRotate) || 0,
label: data.printerLabel, 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,
@@ -1035,6 +1060,34 @@ 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. // Red is only possible on the two-colour roll, so say so as the two settings change.
const accentBox = $('#badge-accent'); const accentBox = $('#badge-accent');
const rollSelect = $('#modal-form [name="printerLabel"]'); const rollSelect = $('#modal-form [name="printerLabel"]');
@@ -1434,8 +1487,9 @@ 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 primary-link" href="/admin/api/tls/ca-bundle.zip" download>Download all certificates (.zip)</a>
<a class="ghost" href="/admin/api/tls/ca.cer" download>CA certificate (.cer, for Jamf and Apple)</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>`;
+51 -53
View File
@@ -7,15 +7,10 @@
$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 # git is called directly and $LASTEXITCODE checked straight afterwards. Wrapping
# call has to be checked. Without this the script reports success after a # it in a function does not work: a PowerShell function returns everything written
# rejected push, which is exactly what it used to do. # to the output stream, so the caller receives git's console output as well as the
function Invoke-Git { # exit code, and comparing that array against 0 reports failure every time.
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments)
& git @Arguments
return $LASTEXITCODE
}
function Fail($message) { function Fail($message) {
Write-Host '' Write-Host ''
Write-Host $message -ForegroundColor Red Write-Host $message -ForegroundColor Red
@@ -27,86 +22,89 @@ 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' Fail 'Git is not installed or not on PATH. Get it from https://git-scm.com/download/win'
} }
# Keeps 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...' Write-Host 'Setting up a new local repository...'
if ((Invoke-Git init -b main) -ne 0) { Fail 'git init failed.' } git init -b main
if ($LASTEXITCODE -ne 0) { Fail 'git init failed.' }
} }
if ((git remote) -match '^origin$') { if ((git remote) -match '^origin$') { git remote set-url origin $Remote }
git remote set-url origin $Remote else { git remote add origin $Remote }
} else {
git remote add origin $Remote
}
# ------------------------------------------------- finish what was started # 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
# A rebase or merge left half-done blocks everything that follows, and the error # ------------------------------------------- an unfinished rebase blocks everything
# 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
$gitDir = (git rev-parse --git-dir 2>$null)
if ($gitDir) { if ($gitDir) {
$stuck = @('rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD') | $stuck = @('rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD') |
Where-Object { Test-Path (Join-Path $gitDir $_) } Where-Object { Test-Path (Join-Path $gitDir $_) }
if ($stuck) { if ($stuck) {
Write-Host '' Write-Host ''
Write-Host 'There is an unfinished rebase or merge in this folder.' -ForegroundColor Red Write-Host 'There is an unfinished rebase or merge here.' -ForegroundColor Red
Write-Host 'Nothing else can happen until it is settled. Your options:' -ForegroundColor Yellow Write-Host ' git rebase --abort throw it away, back to how things were'
Write-Host '' Write-Host ' git status see which files need attention'
Write-Host ' git rebase --abort throw the attempt away and go back to how things were' Write-Host ' git rebase --continue after fixing those files'
Write-Host ' git status see which files still need attention' Fail 'Nothing was done.'
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 # ---------------------------------------------------------------- commit
git add -A git add -A
$pending = git status --porcelain if (git status --porcelain) {
if ($pending) {
$message = Read-Host 'Describe this change (press enter for a dated default)' $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 (-not $message) { $message = "Update $(Get-Date -Format 'yyyy-MM-dd HH:mm')" }
if ((Invoke-Git commit -m $message) -ne 0) { Fail 'git commit failed.' } git commit -m $message
if ($LASTEXITCODE -ne 0) { Fail 'git commit failed. The message above says why.' }
Write-Host 'Committed.' -ForegroundColor Green Write-Host 'Committed.' -ForegroundColor Green
} else { } else {
Write-Host 'No file changes to commit. Checking for anything unpushed...' -ForegroundColor Yellow Write-Host 'Nothing new to commit.' -ForegroundColor Yellow
} }
# ------------------------------------------------------- catch up, then push # ------------------------------------------------------- catch up, then push
Write-Host 'Checking what is on the server...' git fetch origin
if ((Invoke-Git fetch origin) -ne 0) { if ($LASTEXITCODE -ne 0) { Fail 'Could not reach Gitea. Check the network and your sign in details.' }
Fail 'Could not reach Gitea. Check the network and your sign in details.'
}
if (git ls-remote --heads origin main) { if (git ls-remote --heads origin $branch) {
$behind = (git rev-list --count HEAD..origin/main 2>$null) $behind = git rev-list --count "HEAD..origin/$branch" 2>$null
if ($behind -and [int]$behind -gt 0) { if ($behind -and [int]$behind -gt 0) {
Write-Host "The server has $behind commit(s) this folder does not. Replaying your work on top..." 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) { git pull --rebase origin $branch
if ($LASTEXITCODE -ne 0) {
Write-Host '' Write-Host ''
Write-Host 'The two histories could not be joined automatically.' -ForegroundColor Red Write-Host 'The two histories could not be joined automatically.' -ForegroundColor Red
Write-Host '' Write-Host " git log --oneline HEAD..origin/$branch what is on the server"
Write-Host 'See what is on the server that you do not have:' -ForegroundColor Yellow Write-Host " git push --force-with-lease origin $branch if this folder is the good copy"
Write-Host ' git log --oneline HEAD..origin/main' Fail 'Nothing was sent.'
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
} }
} }
} else {
Write-Host "Branch '$branch' is not on the server yet; it will be created."
} }
if ((Invoke-Git push -u origin main) -ne 0) { git push -u origin $branch
Fail 'The push was rejected. Read the message above. Nothing was sent.' if ($LASTEXITCODE -ne 0) { Fail 'The push was rejected. The message above says why. Nothing was sent.' }
}
Write-Host '' Write-Host ''
Write-Host 'Pushed successfully.' -ForegroundColor Green Write-Host "Pushed $branch successfully." -ForegroundColor Green
git log --oneline -1
Write-Host 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin' Write-Host 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin'
+22 -7
View File
@@ -11,6 +11,15 @@ command -v git >/dev/null || fail "Git is not installed."
git config --global core.autocrlf input >/dev/null 2>&1 || true 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
fi
[ -d .git ] || git init -b main || fail "git init failed." [ -d .git ] || git init -b main || fail "git init failed."
if git remote | grep -qx origin; then if git remote | grep -qx origin; then
@@ -19,6 +28,11 @@ else
git remote add origin "$REMOTE" git remote add origin "$REMOTE"
fi 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 # An unfinished rebase or merge blocks everything below, and git's own error is
# easy to mistake for a push problem. # easy to mistake for a push problem.
GIT_DIR_PATH=$(git rev-parse --git-dir 2>/dev/null || echo .git) GIT_DIR_PATH=$(git rev-parse --git-dir 2>/dev/null || echo .git)
@@ -51,28 +65,29 @@ fi
git fetch origin || fail "Could not reach Gitea." git fetch origin || fail "Could not reach Gitea."
if git ls-remote --heads origin main | grep -q main; then if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
BEHIND=$(git rev-list --count HEAD..origin/main 2>/dev/null || echo 0) BEHIND=$(git rev-list --count "HEAD..origin/$BRANCH" 2>/dev/null || echo 0)
if [ "$BEHIND" -gt 0 ]; then if [ "$BEHIND" -gt 0 ]; then
echo "The server has $BEHIND commit(s) this folder does not. Replaying your work on top..." echo "The server has $BEHIND commit(s) this folder does not. Replaying your work on top..."
if ! git pull --rebase origin main; then if ! git pull --rebase origin "$BRANCH"; then
cat >&2 <<'MSG' cat >&2 <<'MSG'
The two histories could not be joined automatically. The two histories could not be joined automatically.
See what is on the server that you do not have: See what is on the server that you do not have:
git log --oneline HEAD..origin/main git log --oneline HEAD..origin/$BRANCH
If that is nothing you need, and this folder is the good copy: If that is nothing you need, and this folder is the good copy:
git push --force-with-lease origin main git push --force-with-lease origin $BRANCH
MSG MSG
exit 1 exit 1
fi fi
fi fi
fi fi
git push -u origin main || fail "The push was rejected. Read the message above. Nothing was sent." git push -u origin "$BRANCH" || fail "The push was rejected. Read the message above. Nothing was sent."
echo echo
echo "Pushed successfully." echo "Pushed $BRANCH successfully."
git log --oneline -1
echo "https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin" echo "https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
+100
View File
@@ -0,0 +1,100 @@
/**
* 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('');
+3 -1
View File
@@ -73,8 +73,10 @@ const args = [
'--printer', target, '--printer', target,
'print', 'print',
'--label', label, '--label', label,
file,
]; ];
// 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('');
console.log(` running: ${config.printing.command} ${args.join(' ')}`); console.log(` running: ${config.printing.command} ${args.join(' ')}`);
+172
View File
@@ -0,0 +1,172 @@
/**
* 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('');
});
+8
View File
@@ -28,6 +28,9 @@ CREATE TABLE IF NOT EXISTS sites (
printer_model TEXT NOT NULL DEFAULT 'QL-820NWB', printer_model TEXT NOT NULL DEFAULT 'QL-820NWB',
printer_rotate INTEGER NOT NULL DEFAULT 0, printer_rotate INTEGER NOT NULL DEFAULT 0,
printer_label TEXT NOT NULL DEFAULT '62', 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',
@@ -188,6 +191,11 @@ addColumn('sites', 'printer_rotate', 'INTEGER NOT NULL DEFAULT 0');
// the printer refuses a two-colour job on a plain roll, so guessing the media // 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. // from a design setting means a wrong-roll error nobody can explain.
addColumn('sites', 'printer_label', "TEXT NOT NULL DEFAULT '62'"); 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)');
+166 -3
View File
@@ -63,6 +63,155 @@ export function accentWillPrintRed(site) {
return Boolean(site?.badge_accent) && labelFor(site) === '62red'; 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 */ /* ------------------------------------------------------------ rendering */
function wrapText(ctx, text, maxWidth, maxLines) { function wrapText(ctx, text, maxWidth, maxLines) {
@@ -122,7 +271,10 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY
if (photo) { if (photo) {
if (portrait) { if (portrait) {
const x = Math.round((widthDots - photoSize) / 2); const x = Math.round((widthDots - photoSize) / 2);
if (paint) ctx.drawImage(photo, x, cursorY, photoSize, photoSize); if (paint) {
ctx.drawImage(photo, x, cursorY, photoSize, photoSize);
halftone(ctx, x, cursorY, photoSize, photoSettings(site));
}
if (paint) { if (paint) {
ctx.strokeStyle = black; ctx.strokeStyle = black;
ctx.lineWidth = Math.max(2, Math.round(mm(0.3))); ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
@@ -133,6 +285,7 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY
const y = Math.round((heightDots - photoSize) / 2); const y = Math.round((heightDots - photoSize) / 2);
if (paint) { if (paint) {
ctx.drawImage(photo, pad, y, photoSize, photoSize); ctx.drawImage(photo, pad, y, photoSize, photoSize);
halftone(ctx, pad, y, photoSize, photoSettings(site));
ctx.strokeStyle = black; ctx.strokeStyle = black;
ctx.lineWidth = Math.max(2, Math.round(mm(0.3))); ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
ctx.strokeRect(pad, y, photoSize, photoSize); ctx.strokeRect(pad, y, photoSize, photoSize);
@@ -291,6 +444,9 @@ function explainPrintError(output, host) {
if (/Unknown label|label/i.test(last) && /identifier/i.test(last)) { if (/Unknown label|label/i.test(last) && /identifier/i.test(last)) {
return 'The printer rejected the label size. Check the roll loaded matches the badge settings.'; return '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)) { 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 '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.';
} }
@@ -336,15 +492,22 @@ export async function printBadge(visit, site) {
const port = Number(site.printer_port) || 9100; const port = Number(site.printer_port) || 9100;
const target = `tcp://${site.printer_host}:${port}`; const target = `tcp://${site.printer_host}:${port}`;
const label = labelFor(site);
const args = [ const args = [
'--backend', 'network', '--backend', 'network',
'--model', site.printer_model || 'QL-820NWB', '--model', site.printer_model || 'QL-820NWB',
'--printer', target, '--printer', target,
'print', 'print',
'--label', labelFor(site), '--label', label,
file,
]; ];
// 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 { try {
await runBrotherQl(args, config.printing.timeoutMs); await runBrotherQl(args, config.printing.timeoutMs);
note(site.id, true, `Printed to ${site.printer_host}`); note(site.id, true, `Printed to ${site.printer_host}`);
+59 -3
View File
@@ -392,12 +392,14 @@ 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 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 = ?, printer_enabled = ?, printer_host = ?,
printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ? 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,
@@ -435,7 +437,16 @@ router.patch('/sites/:id', (req, res) => {
: site.printer_rotate, : site.printer_rotate,
printerCfg.label !== undefined printerCfg.label !== undefined
? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62') ? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62')
: site.printer_label : 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)));
@@ -539,7 +550,7 @@ router.get('/sites/:id/printer-diagnostics', (req, res) => {
'brother_ql --backend network', 'brother_ql --backend network',
`--model ${site.printer_model || 'QL-820NWB'}`, `--model ${site.printer_model || 'QL-820NWB'}`,
`--printer tcp://${site.printer_host}:${site.printer_port || 9100}`, `--printer tcp://${site.printer_host}:${site.printer_port || 9100}`,
`print --label ${printer.labelFor(site)} badge.png`, `print --label ${printer.labelFor(site)}${printer.labelFor(site) === '62red' ? ' --red' : ''} badge.png`,
].join(' '), ].join(' '),
}); });
}); });
@@ -561,6 +572,37 @@ router.post('/visits/:id/print', async (req, res) => {
} }
}); });
/** 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.');
@@ -1275,6 +1317,20 @@ router.get(['/tls/ca.cer', '/tls/ca.der'], (req, res) => {
} }
}); });
/** 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
+20
View File
@@ -133,6 +133,26 @@ 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. // DER for Apple tooling, PEM for everything else.
if (req.url === '/ca.cer' || req.url === '/ca.der') { if (req.url === '/ca.cer' || req.url === '/ca.der') {
try { try {
+5
View File
@@ -67,6 +67,11 @@ export function shapeSite(site) {
rotate: site.printer_rotate || 0, rotate: site.printer_rotate || 0,
label: site.printer_label || '62', 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,
+75
View File
@@ -4,6 +4,7 @@ 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.
@@ -250,6 +251,80 @@ export function caCertificateDer() {
return openssl(['x509', '-in', p.caCert, '-outform', 'der']); 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
@@ -0,0 +1,108 @@
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
@@ -0,0 +1,3 @@
b1a7933 (HEAD -> deploy) Printer Debug 2
eb98658 Printing Debug
8bc7179 (origin/deploy) Server-side printing, roll type setting, cache headers