Public Access
CertZipDL
This commit is contained in:
@@ -216,6 +216,25 @@ 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. `brother_ql`'s network backend only writes to the socket
|
||||||
|
and never reads, which is why a refused job still looks like a success — this asks directly.
|
||||||
|
|
||||||
|
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
@@ -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"
|
||||||
|
|||||||
@@ -415,3 +415,11 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -1434,8 +1434,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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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, 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('');
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* 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]));
|
||||||
|
});
|
||||||
|
|
||||||
|
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(' Some firmware only answers when idle — make sure it is not mid-job,');
|
||||||
|
console.error(' and that nothing else is holding port 9100 open.\n');
|
||||||
|
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('');
|
||||||
|
});
|
||||||
@@ -1275,6 +1275,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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+75
@@ -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
@@ -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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user