Compare commits
7
Commits
8c5cdf16c4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf1b13f86 | ||
|
|
046e523352 | ||
|
|
e41e2b38e3 | ||
|
|
0ee9b708a1 | ||
|
|
8f5517a409 | ||
|
|
0e8d826bce | ||
|
|
e68daee457 |
No files matched your search
@@ -18,10 +18,8 @@ gedacht für den Einsatz im Ladengeschäft (Tresen-Tablet, mehrere Plätze paral
|
||||
* **Keine Datenbank-Installation nötig.** SQLite, eine einzelne Datei.
|
||||
* **Keine Build-Kette.** Kein Webpack, kein TypeScript, kein npm run build.
|
||||
Vanilla JS im Frontend, Express im Backend.
|
||||
* **Abhängigkeiten:** 8 npm-Pakete, alle etabliert (express, express-session,
|
||||
express-rate-limit, sqlite3, bcrypt, dotenv, openid-client, ssh2). `ssh2` ist
|
||||
reines JavaScript und wird nur für die Einrichtung eines Drucker-Pi gebraucht
|
||||
(Abschnitt 6).
|
||||
* **Abhängigkeiten:** 7 npm-Pakete, alle etabliert (express, express-session,
|
||||
express-rate-limit, sqlite3, bcrypt, dotenv, openid-client).
|
||||
|
||||
Realistisch: In 15 Minuten deployt, wenn die drei Stolpersteine aus Abschnitt 3
|
||||
beachtet werden. Ohne sie kann es beliebig lange dauern, weil zwei davon **stumm**
|
||||
@@ -213,7 +211,9 @@ Passwort fehlen — lieber laut scheitern als unsicher laufen.
|
||||
| `PRINTER_PORT` | `9100` | Port der Brücke auf dem Pi |
|
||||
| `PRINTER_TOKEN` | leer | muss mit `PRINT_TOKEN` in `/etc/bondrucker/env` übereinstimmen |
|
||||
| `PRINTER_WIDTH` | `42` | Zeichen je Zeile: `42` bei 80-mm-Papier, `32` bei 58 mm |
|
||||
| `PRINTER_DEVICE` | leer | Gerät am Pi, z. B. `/dev/usb/lp0`. Leer = erstes gefundenes. Wird über die Oberfläche gepflegt |
|
||||
| `PRINTER_TIMEOUT_MS` | `6000` | Abbruch, wenn der Pi nicht antwortet |
|
||||
| `PUBLIC_URL` | leer | Adresse, unter der der Pi die App erreicht. Leer = aus der Anfrage abgeleitet (siehe Abschnitt 6) |
|
||||
|
||||
**Die vier `PRINTER_*`-Werte oberhalb von `PRINTER_TIMEOUT_MS` sind nur
|
||||
Startwerte beim allerersten Start** — genau wie `RATE_PER_10MIN`. Danach lebt
|
||||
@@ -265,6 +265,23 @@ Wichtig in beiden Fällen:
|
||||
* `/health` ist der Endpunkt für Health-Checks (antwortet **ohne** Anmeldung)
|
||||
* Websockets werden **nicht** gebraucht
|
||||
|
||||
### Portal-Kopfleiste
|
||||
|
||||
`frontend/index.html` lädt die zentrale Handy-Insel-Kopfleiste
|
||||
(`https://portal.handy-insel.de/topbar.js`, `data-app="timer"`). Sie bringt
|
||||
App-Umschalter und Benutzer-Menü mit; Benutzername und „Abmelden“ der App
|
||||
stehen deshalb dort, die eigenen im Header erscheinen nur, wenn das Skript
|
||||
nicht lädt.
|
||||
|
||||
* **Abmelden** in der Leiste ruft vorher `POST /api/logout` auf (Ereignis
|
||||
`hi-abmelden`) und meldet danach bei Portal und Authentik ab.
|
||||
* **Launch-URL** der Kachel im Portal: `https://timer.handy-insel.de/auth/login`
|
||||
(startet direkt die Anmeldung über Authentik).
|
||||
* **Content-Security-Policy:** Die App setzt keine. Kommt im Proxy eine dazu,
|
||||
muss sie `script-src`/`connect-src https://portal.handy-insel.de` und
|
||||
`img-src https://portal.handy-insel.de https://login.handy-insel.de`
|
||||
erlauben (Details in den Proxy-Beispielen).
|
||||
|
||||
### Bondrucker
|
||||
|
||||
Vollständige Einrichtung: **`deploy/bondrucker/README.md`**. Hier nur das
|
||||
@@ -321,38 +338,92 @@ der App liegt.
|
||||
|
||||
#### Einen Pi einrichten
|
||||
|
||||
⚙️ Einstellungen → **🔧 Drucker einrichten** richtet einen frisch aufgesetzten
|
||||
Raspberry Pi vollständig selbst ein: IP, Benutzer und Passwort eintragen,
|
||||
Knopf drücken, fertig — rund drei Sekunden.
|
||||
⚙️ Einstellungen → **🔧 Drucker einrichten** → **🔗 Einrichtungslink erzeugen**.
|
||||
Die App zeigt einen fertigen Befehl an, der auf dem Pi ausgeführt wird:
|
||||
|
||||
Die App meldet sich per SSH an, prüft Python/systemd/Druckergerät, überträgt die
|
||||
vier Dateien aus `deploy/bondrucker/` per SFTP nach `/tmp`, startet `install.sh`
|
||||
mit einem selbst erzeugten Token, räumt auf und prüft von außen nach. Danach
|
||||
übernimmt sie den Drucker selbst. Jeder Schritt steht mit Ergebnis im Protokoll.
|
||||
```bash
|
||||
curl -fsSL "http://<timer-app>/setup/bondrucker.sh?code=…" -o bondrucker.sh
|
||||
sudo bash bondrucker.sh
|
||||
```
|
||||
|
||||
Das Skript prüft Python/systemd, legt Dienstbenutzer, Programm, udev-Regel und
|
||||
systemd-Unit an, erzeugt ein Token und meldet sich damit selbst bei der App an.
|
||||
Es läuft ohne Rückfrage durch. Die Oberfläche pollt `/api/printer/setup-status`
|
||||
und springt um, sobald der Pi sich gemeldet hat.
|
||||
|
||||
#### Welcher Drucker bedient wird
|
||||
|
||||
Das entscheidet die **App**, nicht der Pi. `GET /api/printer/devices` reicht die
|
||||
Frage an die Brücke weiter, die alle `/dev/usb/lp*` mit Klartextnamen aus dem
|
||||
sysfs zurückgibt („EPSON TM-T88IV" statt „lp0" — bei zwei Druckern der
|
||||
Unterschied zwischen Wissen und Raten). Die Auswahl landet in der Einstellung
|
||||
`printer_device` und geht bei **jedem** Auftrag im Kopf `X-Print-Device` mit.
|
||||
|
||||
Die Brücke hält dazu keinen Zustand: es gibt genau eine Stelle, die weiß,
|
||||
welcher Drucker gemeint ist, und das ist die, an der es bedient wird. Ein
|
||||
`PRINT_DEVICE` in `/etc/bondrucker/env` schreibt das Einrichtungsskript
|
||||
deshalb nicht mehr.
|
||||
|
||||
Leer heißt „automatisch": die Brücke nimmt das erste vorhandene `/dev/usb/lp*`.
|
||||
Das ist auch der Rückfall, wenn nach dem Aus- und Einstecken aus `lp0` ein
|
||||
`lp1` geworden ist.
|
||||
|
||||
Der Pfad kommt damit von außen und wird an **beiden** Enden gegen
|
||||
`^/dev/(usb/)?lp[0-9]{1,3}$` geprüft — in `PUT /api/settings` und noch einmal in
|
||||
der Brücke. Eine Schranke, die nur an einem Ende steht, ist keine: ohne die
|
||||
Prüfung auf dem Pi ließe sich der Dienst dazu bringen, in eine beliebige Datei
|
||||
zu schreiben.
|
||||
|
||||
**Warum der Pi sich meldet und nicht umgekehrt.** Vorher richtete die App den
|
||||
Pi per SSH ein. Das verlangte ein SSH-Passwort in einem Webformular, eine
|
||||
ssh2-Abhängigkeit und eine Verbindung vom Server zum Pi. Alle drei sind weg.
|
||||
Wer einen Drucker ansteckt, steht ohnehin am Pi — und genau dort gehört auch
|
||||
die Frage hin, welcher Drucker es sein soll.
|
||||
|
||||
**Das Skript ist zusammengesetzt, nicht statisch.** `deploy/bondrucker/einrichten.sh`
|
||||
ist eine Vorlage mit vier Marken: `@@APP_URL@@`, `@@SETUP_CODE@@`,
|
||||
`@@PRINT_SERVER@@` und `@@SERVICE@@`. `backend/setup.js` füllt sie beim Abholen
|
||||
und bettet `print-server.py` und `bondrucker.service` als Here-Dokumente ein.
|
||||
Heraus kommt **eine** Datei — auf dem Pi ist nichts nachzuladen. Enthielte eine
|
||||
eingebettete Datei zufällig eine Zeile mit dem Schlusswort des Here-Dokuments,
|
||||
bräche das Skript mittendrin ab; `einsetzen()` prüft das und verweigert lieber
|
||||
die Auslieferung.
|
||||
|
||||
**Der Einrichtungscode.** 32 Zeichen, gilt **einmal** und **30 Minuten**, liegt
|
||||
nur im Arbeitsspeicher — ein Neustart der App macht ihn ungültig. Verglichen
|
||||
wird zeitkonstant. Es gibt immer nur einen; ein neuer entwertet den alten. Wer
|
||||
ihn hat, kann den Drucker der App umbiegen, deshalb: nicht weiterreichen.
|
||||
|
||||
**Zwei Endpunkte ohne Anmeldung.** `GET /setup/bondrucker.sh` und
|
||||
`POST /api/setup/register` müssen ohne Session auskommen — der Pi kann sich bei
|
||||
Authentik nicht anmelden. Der Code ist dort der einzige Schlüssel, beide sind
|
||||
über `setupLimiter` begrenzt (30 Versuche je 15 Minuten und IP).
|
||||
|
||||
**Die Adresse des Pi kommt aus der Verbindung**, nicht aus dem Rumpf der
|
||||
Anmeldung: was der Pi über sich behauptet, könnte auch jemand anders behaupten.
|
||||
Nur wenn die Anmeldung über die Rückschleife kam (Tunnel, Testaufbau), zählt
|
||||
die Selbstauskunft `hostHint`.
|
||||
|
||||
**Die eigene Adresse leitet die App aus dem Host-Kopf ab** (`appBasisUrl`) —
|
||||
hinter einem nginx sieht sie von sich aus nur `127.0.0.1`. Wer die Einrichtung
|
||||
startet, hat die App gerade offen; unter genau dieser Adresse findet der Pi sie
|
||||
auch. Passt das nicht, setzt `PUBLIC_URL` in der `.env` den Wert fest. Der Wert
|
||||
landet in einem Shell-Skript und wird deshalb gegen
|
||||
`^https?://[A-Za-z0-9.\-]+(:\d{1,5})?$` geprüft, nicht maskiert.
|
||||
|
||||
Warum der Druckerzugang in der Datenbank liegt und nicht in der `.env`: Sonst
|
||||
wäre genau dieser Knopf sinnlos — die Werte müssten hinterher doch wieder von
|
||||
Hand auf dem Server eingetragen werden. Ein Druckerwechsel im Laden soll kein
|
||||
wäre die Anmeldung des Pi sinnlos — die Werte müssten hinterher doch von Hand
|
||||
auf dem Server eingetragen werden. Ein Druckerwechsel im Laden soll kein
|
||||
SSH-Zugang zum Timer-Server sein.
|
||||
|
||||
Zum Umgang mit den Zugangsdaten: Benutzername und Passwort stehen nur während
|
||||
des Vorgangs im Arbeitsspeicher. Sie werden nicht gespeichert, nicht ins Journal
|
||||
geschrieben und nicht an den Browser zurückgegeben; das erzeugte Drucker-Token
|
||||
wird vor der Ausgabe aus dem Protokoll entfernt. Dauerhaft bleibt allein dieses
|
||||
Token in der Datenbank.
|
||||
|
||||
Der Hostschlüssel des Pi wird beim Erstkontakt angenommen und im Protokoll
|
||||
ausgewiesen (`SHA256:…`) — im eigenen Netz vertretbar, aber es ist eben *kein*
|
||||
Schutz gegen einen Angreifer, der schon im Netz sitzt.
|
||||
|
||||
`install.sh` ist wiederholbar und **startet den Dienst dabei neu**. Ohne den
|
||||
Das Skript ist wiederholbar und **startet den Dienst dabei neu**. Ohne den
|
||||
Neustart liefe die Brücke mit dem alten Token weiter und würde jeden Bon mit
|
||||
HTTP 403 ablehnen — die Gegenprobe nach der Einrichtung prüft deshalb nicht nur
|
||||
die Erreichbarkeit, sondern auch, ob das neue Token angenommen wird.
|
||||
HTTP 403 ablehnen — es prüft deshalb nach dem Start selbst, ob das neue Token
|
||||
angenommen wird, und die App prüft danach noch einmal von außen.
|
||||
|
||||
**Der Pi hängt per WLAN und DHCP im Netz.** Wechselt seine Adresse, druckt die
|
||||
App nicht mehr, bis `PRINTER_HOST` angepasst ist — eine DHCP-Reservierung im
|
||||
Router erspart das.
|
||||
App nicht mehr, bis der Drucker neu eingerichtet oder die Adresse in den
|
||||
Einstellungen angepasst ist — eine DHCP-Reservierung im Router erspart das.
|
||||
|
||||
### Backup
|
||||
|
||||
@@ -383,7 +454,7 @@ timer-app/
|
||||
│ ├── escpos.js ← ESC/POS-Bausteine für den Bondrucker
|
||||
│ ├── receipt.js ← Layout des Bons
|
||||
│ ├── printer.js ← Aufruf der Brücke auf dem Pi
|
||||
│ ├── provision.js ← richtet einen Drucker-Pi per SSH ein
|
||||
│ ├── setup.js ← Einrichtungscode und Skript für den Pi
|
||||
│ ├── package.json
|
||||
│ ├── package-lock.json
|
||||
│ ├── .env.example
|
||||
@@ -401,7 +472,7 @@ timer-app/
|
||||
├── README.md ← Einrichtung des Pi
|
||||
├── print-server.py ← der Dienst (nur Python-Standardbibliothek)
|
||||
├── bondrucker.service
|
||||
└── install.sh
|
||||
└── einrichten.sh ← Vorlage, die die App beim Abholen füllt
|
||||
```
|
||||
|
||||
`server.js` ist in beschriftete Abschnitte gegliedert (Konfiguration, Datenbank,
|
||||
@@ -474,10 +545,14 @@ Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und
|
||||
| `POST` | `/api/timers/:id/receipt` | Zwischenbon drucken, Timer bleibt unangetastet |
|
||||
| `DELETE` | `/api/timers/:id` | verwerfen, **nichts** speichern |
|
||||
| `GET` | `/api/settings` | Tarif, Bon-Texte und Druckerzustand lesen |
|
||||
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop, receiptBarcodeEnabled, receiptBarcodeArticle}` |
|
||||
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop, receiptBarcodeEnabled, receiptBarcodeArticle, printerDevice}` |
|
||||
| `GET` | `/api/printer/status` | Erreichbarkeit und Zustand des Druckers |
|
||||
| `GET` | `/api/printer/devices` | welche Drucker am Pi hängen, mit Klartextnamen |
|
||||
| `POST` | `/api/printer/test` | Probedruck |
|
||||
| `POST` | `/api/printer/provision` | `{host, port, username, password}` → richtet den Pi ein und übernimmt ihn. Antwort enthält das Schrittprotokoll, nie die Zugangsdaten |
|
||||
| `POST` | `/api/printer/setup-code` | erzeugt einen Einrichtungscode, liefert Abhol-Link und fertigen Befehl |
|
||||
| `GET` | `/api/printer/setup-status` | hat sich schon ein Pi gemeldet? |
|
||||
| `GET` | `/setup/bondrucker.sh` | **ohne Anmeldung**, Code als Parameter → das fertige Einrichtungsskript |
|
||||
| `POST` | `/api/setup/register` | **ohne Anmeldung**, `{code, port, token, device}` → der Pi meldet sich selbst an |
|
||||
| `GET` | `/api/sessions` | Historie; Parameter `from`, `to`, `q`, `limit`, `offset` |
|
||||
| `DELETE` | `/api/sessions/:id` | Eintrag löschen |
|
||||
| `POST` | `/api/sessions/:id/receipt` | Bon aus der Historie nachdrucken |
|
||||
|
||||
@@ -110,23 +110,13 @@ Darunter steht, was auf dem Bon gedruckt wird:
|
||||
* **Strichcode für die Kasse drucken** — siehe unten. Ausgeschaltet bleibt der
|
||||
Bon genau wie vorher.
|
||||
* **Artikel in der Kasse** — der Name, der im Strichcode steckt.
|
||||
* **Drucker am Pi** — an welchem Gerät gedruckt wird. **🔄 Suchen** fragt den
|
||||
Pi, was gerade angeschlossen ist, und zeigt die Drucker mit Namen an
|
||||
(„EPSON TM-T88IV"). „Automatisch" nimmt den ersten gefundenen — richtig,
|
||||
solange nur ein Drucker am Pi hängt.
|
||||
* **Probedruck** — druckt einen Beispielbon. Beantwortet die Frage, ob der
|
||||
Drucker überhaupt erreichbar ist; darüber steht sein aktueller Zustand.
|
||||
|
||||
### Einen neuen Drucker anschließen
|
||||
|
||||
Wenn der Drucker-Rechner getauscht wird oder ein zweiter Laden dazukommt:
|
||||
**🔧 Drucker einrichten** anklicken, Adresse, Benutzer und Passwort des
|
||||
Raspberry Pi eintragen, **🚀 Jetzt einrichten** drücken. Nach wenigen Sekunden
|
||||
ist der Drucker eingerichtet und übernommen — es ist nichts weiter zu tun.
|
||||
|
||||
Das Protokoll darunter zeigt jeden Schritt. Bleibt einer rot stehen, steht dort
|
||||
auch, woran es lag. Das Passwort wird nur für diesen Vorgang verwendet und
|
||||
danach verworfen.
|
||||
|
||||
Voraussetzung: Auf dem Pi läuft ein frisches Raspberry Pi OS (oder Debian), der
|
||||
eingetragene Benutzer darf `sudo`, und der Bondrucker hängt am USB-Anschluss.
|
||||
|
||||
Wichtig zu wissen:
|
||||
|
||||
* **Jede angefangene Einheit wird voll berechnet.** 10 Minuten und 1 Sekunde
|
||||
@@ -135,7 +125,38 @@ Wichtig zu wissen:
|
||||
* Eine Änderung wirkt sofort auf laufende und neue Timer. Bereits gespeicherte
|
||||
Einträge in der Historie behalten den Tarif, der beim Beenden galt.
|
||||
|
||||
### Einen neuen Drucker anschließen
|
||||
|
||||
Wenn der Drucker-Rechner getauscht wird oder ein zweiter Laden dazukommt:
|
||||
**🔧 Drucker einrichten** anklicken, dann **🔗 Einrichtungslink erzeugen**.
|
||||
|
||||
Es erscheint ein Befehl. Der wird auf dem Raspberry Pi in ein Terminal
|
||||
eingegeben — über **📋 Kopieren** landet er in der Zwischenablage:
|
||||
|
||||
```
|
||||
curl -fsSL "…" -o bondrucker.sh && sudo bash bondrucker.sh
|
||||
```
|
||||
|
||||
Das Skript installiert die Software und meldet den Pi anschließend bei der
|
||||
Timer-App an. Die Anzeige hier springt von „wartet auf den Pi" auf den fertigen
|
||||
Drucker um.
|
||||
|
||||
Danach oben unter **Drucker am Pi** auf **🔄 Suchen** tippen und den Drucker
|
||||
auswählen. Hängt nur einer am Pi, kann „Automatisch" stehen bleiben.
|
||||
|
||||
Der Link **gilt nur einmal und eine halbe Stunde lang**. Ist er abgelaufen,
|
||||
einfach einen neuen erzeugen. Weitergeben sollte man ihn nicht: wer ihn hat,
|
||||
kann einstellen, auf welchem Gerät hier gedruckt wird.
|
||||
|
||||
Voraussetzung: Auf dem Pi läuft ein frisches Raspberry Pi OS (oder Debian) und
|
||||
der Bondrucker hängt am USB-Anschluss.
|
||||
|
||||
## Abmelden
|
||||
|
||||
**🚪 Abmelden** meldet nur dieses Gerät ab. Laufende Timer bleiben davon
|
||||
unberührt und laufen weiter.
|
||||
Ganz oben steht die Handy-Insel-Leiste. Über den Avatar rechts öffnet sich das
|
||||
Benutzer-Menü, dort steht **Abmelden**. Das meldet dieses Gerät beim Timer, im
|
||||
Portal und bei Authentik ab. Laufende Timer bleiben davon unberührt und laufen
|
||||
weiter. Laufen noch welche, fragt die App vorher nach.
|
||||
|
||||
Ist das Portal einmal nicht erreichbar, fehlt die Leiste. Dann steht
|
||||
**🚪 Abmelden** wie früher oben rechts in der App.
|
||||
+18
-4
@@ -62,9 +62,9 @@ RATE_PER_10MIN=10
|
||||
#
|
||||
# ACHTUNG: Diese vier Werte sind nur die STARTWERTE beim allerersten Start.
|
||||
# Danach lebt der Druckerzugang in der Datenbank und wird ueber die Oberflaeche
|
||||
# gepflegt - "Einstellungen" -> "Drucker einrichten" richtet einen Pi sogar
|
||||
# vollstaendig selbst ein (Uebertragung, Installation, Token). Eine spaetere
|
||||
# Aenderung hier bleibt also wirkungslos.
|
||||
# gepflegt - "Einstellungen" -> "Drucker einrichten" legt einen Link bereit,
|
||||
# ueber den sich ein Pi selbst einrichtet und anschliessend hier anmeldet.
|
||||
# Eine spaetere Aenderung hier bleibt also wirkungslos.
|
||||
#
|
||||
# Wer gar keinen Drucker hat, laesst alles leer: die App laeuft dann genau wie
|
||||
# vorher, nur ohne Bon-Knoepfe.
|
||||
@@ -73,12 +73,26 @@ PRINTER_HOST=
|
||||
PRINTER_PORT=9100
|
||||
|
||||
# Muss mit PRINT_TOKEN aus /etc/bondrucker/env auf dem Pi uebereinstimmen.
|
||||
# install.sh gibt den Wert am Ende aus.
|
||||
# Das Einrichtungsskript gibt den Wert am Ende aus, falls es sich nicht selbst
|
||||
# bei der App anmelden konnte.
|
||||
PRINTER_TOKEN=
|
||||
|
||||
# Zeichen je Zeile. 42 = 80-mm-Papier (Standard), 32 = 58-mm-Papier.
|
||||
PRINTER_WIDTH=42
|
||||
|
||||
# Welcher Drucker am Pi bedient wird, z. B. /dev/usb/lp0. LEER lassen: dann
|
||||
# nimmt die Bruecke das erste gefundene Geraet. Ausgewaehlt wird das ohnehin in
|
||||
# den Einstellungen - dort zeigt "Drucker am Pi" alles an, was angeschlossen
|
||||
# ist, mit Klartextnamen.
|
||||
PRINTER_DEVICE=
|
||||
|
||||
# Abbruch, wenn der Pi nicht antwortet. Lieber kurz: ein haengender Drucker
|
||||
# darf das Beenden eines Timers nicht blockieren.
|
||||
PRINTER_TIMEOUT_MS=6000
|
||||
|
||||
# Adresse, unter der der Drucker-Pi diese App erreicht. Normalerweise LEER
|
||||
# lassen: die App leitet sie aus der Anfrage ab, also aus der Adresse, unter der
|
||||
# gerade jemand die Einstellungen offen hat. Nur setzen, wenn der Pi die App
|
||||
# unter einem anderen Namen erreichen muss als der Browser.
|
||||
# Beispiel: PUBLIC_URL=http://192.168.1.50:3003
|
||||
PUBLIC_URL=
|
||||
Generated
+1
-69
@@ -14,8 +14,7 @@
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"express-session": "^1.17.3",
|
||||
"openid-client": "^6.8.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"ssh2": "^1.17.0"
|
||||
"sqlite3": "^5.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -184,14 +183,6 @@
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -229,14 +220,6 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
@@ -310,15 +293,6 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -450,20 +424,6 @@
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buildcheck": "~0.0.6",
|
||||
"nan": "^2.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
@@ -1422,12 +1382,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
|
||||
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
@@ -2138,23 +2092,6 @@
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssri": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
|
||||
@@ -2294,11 +2231,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"express-session": "^1.17.3",
|
||||
"openid-client": "^6.8.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"ssh2": "^1.17.0"
|
||||
"sqlite3": "^5.1.6"
|
||||
}
|
||||
}
|
||||
+41
-3
@@ -25,6 +25,10 @@ let cfg = {
|
||||
port: 9100,
|
||||
token: '',
|
||||
width: 42,
|
||||
// Welcher Drucker am Pi bedient wird. Leer = die Bruecke sucht sich selbst
|
||||
// einen. Die Wahl lebt hier und nicht auf dem Pi, damit sie dort bedient
|
||||
// werden kann, wo auch alles andere eingestellt wird.
|
||||
device: '',
|
||||
timeoutMs: parseInt(process.env.PRINTER_TIMEOUT_MS) || 6000
|
||||
};
|
||||
|
||||
@@ -40,7 +44,8 @@ function setConfig(next = {}) {
|
||||
host: String(next.host == null ? cfg.host : next.host).trim(),
|
||||
port: Number.isInteger(port) && port > 0 && port < 65536 ? port : cfg.port,
|
||||
token: next.token == null ? cfg.token : String(next.token).trim(),
|
||||
width: Number.isInteger(width) && width >= 24 && width <= 96 ? width : cfg.width
|
||||
width: Number.isInteger(width) && width >= 24 && width <= 96 ? width : cfg.width,
|
||||
device: next.device == null ? cfg.device : String(next.device).trim()
|
||||
};
|
||||
return cfg;
|
||||
}
|
||||
@@ -55,7 +60,10 @@ function width() {
|
||||
|
||||
/** Konfiguration OHNE das Token - dieser Wert verlaesst den Server nie. */
|
||||
function config() {
|
||||
return { host: cfg.host, port: cfg.port, width: cfg.width, timeoutMs: cfg.timeoutMs, tokenSet: !!cfg.token };
|
||||
return {
|
||||
host: cfg.host, port: cfg.port, width: cfg.width, device: cfg.device,
|
||||
timeoutMs: cfg.timeoutMs, tokenSet: !!cfg.token
|
||||
};
|
||||
}
|
||||
|
||||
function state() {
|
||||
@@ -64,6 +72,7 @@ function state() {
|
||||
host: cfg.host || null,
|
||||
port: isConfigured() ? cfg.port : null,
|
||||
width: cfg.width,
|
||||
device: cfg.device || null,
|
||||
lastError,
|
||||
lastPrintAt
|
||||
};
|
||||
@@ -83,6 +92,9 @@ function request(pfad, method, body) {
|
||||
headers['Content-Length'] = body.length;
|
||||
}
|
||||
if (cfg.token) headers['X-Print-Token'] = cfg.token;
|
||||
// Ohne diesen Kopf sucht sich die Bruecke selbst ein Geraet. Mit ihm
|
||||
// gilt genau das, was in den Einstellungen steht.
|
||||
if (cfg.device) headers['X-Print-Device'] = cfg.device;
|
||||
|
||||
const req = http.request(
|
||||
{ host: cfg.host, port: cfg.port, path: pfad, method, headers },
|
||||
@@ -125,6 +137,22 @@ function request(pfad, method, body) {
|
||||
/** Zustand der Bruecke abfragen (Geraet da? beschreibbar?). */
|
||||
async function status() {
|
||||
const res = await request('/status', 'GET');
|
||||
|
||||
// Die Bruecke antwortet mit 503, wenn sie zwar laeuft, der Drucker aber
|
||||
// nicht bereit ist: Kabel ab, falsches Geraet gewaehlt, kein Schreibrecht.
|
||||
// Das ist kein Protokollfehler - und der Rumpf sagt, was genau fehlt.
|
||||
// Ohne diese Auswertung stuende in der Oberflaeche "HTTP 503", was niemandem
|
||||
// verraet, dass nur ein USB-Stecker lose ist.
|
||||
const p = res.data;
|
||||
if (!res.ok && p && p.version) {
|
||||
lastError = !p.deviceExists
|
||||
? `Gerät ${p.device} ist am Pi nicht vorhanden. Kabel prüfen oder einen anderen Drucker wählen.`
|
||||
: !p.writable
|
||||
? `Auf ${p.device} darf der Druckdienst nicht schreiben.`
|
||||
: p.lastError || 'Drucker nicht bereit.';
|
||||
return { ok: false, error: lastError, printer: p, ...state() };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
lastError = res.error;
|
||||
return { ok: false, error: res.error, ...state() };
|
||||
@@ -132,6 +160,16 @@ async function status() {
|
||||
return { ok: true, printer: res.data, ...state() };
|
||||
}
|
||||
|
||||
/** Welche Drucker am Pi haengen - die Auswahl fuer die Einstellungen. */
|
||||
async function devices() {
|
||||
const res = await request('/devices', 'GET');
|
||||
if (!res.ok) {
|
||||
lastError = res.error;
|
||||
return { ok: false, error: res.error };
|
||||
}
|
||||
return { ok: true, devices: (res.data && res.data.devices) || [], fallback: res.data && res.data.fallback };
|
||||
}
|
||||
|
||||
/** Rohe ESC/POS-Daten an den Drucker geben. */
|
||||
async function print(buffer) {
|
||||
const res = await request('/print', 'POST', buffer);
|
||||
@@ -145,4 +183,4 @@ async function print(buffer) {
|
||||
return res;
|
||||
}
|
||||
|
||||
module.exports = { setConfig, isConfigured, width, config, state, status, print };
|
||||
module.exports = { setConfig, isConfigured, width, config, state, status, devices, print };
|
||||
@@ -1,254 +0,0 @@
|
||||
/**
|
||||
* Einrichtung eines Drucker-Pi aus der Oberflaeche heraus
|
||||
*
|
||||
* Bis hierher war das Handarbeit: Dateien per scp auf den Pi, dort install.sh
|
||||
* mit sudo starten, Token abschreiben, in die .env eintragen, Dienst neu
|
||||
* starten. Das ist genau die Sorte Arbeit, die beim naechsten Geraetetausch
|
||||
* niemand mehr weiss. Dieses Modul macht daraus einen Knopf.
|
||||
*
|
||||
* Was hier passiert, ist bewusst dasselbe wie von Hand - es laeuft nur
|
||||
* ferngesteuert ab:
|
||||
*
|
||||
* 1. per SSH anmelden
|
||||
* 2. pruefen, ob der Pi ueberhaupt taugt (Python, Druckergeraet, sudo)
|
||||
* 3. die drei Dateien aus deploy/bondrucker/ uebertragen
|
||||
* 4. install.sh mit einem hier erzeugten Token starten
|
||||
* 5. Spuren im /tmp beseitigen
|
||||
* 6. von aussen nachsehen, ob der Dienst antwortet
|
||||
*
|
||||
* Zum Umgang mit den Zugangsdaten: Passwort und Benutzername stehen nur
|
||||
* waehrend des Vorgangs im Arbeitsspeicher. Nichts davon wird gespeichert,
|
||||
* protokolliert oder an den Browser zurueckgegeben. Dauerhaft bleibt allein
|
||||
* das erzeugte Drucker-Token.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Client } = require('ssh2');
|
||||
|
||||
const QUELLE = path.join(__dirname, '../deploy/bondrucker');
|
||||
const DATEIEN = ['print-server.py', 'bondrucker.service', 'install.sh', 'README.md'];
|
||||
|
||||
const VERBINDUNGS_TIMEOUT_MS = 15000;
|
||||
const BEFEHL_TIMEOUT_MS = 180000;
|
||||
|
||||
/**
|
||||
* Token fuer die Bruecke. Bewusst nur Buchstaben und Ziffern: der Wert wird
|
||||
* auf dem Pi als Argument an install.sh weitergereicht, und was keine
|
||||
* Sonderzeichen enthaelt, kann in keiner Shell etwas anderes bedeuten.
|
||||
*/
|
||||
function tokenErzeugen() {
|
||||
return crypto.randomBytes(32).toString('base64').replace(/[^A-Za-z0-9]/g, '').slice(0, 32);
|
||||
}
|
||||
|
||||
/** Fingerabdruck des Hostschluessels, im selben Format wie ssh es zeigt. */
|
||||
function fingerabdruck(key) {
|
||||
return 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Befehl auf dem Pi ausfuehren.
|
||||
*
|
||||
* Wichtig: Nutzereingaben werden NIE in die Befehlszeile geschrieben. Das
|
||||
* Skript kommt fest aus dieser Datei, veraenderliche Werte gehen ueber stdin
|
||||
* oder ueber Umgebungsvariablen, die ssh2 selbst quotet.
|
||||
*/
|
||||
function exec(conn, command, stdin = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timeout = setTimeout(() => {
|
||||
stream.close();
|
||||
reject(new Error('Zeitueberschreitung auf dem Pi'));
|
||||
}, BEFEHL_TIMEOUT_MS);
|
||||
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code: code === null ? -1 : code, stdout, stderr });
|
||||
});
|
||||
stream.on('data', d => { stdout += d.toString('utf8'); });
|
||||
stream.stderr.on('data', d => { stderr += d.toString('utf8'); });
|
||||
|
||||
if (stdin !== null) stream.write(stdin);
|
||||
stream.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function verbinden({ host, port, username, password }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
let hostkey = null;
|
||||
|
||||
const abbruch = setTimeout(() => {
|
||||
conn.end();
|
||||
reject(new Error(`Keine Antwort von ${host}:${port} innerhalb von ${VERBINDUNGS_TIMEOUT_MS / 1000} s`));
|
||||
}, VERBINDUNGS_TIMEOUT_MS);
|
||||
|
||||
conn.on('ready', () => {
|
||||
clearTimeout(abbruch);
|
||||
resolve({ conn, hostkey });
|
||||
});
|
||||
|
||||
conn.on('error', (err) => {
|
||||
clearTimeout(abbruch);
|
||||
// Die Meldungen von ssh2 sind fuer eine Ladenoberflaeche zu technisch.
|
||||
const roh = String(err.message || err);
|
||||
if (/All configured authentication methods failed/i.test(roh)) {
|
||||
return reject(new Error('Anmeldung abgelehnt - Benutzername oder Passwort stimmt nicht.'));
|
||||
}
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
return reject(new Error(`${host}:${port} nimmt keine SSH-Verbindung an. Laeuft dort sshd?`));
|
||||
}
|
||||
if (err.code === 'EHOSTUNREACH' || err.code === 'ENETUNREACH' || err.code === 'ETIMEDOUT') {
|
||||
return reject(new Error(`${host} ist im Netz nicht erreichbar.`));
|
||||
}
|
||||
reject(new Error(roh));
|
||||
});
|
||||
|
||||
conn.connect({
|
||||
host, port, username, password,
|
||||
readyTimeout: VERBINDUNGS_TIMEOUT_MS,
|
||||
// Erstkontakt im eigenen Netz: der Schluessel wird angenommen und
|
||||
// im Protokoll ausgewiesen, damit er nachvollziehbar bleibt.
|
||||
hostVerifier: (key) => { hostkey = fingerabdruck(key); return true; }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Die vier Dateien per SFTP in ein frisches Verzeichnis unter /tmp legen. */
|
||||
function uebertragen(conn, zielVerzeichnis) {
|
||||
return new Promise((resolve, reject) => {
|
||||
conn.sftp((err, sftp) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
const naechste = (i) => {
|
||||
if (i >= DATEIEN.length) return resolve(DATEIEN.length);
|
||||
const name = DATEIEN[i];
|
||||
const inhalt = fs.readFileSync(path.join(QUELLE, name));
|
||||
const strom = sftp.createWriteStream(`${zielVerzeichnis}/${name}`);
|
||||
strom.on('close', () => naechste(i + 1));
|
||||
strom.on('error', (e) => reject(new Error(`${name}: ${e.message}`)));
|
||||
strom.end(inhalt);
|
||||
};
|
||||
|
||||
naechste(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Pi einrichten.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {string} o.host IP oder Name des Pi
|
||||
* @param {number} o.port SSH-Port
|
||||
* @param {string} o.username SSH-Benutzer (muss sudo duerfen)
|
||||
* @param {string} o.password SSH-Passwort, zugleich sudo-Passwort
|
||||
* @returns {Promise<{ok, token, host, port, steps, fingerprint}>}
|
||||
* steps = [{name, ok, detail}] - taugt unveraendert als Protokoll
|
||||
* fuer die Oberflaeche.
|
||||
*/
|
||||
async function provision({ host, port = 22, username, password }) {
|
||||
const steps = [];
|
||||
const schritt = (name, ok, detail) => { steps.push({ name, ok, detail: detail || '' }); };
|
||||
|
||||
const token = tokenErzeugen();
|
||||
const tmp = `/tmp/bondrucker-${crypto.randomBytes(6).toString('hex')}`;
|
||||
let conn = null;
|
||||
let fingerprint = null;
|
||||
|
||||
try {
|
||||
// --- 1. Anmelden ----------------------------------------------------
|
||||
const verbindung = await verbinden({ host, port, username, password });
|
||||
conn = verbindung.conn;
|
||||
fingerprint = verbindung.hostkey;
|
||||
schritt('Verbindung', true, `als ${username}@${host}:${port} angemeldet` +
|
||||
(fingerprint ? ` · Hostschlüssel ${fingerprint}` : ''));
|
||||
|
||||
// --- 2. Pi pruefen ---------------------------------------------------
|
||||
// Ein Skript statt vieler Einzelbefehle: eine Runde ueber die Leitung,
|
||||
// und die Ausgabe ist auch fuer Menschen lesbar.
|
||||
const pruefung = await exec(conn, 'bash -s', `
|
||||
printf 'SYSTEM=%s\\n' "$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)"
|
||||
printf 'ARCH=%s\\n' "$(uname -m)"
|
||||
printf 'PYTHON=%s\\n' "$(command -v python3 || echo FEHLT)"
|
||||
printf 'DRUCKER=%s\\n' "$(ls /dev/usb/lp* 2>/dev/null | head -1 || echo KEINER)"
|
||||
printf 'SYSTEMD=%s\\n' "$(command -v systemctl || echo FEHLT)"
|
||||
`);
|
||||
const info = Object.fromEntries(
|
||||
pruefung.stdout.split('\n').filter(z => z.includes('=')).map(z => {
|
||||
const i = z.indexOf('=');
|
||||
return [z.slice(0, i), z.slice(i + 1).trim()];
|
||||
})
|
||||
);
|
||||
|
||||
if (info.PYTHON === 'FEHLT') {
|
||||
schritt('System geprüft', false, 'Auf dem Pi fehlt python3 - der Druckdienst braucht es.');
|
||||
throw new Error('python3 fehlt auf dem Zielsystem.');
|
||||
}
|
||||
if (info.SYSTEMD === 'FEHLT') {
|
||||
schritt('System geprüft', false, 'Kein systemd gefunden - der Dienst kann nicht eingerichtet werden.');
|
||||
throw new Error('systemd fehlt auf dem Zielsystem.');
|
||||
}
|
||||
|
||||
// Ein fehlender Drucker ist kein Abbruchgrund: der Dienst laeuft auch
|
||||
// ohne und findet das Geraet, sobald es angesteckt wird.
|
||||
const druckerHinweis = info.DRUCKER === 'KEINER'
|
||||
? ' · ACHTUNG: gerade kein Drucker unter /dev/usb/lp* - Kabel prüfen'
|
||||
: ` · Drucker ${info.DRUCKER}`;
|
||||
schritt('System geprüft', true, `${info.SYSTEM || 'unbekannt'} (${info.ARCH})${druckerHinweis}`);
|
||||
|
||||
// --- 3. Dateien uebertragen ------------------------------------------
|
||||
await exec(conn, `mkdir -p ${tmp} && chmod 700 ${tmp}`);
|
||||
const anzahl = await uebertragen(conn, tmp);
|
||||
schritt('Dateien übertragen', true, `${anzahl} Dateien nach ${tmp}`);
|
||||
|
||||
// --- 4. Einrichten ----------------------------------------------------
|
||||
// Das Passwort geht ueber stdin an sudo, nie ueber die Befehlszeile -
|
||||
// dort waere es fuer jeden sichtbar, der auf dem Pi "ps" tippt.
|
||||
const install = await exec(
|
||||
conn,
|
||||
`cd ${tmp} && sudo -S -p '' bash install.sh ${token} 2>&1`,
|
||||
password + '\n'
|
||||
);
|
||||
// install.sh gibt das Token am Ende aus - fuer den Menschen, der es von
|
||||
// Hand aufsetzt, genau richtig. Hier ist es unnoetig: die App kennt es
|
||||
// ohnehin. Und das Protokoll geht in den Browser und ins Journal.
|
||||
const ausgabe = (install.stdout + install.stderr)
|
||||
.split(token).join('(hier nicht angezeigt)')
|
||||
.trim();
|
||||
|
||||
if (install.code !== 0) {
|
||||
const grund = /incorrect password|Sorry, try again/i.test(ausgabe)
|
||||
? 'sudo hat das Passwort abgelehnt.'
|
||||
: `install.sh endete mit Rückgabewert ${install.code}.`;
|
||||
schritt('Dienst eingerichtet', false, `${grund}\n${ausgabe}`);
|
||||
throw new Error(grund);
|
||||
}
|
||||
schritt('Dienst eingerichtet', true, ausgabe);
|
||||
|
||||
// --- 5. Aufraeumen ----------------------------------------------------
|
||||
await exec(conn, `rm -rf ${tmp}`);
|
||||
schritt('Aufgeräumt', true, `${tmp} entfernt`);
|
||||
|
||||
return { ok: true, token, host, port: 9100, steps, fingerprint };
|
||||
} catch (err) {
|
||||
if (!steps.length || steps[steps.length - 1].ok) {
|
||||
schritt('Abbruch', false, String(err.message || err));
|
||||
}
|
||||
return { ok: false, error: String(err.message || err), steps, fingerprint };
|
||||
} finally {
|
||||
if (conn) {
|
||||
// Der Versuch aufzuraeumen darf das Ergebnis nicht mehr kippen.
|
||||
try { await exec(conn, `rm -rf ${tmp}`); } catch (e) { /* egal */ }
|
||||
conn.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { provision };
|
||||
+156
-55
@@ -19,7 +19,7 @@ const oidc = require('./oidc');
|
||||
const printer = require('./printer');
|
||||
const { buildReceipt } = require('./receipt');
|
||||
const { barcodeMaxLength } = require('./escpos');
|
||||
const { provision } = require('./provision');
|
||||
const setup = require('./setup');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -170,6 +170,10 @@ async function initDatabase() {
|
||||
[(process.env.PRINTER_TOKEN || '').trim()]);
|
||||
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_width',?)`,
|
||||
[String(parseInt(process.env.PRINTER_WIDTH) || 42)]);
|
||||
// Leer heisst: die Bruecke auf dem Pi sucht sich selbst ein Geraet.
|
||||
// Sobald in den Einstellungen einer gewaehlt wurde, gilt der.
|
||||
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_device',?)`,
|
||||
[(process.env.PRINTER_DEVICE || '').trim()]);
|
||||
|
||||
await refreshPrinterConfig();
|
||||
|
||||
@@ -212,10 +216,12 @@ async function refreshPrinterConfig() {
|
||||
host: map.printer_host || '',
|
||||
port: map.printer_port,
|
||||
token: map.printer_token || '',
|
||||
width: map.printer_width
|
||||
width: map.printer_width,
|
||||
device: map.printer_device || ''
|
||||
});
|
||||
console.log(cfg.host
|
||||
? `Bondrucker: ${cfg.host}:${cfg.port}, ${cfg.width} Zeichen je Zeile`
|
||||
? `Bondrucker: ${cfg.host}:${cfg.port}, ${cfg.width} Zeichen je Zeile, ` +
|
||||
`Geraet ${cfg.device || 'automatisch'}`
|
||||
: 'Bondrucker: nicht eingerichtet');
|
||||
return cfg;
|
||||
}
|
||||
@@ -481,9 +487,14 @@ app.get('/auth/logout', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Passwort-Logout (Fallback, wird per fetch aufgerufen)
|
||||
// Logout per fetch: beendet nur die lokale Sitzung. Aufgerufen von der
|
||||
// Portal-Kopfleiste (Ereignis "hi-abmelden"), die danach selbst bei Portal
|
||||
// und Authentik abmeldet.
|
||||
app.post('/api/logout', (req, res) => {
|
||||
req.session.destroy(() => res.json({ success: true }));
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -732,6 +743,16 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => {
|
||||
await setSetting('printer_width', String(w));
|
||||
druckerGeaendert = true;
|
||||
}
|
||||
if (typeof b.printerDevice === 'string') {
|
||||
const geraet = b.printerDevice.trim();
|
||||
// Derselbe Rahmen wie auf dem Pi. Der Wert wird dort noch einmal
|
||||
// geprueft - eine Schranke, die nur an einem Ende steht, ist keine.
|
||||
if (geraet && !/^\/dev\/(usb\/)?lp[0-9]{1,3}$/.test(geraet)) {
|
||||
return res.status(400).json({ error: 'Das ist kein Druckergerät (erwartet: /dev/usb/lp0).' });
|
||||
}
|
||||
await setSetting('printer_device', geraet);
|
||||
druckerGeaendert = true;
|
||||
}
|
||||
// Das Token kommt nur herein, nie heraus. Ein leerer Wert wird ignoriert,
|
||||
// damit ein Speichern ohne Eingabe das vorhandene Token nicht loescht.
|
||||
if (typeof b.printerToken === 'string' && b.printerToken.trim()) {
|
||||
@@ -755,77 +776,157 @@ app.get('/api/printer/status', requireAuth, wrap(async (req, res) => {
|
||||
res.json({ configured: true, ...await printer.status(), config: printer.config() });
|
||||
}));
|
||||
|
||||
// Einrichtung ist teuer und beruehrt ein fremdes Geraet - hier deutlich
|
||||
// enger begrenzt als beim Drucken.
|
||||
const provisionLimiter = rateLimit({
|
||||
/**
|
||||
* Welche Drucker am Pi haengen.
|
||||
*
|
||||
* Die Liste kommt frisch vom Pi und wird nicht zwischengespeichert: wer sie
|
||||
* aufruft, hat gerade ein Kabel umgesteckt und will genau das sehen.
|
||||
*/
|
||||
app.get('/api/printer/devices', requireAuth, wrap(async (req, res) => {
|
||||
if (!printer.isConfigured()) {
|
||||
return res.status(503).json({ error: 'Kein Bondrucker eingerichtet.' });
|
||||
}
|
||||
const liste = await printer.devices();
|
||||
if (!liste.ok) return res.status(502).json({ error: liste.error });
|
||||
res.json({ devices: liste.devices, fallback: liste.fallback, selected: printer.config().device });
|
||||
}));
|
||||
|
||||
// Einrichtung beruehrt ein fremdes Geraet und ist von aussen erreichbar -
|
||||
// hier deutlich enger begrenzt als beim Drucken.
|
||||
const setupLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Zu viele Einrichtungsversuche. Bitte 15 Minuten warten.' }
|
||||
});
|
||||
|
||||
/**
|
||||
* Drucker-Pi per SSH einrichten und anschliessend uebernehmen.
|
||||
* Adresse, unter der der Pi diese App erreicht.
|
||||
*
|
||||
* Die Zugangsdaten kommen aus dem Formular, werden einmal benutzt und danach
|
||||
* vergessen: gespeichert wird ausschliesslich das erzeugte Drucker-Token.
|
||||
* Abgeleitet aus der Anfrage, denn die App weiss von sich aus nicht, unter
|
||||
* welchem Namen sie erreichbar ist - hinter einem nginx sieht sie nur
|
||||
* 127.0.0.1. Wer die Einrichtung startet, hat die App gerade offen; unter
|
||||
* genau dieser Adresse findet sie auch der Pi im selben Netz. PUBLIC_URL
|
||||
* ueberstimmt das, falls die Adresse fuer den Pi eine andere sein muss.
|
||||
*/
|
||||
app.post('/api/printer/provision', requireAuth, provisionLimiter, wrap(async (req, res) => {
|
||||
function appBasisUrl(req) {
|
||||
const fest = (process.env.PUBLIC_URL || '').trim();
|
||||
if (fest) return fest.replace(/\/+$/, '');
|
||||
return `${req.protocol}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse des Pi - aus der Verbindung, nicht aus dem Rumpf.
|
||||
*
|
||||
* Was der Pi ueber sich selbst behauptet, koennte auch jemand anders
|
||||
* behaupten. Nur wenn die Anmeldung ueber die Rueckschleife kam (Testaufbau
|
||||
* oder Tunnel), hilft die Selbstauskunft ueberhaupt weiter.
|
||||
*/
|
||||
function quelleAdresse(req, hinweis) {
|
||||
const ip = String(req.ip || '').replace(/^::ffff:/, '');
|
||||
if (ip && ip !== '::1' && ip !== '127.0.0.1') return ip;
|
||||
const h = String(hinweis || '').trim();
|
||||
return /^[A-Za-z0-9.\-]{1,120}$/.test(h) ? h : ip;
|
||||
}
|
||||
|
||||
/** Einen Einrichtungscode erzeugen und den Abhol-Link dazu liefern. */
|
||||
app.post('/api/printer/setup-code', requireAuth, setupLimiter, wrap(async (req, res) => {
|
||||
const basis = appBasisUrl(req);
|
||||
const { code, expiresAt } = setup.codeAnlegen();
|
||||
|
||||
// Das Skript gleich hier bauen: ein unbrauchbarer Hostname soll jetzt
|
||||
// auffallen und nicht erst, wenn jemand mit dem Laptop am Pi steht.
|
||||
try {
|
||||
setup.skriptBauen({ appUrl: basis, code });
|
||||
} catch (err) {
|
||||
console.error('[einrichtung] Skript nicht baubar:', err.message);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
const url = `${basis}/setup/bondrucker.sh?code=${code}`;
|
||||
console.log(`[einrichtung] Code erzeugt, gueltig bis ${new Date(expiresAt).toISOString()}`);
|
||||
res.json({
|
||||
url,
|
||||
expiresAt,
|
||||
command: `curl -fsSL "${url}" -o bondrucker.sh && sudo bash bondrucker.sh`
|
||||
});
|
||||
}));
|
||||
|
||||
/** Wartet noch jemand auf den Pi, oder hat er sich gemeldet? */
|
||||
app.get('/api/printer/setup-status', requireAuth, wrap(async (req, res) => {
|
||||
res.json({ ...setup.zustand(), printer: printer.state() });
|
||||
}));
|
||||
|
||||
/**
|
||||
* Das Einrichtungsskript ausliefern.
|
||||
*
|
||||
* OHNE Anmeldung: der Pi holt es mit curl ab und kann sich bei Authentik nicht
|
||||
* anmelden. Der Einrichtungscode ist hier der Schluessel - er gilt einmal und
|
||||
* eine halbe Stunde lang.
|
||||
*/
|
||||
app.get('/setup/bondrucker.sh', setupLimiter, wrap(async (req, res) => {
|
||||
const eintrag = setup.codePruefen(req.query.code);
|
||||
if (!eintrag) {
|
||||
return res.status(403).type('text/plain; charset=utf-8').send(
|
||||
'# Einrichtungscode ungueltig, verbraucht oder abgelaufen.\n' +
|
||||
'# In der Timer-App unter Einstellungen einen neuen erzeugen.\n');
|
||||
}
|
||||
|
||||
let skript;
|
||||
try {
|
||||
skript = setup.skriptBauen({ appUrl: appBasisUrl(req), code: eintrag.code });
|
||||
} catch (err) {
|
||||
console.error('[einrichtung] Skript nicht baubar:', err.message);
|
||||
return res.status(500).type('text/plain; charset=utf-8').send(`# ${err.message}\n`);
|
||||
}
|
||||
|
||||
res.type('text/x-shellscript; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="bondrucker.sh"');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(skript);
|
||||
}));
|
||||
|
||||
/**
|
||||
* Der Pi meldet sich nach der Einrichtung selbst an.
|
||||
*
|
||||
* OHNE Anmeldung, aus demselben Grund wie oben. Der Code wird hier verbraucht:
|
||||
* ein zweiter Pi kann sich mit demselben Code nicht dazwischenschieben.
|
||||
*/
|
||||
app.post('/api/setup/register', setupLimiter, wrap(async (req, res) => {
|
||||
const b = req.body || {};
|
||||
const host = String(b.host || '').trim();
|
||||
const username = String(b.username || '').trim();
|
||||
const password = String(b.password || '');
|
||||
const port = b.port === undefined || b.port === '' ? 22 : parseInt(b.port);
|
||||
const eintrag = setup.codePruefen(b.code);
|
||||
if (!eintrag) {
|
||||
console.warn('[einrichtung] Anmeldung mit ungueltigem Code abgewiesen');
|
||||
return res.status(403).json({ error: 'Einrichtungscode ungueltig, verbraucht oder abgelaufen.' });
|
||||
}
|
||||
|
||||
if (!host) return res.status(400).json({ error: 'Adresse des Pi fehlt.' });
|
||||
if (!username) return res.status(400).json({ error: 'Benutzername fehlt.' });
|
||||
if (!password) return res.status(400).json({ error: 'Passwort fehlt.' });
|
||||
const token = String(b.token || '').trim();
|
||||
const port = b.port === undefined ? 9100 : parseInt(b.port);
|
||||
const device = String(b.device || '').slice(0, 120);
|
||||
|
||||
if (!token) return res.status(400).json({ error: 'Drucker-Token fehlt.' });
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return res.status(400).json({ error: 'SSH-Port muss zwischen 1 und 65535 liegen.' });
|
||||
}
|
||||
// Kein Doppelpunkt, kein Leerzeichen: eine Adresse, kein Befehlsfragment.
|
||||
if (!/^[A-Za-z0-9.\-_]+$/.test(host)) {
|
||||
return res.status(400).json({ error: 'Adresse enthält unerlaubte Zeichen.' });
|
||||
return res.status(400).json({ error: 'Port muss zwischen 1 und 65535 liegen.' });
|
||||
}
|
||||
|
||||
console.log(`[einrichtung] Pi ${username}@${host}:${port} wird eingerichtet`);
|
||||
const ergebnis = await provision({ host, port, username, password });
|
||||
const host = quelleAdresse(req, b.hostHint);
|
||||
if (!host) return res.status(400).json({ error: 'Adresse des Pi nicht ermittelbar.' });
|
||||
|
||||
if (!ergebnis.ok) {
|
||||
console.warn('[einrichtung] fehlgeschlagen:', ergebnis.error);
|
||||
return res.status(502).json({ error: ergebnis.error, steps: ergebnis.steps });
|
||||
}
|
||||
|
||||
// Uebernehmen: ab hier druckt die App auf diesen Pi.
|
||||
await setSetting('printer_host', ergebnis.host);
|
||||
await setSetting('printer_port', String(ergebnis.port));
|
||||
await setSetting('printer_token', ergebnis.token);
|
||||
await setSetting('printer_host', host);
|
||||
await setSetting('printer_port', String(port));
|
||||
await setSetting('printer_token', token);
|
||||
await refreshPrinterConfig();
|
||||
setup.codeEinloesen(host, device);
|
||||
|
||||
// Gegenprobe von aussen. Erreichbarkeit allein genuegt nicht: die Bruecke
|
||||
// meldet zusaetzlich, ob sie das Token annimmt. Ein Dienst, der noch mit
|
||||
// einem alten Token laeuft, wuerde sonst als "bereit" durchgehen und beim
|
||||
// ersten Bon mit 403 abbrechen.
|
||||
// Gegenprobe von aussen. Der Pi hat sich eben selbst geprueft, aber nur von
|
||||
// innen - ob die App wirklich durchkommt, zeigt erst dieser Griff.
|
||||
const probe = await printer.status();
|
||||
const tokenOk = !probe.printer || probe.printer.tokenAccepted !== false;
|
||||
const bereit = probe.ok && tokenOk;
|
||||
|
||||
let detail;
|
||||
if (bereit) {
|
||||
detail = `${ergebnis.host}:${ergebnis.port} antwortet und nimmt das neue Token an` +
|
||||
(probe.printer && probe.printer.device ? ` · Gerät ${probe.printer.device}` : '');
|
||||
} else if (probe.ok && !tokenOk) {
|
||||
detail = 'Der Dienst antwortet, lehnt das neue Token aber ab. Läuft dort noch eine ' +
|
||||
'alte Fassung der Brücke? Einrichtung bitte wiederholen.';
|
||||
} else {
|
||||
detail = `${probe.error} — der Dienst wurde eingerichtet, antwortet aber noch nicht. ` +
|
||||
'Meist steht eine Firewall zwischen Server und Pi.';
|
||||
}
|
||||
ergebnis.steps.push({ name: 'Gegenprobe', ok: bereit, detail });
|
||||
|
||||
console.log(`[einrichtung] fertig, Drucker ${bereit ? 'bereit' : 'NICHT bereit'}`);
|
||||
res.json({ ok: true, ready: bereit, steps: ergebnis.steps, printer: printer.state() });
|
||||
console.log(`[einrichtung] ${host}:${port} angemeldet, Drucker ${bereit ? 'bereit' : 'NICHT erreichbar'}`);
|
||||
res.json({ ok: true, host, port, ready: bereit });
|
||||
}));
|
||||
|
||||
/** Probedruck - beantwortet die Frage "liegt es am Drucker oder an der App?". */
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Einrichtung eines Drucker-Pi - die Holschuld-Variante
|
||||
*
|
||||
* Frueher hat die App den Pi selbst eingerichtet: per SSH anmelden, Dateien
|
||||
* schieben, Skript starten. Das verlangte, dass jemand ein SSH-Passwort in ein
|
||||
* Webformular tippt und dass der Server den Pi ueberhaupt erreicht.
|
||||
*
|
||||
* Jetzt andersherum: die App legt ein Skript zum Abholen bereit und wartet.
|
||||
* Wer den Drucker ansteckt, steht ohnehin am Pi - dort wird das Skript
|
||||
* gestartet, dort wird der Drucker ausgewaehlt, und am Ende meldet sich der Pi
|
||||
* von selbst bei der App an. Kein Passwort verlaesst den Laden, und die
|
||||
* Verbindung muss nur in eine Richtung funktionieren.
|
||||
*
|
||||
* Das Skript wird beim Abholen zusammengesetzt: die Vorlage aus
|
||||
* deploy/bondrucker/einrichten.sh bekommt die Adresse dieser App, den
|
||||
* einmaligen Code sowie den Druckdienst und die systemd-Unit eingesetzt.
|
||||
* Heraus kommt eine einzige Datei - herunterladen, starten, fertig.
|
||||
*
|
||||
* Der Code lebt nur im Arbeitsspeicher. Ein Neustart der App macht ihn
|
||||
* ungueltig; das ist gewollt, denn ein Einrichtungscode auf der Platte waere
|
||||
* ein Dauerschluessel zum Umbiegen des Druckers.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const QUELLE = path.join(__dirname, '../deploy/bondrucker');
|
||||
const VORLAGE = path.join(QUELLE, 'einrichten.sh');
|
||||
|
||||
// Halbe Stunde: lang genug, um vom Schreibtisch zum Pi zu gehen und ein Kabel
|
||||
// zu suchen; kurz genug, dass ein vergessener Code nicht wochenlang gilt.
|
||||
const GUELTIG_MS = 30 * 60 * 1000;
|
||||
|
||||
// Bewusst nur ein Code gleichzeitig. Zwei offene Einrichtungen waeren ein
|
||||
// Rennen darum, welcher Pi am Ende der Drucker ist.
|
||||
let aktuell = null;
|
||||
|
||||
/** Nur Buchstaben und Ziffern - der Wert landet in einer URL und in einem
|
||||
* Shell-Skript, und was keine Sonderzeichen hat, kann dort nichts anrichten. */
|
||||
function codeErzeugen() {
|
||||
return crypto.randomBytes(32).toString('base64').replace(/[^A-Za-z0-9]/g, '').slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Einrichtungscode anlegen. Ein vorhandener wird dabei ungueltig.
|
||||
* @returns {{code: string, expiresAt: number}}
|
||||
*/
|
||||
function codeAnlegen() {
|
||||
aktuell = {
|
||||
code: codeErzeugen(),
|
||||
expiresAt: Date.now() + GUELTIG_MS,
|
||||
erzeugtAm: Date.now(),
|
||||
eingeloestAm: null,
|
||||
host: null,
|
||||
device: null
|
||||
};
|
||||
return { code: aktuell.code, expiresAt: aktuell.expiresAt };
|
||||
}
|
||||
|
||||
/** Gueltiger, noch nicht eingeloester Code? Sonst null. */
|
||||
function codePruefen(code) {
|
||||
if (!aktuell || !code) return null;
|
||||
if (aktuell.eingeloestAm) return null;
|
||||
if (Date.now() > aktuell.expiresAt) return null;
|
||||
|
||||
// Zeitkonstanter Vergleich: sonst verraet die Laufzeit, wie viele Zeichen
|
||||
// schon stimmen, und der Code liesse sich Stueck fuer Stueck erraten.
|
||||
const a = Buffer.from(String(code));
|
||||
const b = Buffer.from(aktuell.code);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||
return aktuell;
|
||||
}
|
||||
|
||||
/** Code als verbraucht markieren - er gilt genau einmal. */
|
||||
function codeEinloesen(host, device) {
|
||||
if (!aktuell) return;
|
||||
aktuell.eingeloestAm = Date.now();
|
||||
aktuell.host = host;
|
||||
aktuell.device = device;
|
||||
}
|
||||
|
||||
/** Zustand fuer die Oberflaeche: wartet noch jemand, oder ist es durch? */
|
||||
function zustand() {
|
||||
if (!aktuell) return { state: 'none' };
|
||||
if (aktuell.eingeloestAm) {
|
||||
return { state: 'done', host: aktuell.host, device: aktuell.device, at: aktuell.eingeloestAm };
|
||||
}
|
||||
if (Date.now() > aktuell.expiresAt) return { state: 'expired', expiresAt: aktuell.expiresAt };
|
||||
return { state: 'waiting', expiresAt: aktuell.expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Datei als Inhalt eines Here-Dokuments einsetzen.
|
||||
*
|
||||
* Der Abschluss eines Here-Dokuments ist eine Zeile, die genau aus dem
|
||||
* Schlusswort besteht. Steht dieselbe Zeile zufaellig in der eingesetzten
|
||||
* Datei, endet das Dokument zu frueh und der Rest wird als Shell-Befehl
|
||||
* gelesen. Das darf nicht stillschweigend passieren.
|
||||
*/
|
||||
function einsetzen(skript, marke, dateiname, schlusswort) {
|
||||
const inhalt = fs.readFileSync(path.join(QUELLE, dateiname), 'utf8').replace(/\n$/, '');
|
||||
if (inhalt.split('\n').some(z => z.trim() === schlusswort)) {
|
||||
throw new Error(`${dateiname} enthaelt die Zeile "${schlusswort}" und kann so nicht eingebettet werden.`);
|
||||
}
|
||||
if (!skript.includes(marke)) {
|
||||
throw new Error(`In einrichten.sh fehlt die Marke ${marke}.`);
|
||||
}
|
||||
return skript.replace(marke, inhalt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Das fertige Einrichtungsskript bauen.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {string} o.appUrl Adresse dieser App, wie der Pi sie erreicht
|
||||
* @param {string} o.code Einrichtungscode
|
||||
* @returns {string}
|
||||
*/
|
||||
function skriptBauen({ appUrl, code }) {
|
||||
// appUrl stammt aus dem Host-Kopf der Anfrage und ist damit von aussen
|
||||
// bestimmbar. Der Wert landet in einem Shell-Skript - hier wird deshalb
|
||||
// streng geprueft statt maskiert.
|
||||
if (!/^https?:\/\/[A-Za-z0-9.\-]+(:\d{1,5})?$/.test(appUrl)) {
|
||||
throw new Error(`Unbrauchbare Adresse fuer die Rueckmeldung: ${appUrl}`);
|
||||
}
|
||||
if (!/^[A-Za-z0-9]{1,64}$/.test(code)) {
|
||||
throw new Error('Unbrauchbarer Einrichtungscode.');
|
||||
}
|
||||
|
||||
let skript = fs.readFileSync(VORLAGE, 'utf8');
|
||||
skript = skript.split('@@APP_URL@@').join(appUrl);
|
||||
skript = skript.split('@@SETUP_CODE@@').join(code);
|
||||
skript = einsetzen(skript, '@@PRINT_SERVER@@', 'print-server.py', 'PRINT_SERVER_ENDE');
|
||||
skript = einsetzen(skript, '@@SERVICE@@', 'bondrucker.service', 'SERVICE_ENDE');
|
||||
return skript;
|
||||
}
|
||||
|
||||
module.exports = { codeAnlegen, codePruefen, codeEinloesen, zustand, skriptBauen, GUELTIG_MS };
|
||||
@@ -9,6 +9,13 @@ timer.firma.example {
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "no-referrer"
|
||||
# Die Seite laedt die Portal-Kopfleiste von portal.handy-insel.de.
|
||||
# Wer hier eine Content-Security-Policy setzt, muss diese Quellen erlauben
|
||||
# (sonst blockiert der Browser die Leiste), jeweils zusaetzlich zu den
|
||||
# vorhandenen Quellen (die App nutzt Inline-Styles und onclick-Handler):
|
||||
# script-src ... https://portal.handy-insel.de
|
||||
# connect-src ... https://portal.handy-insel.de
|
||||
# img-src ... https://portal.handy-insel.de https://login.handy-insel.de
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:3003 {
|
||||
|
||||
+62
-31
@@ -14,54 +14,75 @@ Nur Python-Standardbibliothek, kein pip, kein CUPS, kein node.
|
||||
|
||||
## Installation — der übliche Weg
|
||||
|
||||
**Aus der Oberfläche:** ⚙️ Einstellungen → **🔧 Drucker einrichten**. Dort IP,
|
||||
Benutzer und Passwort des Pi eintragen und auf **🚀 Jetzt einrichten** klicken.
|
||||
|
||||
Die App macht dann per SSH genau das, was unten von Hand steht: Dateien
|
||||
übertragen, `install.sh` mit einem frisch erzeugten Token starten, aufräumen
|
||||
und nachsehen, ob der Dienst antwortet. Anschließend übernimmt sie den Drucker
|
||||
selbst — es ist **nichts** mehr in der `.env` einzutragen. Das Protokoll zeigt
|
||||
Schritt für Schritt, was passiert ist.
|
||||
|
||||
Der Pi braucht dafür nur: SSH, Python 3, systemd, einen Benutzer mit sudo — und
|
||||
den Drucker am USB-Anschluss. Ein frisch aufgesetztes Raspberry Pi OS erfüllt
|
||||
das ab Werk.
|
||||
|
||||
Die Zugangsdaten werden einmal benutzt und danach verworfen. Gespeichert wird
|
||||
allein das erzeugte Drucker-Token.
|
||||
|
||||
## Installation von Hand
|
||||
|
||||
Falls die App den Pi nicht erreicht (anderes Netz, kein sudo per Passwort):
|
||||
**In der Timer-App:** ⚙️ Einstellungen → **🔧 Drucker einrichten** →
|
||||
**🔗 Einrichtungslink erzeugen**. Die App zeigt einen Befehl an. Der wird auf
|
||||
dem Pi in ein Terminal getippt:
|
||||
|
||||
```bash
|
||||
# Dateien auf den Pi kopieren
|
||||
scp print-server.py bondrucker.service install.sh README.md pi@<PI-IP>:/tmp/bondrucker/
|
||||
|
||||
# Dort einrichten
|
||||
ssh pi@<PI-IP>
|
||||
cd /tmp/bondrucker && sudo bash install.sh
|
||||
curl -fsSL "<Link aus der Timer-App>" -o bondrucker.sh
|
||||
sudo bash bondrucker.sh
|
||||
```
|
||||
|
||||
`install.sh` legt an:
|
||||
Das Skript richtet alles ein und meldet den Pi anschließend selbst bei der
|
||||
Timer-App an — ohne Rückfrage. In der Oberfläche springt die Anzeige von
|
||||
„wartet auf den Pi" auf den fertigen Drucker um. In der `.env` ist **nichts**
|
||||
einzutragen.
|
||||
|
||||
**Welcher Drucker bedient wird, wird danach in der Timer-App ausgewählt**
|
||||
(Einstellungen → *Drucker am Pi* → **🔄 Suchen**). Die App fragt den Pi über
|
||||
`/devices`, was angeschlossen ist, und schickt den gewählten Pfad bei jedem
|
||||
Auftrag im Kopf `X-Print-Device` mit. Dieser Dienst hält dazu keinen Zustand:
|
||||
es gibt genau eine Stelle, die weiß, welcher Drucker gemeint ist.
|
||||
|
||||
Der Pi braucht dafür nur Python 3, systemd, `curl` und den Drucker am
|
||||
USB-Anschluss. Ein frisch aufgesetztes Raspberry Pi OS erfüllt das ab Werk.
|
||||
|
||||
### Warum der Pi sich meldet und nicht umgekehrt
|
||||
|
||||
Früher hat die App den Pi per SSH eingerichtet. Das verlangte ein SSH-Passwort
|
||||
in einem Webformular und eine Verbindung vom Server zum Pi. Beides ist jetzt
|
||||
weg: kein Passwort verlässt den Laden, und die Verbindung muss nur in eine
|
||||
Richtung funktionieren. Wer einen Drucker anschließt, steht ohnehin am Pi.
|
||||
|
||||
Der Link enthält einen Einrichtungscode. Er gilt **einmal** und **30 Minuten**,
|
||||
liegt nur im Arbeitsspeicher der App und ist nach einem Neustart der App
|
||||
ungültig. Wer den Code hat, kann den Drucker der App umbiegen — er gehört
|
||||
deshalb nicht in einen Chatverlauf.
|
||||
|
||||
## Was das Skript anlegt
|
||||
|
||||
| Was | Wo |
|
||||
|---|---|
|
||||
| Dienstbenutzer `bondrucker` (Gruppe `lp`) | — |
|
||||
| Programm | `/opt/bondrucker/print-server.py` |
|
||||
| Token | `/etc/bondrucker/env` (`0640`, root:bondrucker) |
|
||||
| Token und Port | `/etc/bondrucker/env` (`0640`, root:bondrucker) |
|
||||
| udev-Regel | `/etc/udev/rules.d/99-bondrucker.rules` |
|
||||
| Dienst | `bondrucker.service`, aktiviert und gestartet |
|
||||
|
||||
Am Ende gibt das Skript den Wert für das Token aus.
|
||||
|
||||
`install.sh` ist wiederholbar: ein erneuter Aufruf überschreibt Programm und
|
||||
Das Skript ist wiederholbar: ein erneuter Aufruf überschreibt Programm und
|
||||
Token und **startet den Dienst neu**. Ohne den Neustart liefe er mit dem alten
|
||||
Token weiter und würde jeden Druckauftrag mit HTTP 403 ablehnen.
|
||||
|
||||
Abweichender Port der Brücke:
|
||||
|
||||
```bash
|
||||
sudo bash bondrucker.sh --port 9100
|
||||
```
|
||||
|
||||
## Installation ohne die Timer-App
|
||||
|
||||
`einrichten.sh` im Quelltext ist eine Vorlage: die Stellen `@@APP_URL@@`,
|
||||
`@@SETUP_CODE@@`, `@@PRINT_SERVER@@` und `@@SERVICE@@` füllt die App beim
|
||||
Ausliefern. Direkt ausführbar ist sie deshalb nicht.
|
||||
|
||||
Ohne die App werden die Dateien von Hand abgelegt — `print-server.py` nach
|
||||
`/opt/bondrucker/`, `bondrucker.service` nach `/etc/systemd/system/`, dazu
|
||||
`/etc/bondrucker/env` mit `PRINT_TOKEN=`. Adresse, Port und Token kommen
|
||||
anschließend von Hand in die Einstellungen der Timer-App.
|
||||
|
||||
## Timer-Server konfigurieren
|
||||
|
||||
Nach der Einrichtung aus der Oberfläche: nichts zu tun.
|
||||
Nach der Einrichtung über den Link: nichts zu tun.
|
||||
|
||||
Nach der Einrichtung von Hand: in ⚙️ Einstellungen unter „Drucker einrichten"
|
||||
lassen sich Adresse, Port und Token auch direkt eintragen. Der Druckerzugang
|
||||
@@ -89,8 +110,18 @@ Antwortet `/status` mit `"ok": true`, ist das Gerät da und beschreibbar.
|
||||
| Aufruf | Wirkung |
|
||||
|---|---|
|
||||
| `GET /status` | JSON mit Gerät, Beschreibbarkeit, letztem Fehler. HTTP 503, wenn kein Drucker da ist |
|
||||
| `GET /devices` | alle `/dev/usb/lp*` mit Klartextnamen. Token nötig |
|
||||
| `POST /print` | Rumpf = rohe ESC/POS-Bytes, Kopfzeile `X-Print-Token`. Antwortet `{"ok":true,"bytes":N}` |
|
||||
|
||||
`/status` und `/print` beachten den Kopf **`X-Print-Device`**. Fehlt er, nimmt
|
||||
die Brücke das eingestellte Gerät und sonst das erste gefundene. Der Pfad wird
|
||||
gegen `^/dev/(usb/)?lp[0-9]{1,3}$` geprüft — er kommt von außen, und ohne diese
|
||||
Schranke ließe sich der Dienst dazu bringen, in eine beliebige Datei zu
|
||||
schreiben. Ein unerlaubter Pfad ergibt HTTP 400.
|
||||
|
||||
`/devices` verlangt ein Token: welche Geräte am Pi hängen, ist zwar kein
|
||||
Geheimnis, aber auch keine Auskunft für jeden im Netz.
|
||||
|
||||
`/status` verlangt **kein** Token — der Zustand des Druckers ist nicht geheim
|
||||
und soll für Health-Checks abfragbar bleiben. Wird trotzdem eines mitgeschickt,
|
||||
meldet die Antwort in `tokenAccepted`, ob es passt. Die App nutzt das nach der
|
||||
|
||||
@@ -15,7 +15,7 @@ Group=bondrucker
|
||||
SupplementaryGroups=lp
|
||||
|
||||
# Konfiguration. Das Token steht in /etc/bondrucker/env, damit es nicht in der
|
||||
# Versionsverwaltung landet - die Datei legt install.sh an.
|
||||
# Versionsverwaltung landet - die Datei legt einrichten.sh an.
|
||||
Environment=PRINT_DEVICE=/dev/usb/lp0
|
||||
Environment=PRINT_HOST=0.0.0.0
|
||||
Environment=PRINT_PORT=9100
|
||||
|
||||
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/bin/bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bondrucker-Bruecke einrichten - wird AUF DEM PI ausgefuehrt.
|
||||
#
|
||||
# Dieses Skript wird nicht von Hand gepflegt heruntergeladen, sondern von der
|
||||
# Timer-App ausgeliefert: sie setzt ihre eigene Adresse und einen einmaligen
|
||||
# Einrichtungscode ein und haengt den Druckdienst sowie die systemd-Unit
|
||||
# gleich mit an. Heraus kommt eine einzige Datei, die alles enthaelt.
|
||||
#
|
||||
# Aufruf auf dem Pi:
|
||||
# curl -fsSL "<Link aus der Timer-App>" -o bondrucker.sh
|
||||
# sudo bash bondrucker.sh
|
||||
#
|
||||
# Der Link steht in der Timer-App unter Einstellungen -> Drucker einrichten.
|
||||
#
|
||||
# Dieses Skript installiert nur die Software. WELCHER Drucker bedient wird,
|
||||
# wird anschliessend in der Timer-App ausgewaehlt - sie fragt den Pi, was
|
||||
# angeschlossen ist. Hier ist dazu nichts einzustellen.
|
||||
#
|
||||
# Ohne Netz zur Timer-App laeuft das Skript trotzdem durch; es zeigt am Ende
|
||||
# Adresse und Token an, die dann von Hand in die Einstellungen gehoeren.
|
||||
#
|
||||
# Schalter:
|
||||
# --port 9100 Lausch-Port der Bruecke
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
# Diese beiden Werte ersetzt die Timer-App beim Ausliefern.
|
||||
APP_URL="@@APP_URL@@"
|
||||
SETUP_CODE="@@SETUP_CODE@@"
|
||||
|
||||
ZIEL=/opt/bondrucker
|
||||
PORT=9100
|
||||
|
||||
# --- Schalter --------------------------------------------------------------
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--port) PORT="${2:-9100}"; shift 2 ;;
|
||||
-h|--help) sed -n '2,26p' "$0"; exit 0 ;;
|
||||
*) echo "Unbekannter Schalter: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Bitte mit sudo starten: sudo bash $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hilfsmittel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
titel() { printf '\n\033[1m%s\033[0m\n' "$1"; }
|
||||
punkt() { printf ' %s\n' "$1"; }
|
||||
warnung() { printf ' \033[33m%s\033[0m\n' "$1"; }
|
||||
fehler() { printf '\n\033[31m%s\033[0m\n' "$1" >&2; exit 1; }
|
||||
|
||||
# Klartextname eines USB-Druckers aus dem sysfs. Nur zur Anzeige - ausgewaehlt
|
||||
# wird spaeter in der Timer-App, die dieselbe Liste ueber /devices abfragt.
|
||||
usb_name() {
|
||||
local pfad
|
||||
pfad=$(readlink -f "/sys/class/usbmisc/$(basename "$1")/device" 2>/dev/null) || return 1
|
||||
while [ -n "$pfad" ] && [ "$pfad" != "/" ]; do
|
||||
if [ -r "$pfad/product" ]; then
|
||||
printf '%s %s' "$(cat "$pfad/manufacturer" 2>/dev/null || true)" "$(cat "$pfad/product")"
|
||||
return 0
|
||||
fi
|
||||
pfad=$(dirname "$pfad")
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. System pruefen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
titel "1. System"
|
||||
|
||||
command -v python3 >/dev/null || fehler "python3 fehlt. Bitte nachinstallieren: apt install python3"
|
||||
command -v systemctl >/dev/null || fehler "systemd fehlt - dieses Skript richtet einen systemd-Dienst ein."
|
||||
|
||||
punkt "$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s) ($(uname -m))"
|
||||
punkt "$(python3 --version)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Angeschlossene Drucker anzeigen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
titel "2. Angeschlossene Drucker"
|
||||
|
||||
gefunden=0
|
||||
for d in /dev/usb/lp* /dev/lp*; do
|
||||
[ -e "$d" ] || continue
|
||||
gefunden=$((gefunden + 1))
|
||||
printf ' %-16s %s\n' "$d" "$(usb_name "$d" || echo '')"
|
||||
done
|
||||
|
||||
if [ "$gefunden" -eq 0 ]; then
|
||||
warnung "Gerade haengt kein Drucker unter /dev/usb/lp* am Pi."
|
||||
warnung "Das ist kein Hindernis: der Dienst findet ihn, sobald er angesteckt"
|
||||
warnung "wird. Danach in der Timer-App die Liste neu abrufen."
|
||||
else
|
||||
echo
|
||||
punkt "Welcher davon genommen wird, wird in der Timer-App ausgewaehlt."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Dienstbenutzer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
titel "3. Dienstbenutzer"
|
||||
|
||||
if ! id bondrucker >/dev/null 2>&1; then
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin --groups lp bondrucker
|
||||
punkt "Benutzer 'bondrucker' angelegt (Gruppe lp)."
|
||||
else
|
||||
usermod -aG lp bondrucker
|
||||
punkt "Benutzer 'bondrucker' war schon da."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Programmdateien
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
titel "4. Programmdateien"
|
||||
|
||||
install -d -m 755 "$ZIEL"
|
||||
|
||||
cat > "$ZIEL/print-server.py" <<'PRINT_SERVER_ENDE'
|
||||
@@PRINT_SERVER@@
|
||||
PRINT_SERVER_ENDE
|
||||
chmod 755 "$ZIEL/print-server.py"
|
||||
punkt "$ZIEL/print-server.py"
|
||||
|
||||
cat > /etc/systemd/system/bondrucker.service <<'SERVICE_ENDE'
|
||||
@@SERVICE@@
|
||||
SERVICE_ENDE
|
||||
chmod 644 /etc/systemd/system/bondrucker.service
|
||||
punkt "/etc/systemd/system/bondrucker.service"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Konfiguration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
titel "5. Konfiguration"
|
||||
|
||||
TOKEN="$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 32)"
|
||||
|
||||
install -d -m 750 /etc/bondrucker
|
||||
# Bewusst OHNE PRINT_DEVICE: welcher Drucker bedient wird, schickt die
|
||||
# Timer-App bei jedem Auftrag mit. Stuende die Wahl auch hier, gaebe es zwei
|
||||
# Stellen, die es zu wissen glauben - und eine davon waere irgendwann falsch.
|
||||
{
|
||||
printf 'PRINT_TOKEN=%s\n' "$TOKEN"
|
||||
printf 'PRINT_PORT=%s\n' "$PORT"
|
||||
} > /etc/bondrucker/env
|
||||
chmod 640 /etc/bondrucker/env
|
||||
chown root:bondrucker /etc/bondrucker/env
|
||||
punkt "/etc/bondrucker/env geschrieben, neues Token erzeugt."
|
||||
|
||||
# Ohne diese Regel gehoert /dev/usb/lp0 nach jedem Einstecken wieder root
|
||||
# allein - der Dienst laeuft aber nicht als root und koennte nicht drucken.
|
||||
cat > /etc/udev/rules.d/99-bondrucker.rules <<'RULE'
|
||||
# USB-Bondrucker: Gruppe lp und Schreibrecht, damit der Dienst 'bondrucker'
|
||||
# ohne root drucken kann. Gilt fuer alle usblp-Geraete.
|
||||
SUBSYSTEM=="usbmisc", KERNEL=="lp[0-9]*", GROUP="lp", MODE="0660"
|
||||
SUBSYSTEM=="usb", ATTR{bInterfaceClass}=="07", GROUP="lp", MODE="0660"
|
||||
RULE
|
||||
udevadm control --reload-rules >/dev/null 2>&1 || true
|
||||
udevadm trigger --subsystem-match=usbmisc >/dev/null 2>&1 || true
|
||||
punkt "udev-Regel gesetzt (Geraet ueberlebt das Aus- und Einstecken)."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Dienst starten
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
titel "6. Dienst"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable bondrucker.service >/dev/null 2>&1
|
||||
# Bewusst "restart" und nicht "enable --now": bei einer erneuten Einrichtung
|
||||
# laeuft der Dienst schon, und "enable --now" wuerde ihn in Ruhe lassen. Er
|
||||
# liefe dann mit dem ALTEN Token weiter und wuerde jeden Bon ablehnen.
|
||||
systemctl restart bondrucker.service
|
||||
sleep 1
|
||||
|
||||
if ! systemctl is-active --quiet bondrucker.service; then
|
||||
systemctl --no-pager --lines=20 status bondrucker.service || true
|
||||
fehler "Der Dienst laeuft nicht. Die Ausgabe oben sagt, woran es liegt."
|
||||
fi
|
||||
punkt "bondrucker.service laeuft."
|
||||
|
||||
# Gegenprobe von innen: antwortet die Bruecke, und nimmt sie das neue Token?
|
||||
ANTWORT="$(curl -fsS -m 5 -H "X-Print-Token: $TOKEN" "http://127.0.0.1:$PORT/status" 2>/dev/null || true)"
|
||||
case "$ANTWORT" in
|
||||
*'"tokenAccepted": true'*|*'"tokenAccepted":true'*) punkt "Bruecke antwortet und nimmt das Token an." ;;
|
||||
"") warnung "Die Bruecke antwortet auf 127.0.0.1:$PORT noch nicht." ;;
|
||||
*) warnung "Die Bruecke antwortet, lehnt das Token aber ab: $ANTWORT" ;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Bei der Timer-App anmelden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
|
||||
titel "7. Timer-App"
|
||||
|
||||
angemeldet=0
|
||||
case "$APP_URL" in
|
||||
@@*|"")
|
||||
punkt "Kein Einrichtungscode enthalten - dieses Skript wurde nicht ueber"
|
||||
punkt "die Timer-App geladen. Anmeldung wird uebersprungen."
|
||||
;;
|
||||
*)
|
||||
if ! command -v curl >/dev/null; then
|
||||
warnung "curl fehlt - die Anmeldung muss von Hand erfolgen."
|
||||
else
|
||||
RUMPF="$(printf '{"code":"%s","port":%s,"token":"%s","hostHint":"%s"}' \
|
||||
"$SETUP_CODE" "$PORT" "$TOKEN" "$IP")"
|
||||
if ERGEBNIS="$(curl -fsS -m 15 -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$RUMPF" "$APP_URL/api/setup/register" 2>&1)"; then
|
||||
punkt "Bei $APP_URL angemeldet."
|
||||
punkt "Jetzt dort unter Einstellungen den Drucker auswaehlen."
|
||||
angemeldet=1
|
||||
else
|
||||
warnung "Anmeldung fehlgeschlagen: $ERGEBNIS"
|
||||
warnung "Meist ist der Code abgelaufen - in der Timer-App einen neuen erzeugen."
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fertig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
titel "Fertig"
|
||||
punkt "Adresse: ${IP:-<IP des Pi>}"
|
||||
punkt "Port: $PORT"
|
||||
|
||||
if [ "$angemeldet" -eq 0 ]; then
|
||||
echo
|
||||
punkt "Von Hand eintragen in der Timer-App unter Einstellungen:"
|
||||
printf ' Adresse: %s\n Port: %s\n Token: %s\n' "${IP:-?}" "$PORT" "$TOKEN"
|
||||
fi
|
||||
|
||||
echo
|
||||
punkt "Zustand abfragen: curl -s http://${IP:-127.0.0.1}:$PORT/status"
|
||||
punkt "Protokoll: journalctl -u bondrucker -f"
|
||||
echo
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bondrucker-Bruecke auf dem Raspberry Pi einrichten.
|
||||
#
|
||||
# Aufruf auf dem Pi: sudo bash install.sh [TOKEN]
|
||||
# Ohne TOKEN wird eins erzeugt und am Ende ausgegeben - dieser Wert gehoert
|
||||
# anschliessend in die .env des Timer-Servers (PRINTER_TOKEN).
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
QUELLE="$(cd "$(dirname "$0")" && pwd)"
|
||||
ZIEL=/opt/bondrucker
|
||||
TOKEN="${1:-}"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Bitte mit sudo starten." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- Dienstbenutzer"
|
||||
if ! id bondrucker >/dev/null 2>&1; then
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin --groups lp bondrucker
|
||||
echo " Benutzer 'bondrucker' angelegt (Gruppe lp)."
|
||||
else
|
||||
usermod -aG lp bondrucker
|
||||
echo " Benutzer 'bondrucker' war schon da."
|
||||
fi
|
||||
|
||||
echo "--- Programmdateien nach $ZIEL"
|
||||
install -d -m 755 "$ZIEL"
|
||||
install -m 755 "$QUELLE/print-server.py" "$ZIEL/print-server.py"
|
||||
[ -f "$QUELLE/README.md" ] && install -m 644 "$QUELLE/README.md" "$ZIEL/README.md"
|
||||
|
||||
echo "--- Konfiguration"
|
||||
install -d -m 750 /etc/bondrucker
|
||||
if [ -f /etc/bondrucker/env ] && [ -z "$TOKEN" ]; then
|
||||
echo " /etc/bondrucker/env besteht bereits - Token bleibt unveraendert."
|
||||
else
|
||||
[ -n "$TOKEN" ] || TOKEN="$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 32)"
|
||||
printf 'PRINT_TOKEN=%s\n' "$TOKEN" > /etc/bondrucker/env
|
||||
chmod 640 /etc/bondrucker/env
|
||||
chown root:bondrucker /etc/bondrucker/env
|
||||
echo " Token geschrieben."
|
||||
fi
|
||||
|
||||
echo "--- udev-Regel (Geraet ueberlebt das Aus- und Einstecken)"
|
||||
cat > /etc/udev/rules.d/99-bondrucker.rules <<'RULE'
|
||||
# USB-Bondrucker: Gruppe lp und Schreibrecht, damit der Dienst 'bondrucker'
|
||||
# ohne root drucken kann. Gilt fuer alle usblp-Geraete.
|
||||
SUBSYSTEM=="usbmisc", KERNEL=="lp[0-9]*", GROUP="lp", MODE="0660"
|
||||
SUBSYSTEM=="usb", ATTR{bInterfaceClass}=="07", GROUP="lp", MODE="0660"
|
||||
RULE
|
||||
udevadm control --reload-rules || true
|
||||
udevadm trigger --subsystem-match=usbmisc || true
|
||||
|
||||
echo "--- systemd"
|
||||
install -m 644 "$QUELLE/bondrucker.service" /etc/systemd/system/bondrucker.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable bondrucker.service
|
||||
# Bewusst "restart" statt "enable --now": bei einer erneuten Einrichtung laeuft
|
||||
# der Dienst schon, und "enable --now" wuerde ihn in Ruhe lassen. Er liefe dann
|
||||
# mit dem ALTEN Token weiter und wuerde jeden Druckauftrag ablehnen.
|
||||
systemctl restart bondrucker.service
|
||||
sleep 1
|
||||
systemctl --no-pager --lines=5 status bondrucker.service || true
|
||||
|
||||
echo
|
||||
echo "--- Fertig"
|
||||
echo "Status abfragen: curl -s http://$(hostname -I | awk '{print $1}'):9100/status"
|
||||
echo "Token fuer die .env des Timer-Servers:"
|
||||
echo " PRINTER_TOKEN=$(sed -n 's/^PRINT_TOKEN=//p' /etc/bondrucker/env)"
|
||||
@@ -10,12 +10,21 @@ Geraet. Die Timer-App baut den Bon, der Pi druckt ihn nur noch.
|
||||
Bewusst nur Python-Standardbibliothek: auf dem Pi muss dafuer nichts
|
||||
nachinstalliert werden (kein pip, kein CUPS, kein node).
|
||||
|
||||
Welcher Drucker genommen wird, entscheidet die Timer-App: sie fragt ueber
|
||||
/devices ab, was angeschlossen ist, und schickt den gewaehlten Pfad bei jedem
|
||||
Auftrag im Kopf X-Print-Device mit. Dieser Dienst haelt dazu keinen Zustand -
|
||||
die Einstellung lebt an einer Stelle, naemlich dort, wo sie bedient wird.
|
||||
|
||||
Endpunkte
|
||||
GET /status Zustand des Druckers als JSON (fuer Health-Checks)
|
||||
GET /devices angeschlossene Drucker mit Klartextnamen (Token noetig)
|
||||
POST /print Rumpf = rohe ESC/POS-Bytes, wird 1:1 an den Drucker gereicht
|
||||
|
||||
/status und /print beachten den Kopf X-Print-Device. Fehlt er, wird das
|
||||
eingestellte Geraet genommen und sonst das erste gefundene.
|
||||
|
||||
Konfiguration ueber Umgebungsvariablen (siehe bondrucker.service):
|
||||
PRINT_DEVICE Geraetedatei, Standard /dev/usb/lp0
|
||||
PRINT_DEVICE Rueckfall, wenn die App nichts vorgibt; Standard /dev/usb/lp0
|
||||
PRINT_HOST Lausch-Adresse, Standard 0.0.0.0
|
||||
PRINT_PORT Lausch-Port, Standard 9100
|
||||
PRINT_TOKEN gemeinsames Geheimnis; leer = ohne Anmeldung (nur im LAN!)
|
||||
@@ -26,15 +35,21 @@ import glob
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
VERSION = "1.1"
|
||||
VERSION = "1.2"
|
||||
|
||||
DEVICE = os.environ.get("PRINT_DEVICE", "/dev/usb/lp0")
|
||||
|
||||
# Welcher Drucker genommen wird, entscheidet die Timer-App und schickt es bei
|
||||
# jedem Auftrag mit. Der Pfad kommt damit von aussen - ohne diese Schranke
|
||||
# liesse sich der Dienst dazu bringen, in eine beliebige Datei zu schreiben.
|
||||
DEVICE_MUSTER = re.compile(r"^/dev/(usb/)?lp[0-9]{1,3}$")
|
||||
HOST = os.environ.get("PRINT_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("PRINT_PORT", "9100"))
|
||||
TOKEN = os.environ.get("PRINT_TOKEN", "").strip()
|
||||
@@ -52,21 +67,87 @@ def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def find_device():
|
||||
"""Konfiguriertes Geraet, sonst das erste vorhandene /dev/usb/lp*.
|
||||
def _lies(pfad):
|
||||
try:
|
||||
with open(pfad) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
Nach dem Aus- und Wiedereinstecken des USB-Kabels kann aus lp0 ein lp1
|
||||
werden. Ohne diesen Fallback druckt danach nichts mehr, bis jemand den
|
||||
Dienst anfasst.
|
||||
|
||||
def usb_name(geraet):
|
||||
"""Klartextname eines USB-Druckers, etwa "EPSON TM-T88IV".
|
||||
|
||||
Dahinter steckt ein Aufstieg im sysfs: die Geraetedatei zeigt auf die
|
||||
USB-Schnittstelle, die Bezeichnungen haengen aber am Geraet darueber.
|
||||
Ohne diesen Namen steht in der Auswahl nur "lp0" und "lp1" - bei zwei
|
||||
Druckern ist das Raten.
|
||||
"""
|
||||
basis = os.path.basename(geraet)
|
||||
start = "/sys/class/usbmisc/%s/device" % basis
|
||||
if not os.path.exists(start):
|
||||
start = "/sys/class/printer/%s/device" % basis
|
||||
if not os.path.exists(start):
|
||||
return ""
|
||||
|
||||
pfad = os.path.realpath(start)
|
||||
while pfad and pfad != "/":
|
||||
if os.path.isfile(os.path.join(pfad, "product")):
|
||||
name = _lies(os.path.join(pfad, "manufacturer")) + " " + \
|
||||
_lies(os.path.join(pfad, "product"))
|
||||
return " ".join(name.split())
|
||||
pfad = os.path.dirname(pfad)
|
||||
return ""
|
||||
|
||||
|
||||
def list_devices():
|
||||
"""Alle angeschlossenen Druckergeraete - die Auswahl fuer die Timer-App."""
|
||||
pfade = sorted(set(glob.glob("/dev/usb/lp*")) | set(glob.glob("/dev/lp*")))
|
||||
return [
|
||||
{
|
||||
"path": p,
|
||||
"name": usb_name(p),
|
||||
"writable": os.access(p, os.W_OK),
|
||||
}
|
||||
for p in pfade
|
||||
if DEVICE_MUSTER.match(p)
|
||||
]
|
||||
|
||||
|
||||
def find_device(requested=None):
|
||||
"""Welches Geraet bedient werden soll.
|
||||
|
||||
Mit Angabe: genau dieses, sofern der Pfad ueberhaupt ein Druckergeraet sein
|
||||
kann. Ohne Angabe das eingestellte, und wenn es das nicht gibt, das erste
|
||||
vorhandene - nach dem Aus- und Wiedereinstecken des USB-Kabels kann aus lp0
|
||||
naemlich ein lp1 werden, und ohne diesen Rueckfall druckt danach nichts
|
||||
mehr, bis jemand den Dienst anfasst.
|
||||
"""
|
||||
if requested:
|
||||
if not DEVICE_MUSTER.match(requested):
|
||||
raise ValueError("Unerlaubter Geraetepfad: %s" % requested)
|
||||
return requested
|
||||
if os.path.exists(DEVICE):
|
||||
return DEVICE
|
||||
found = sorted(glob.glob("/dev/usb/lp*"))
|
||||
return found[0] if found else None
|
||||
|
||||
|
||||
def device_status(token_accepted=None):
|
||||
dev = find_device()
|
||||
def device_status(token_accepted=None, requested=None):
|
||||
try:
|
||||
dev = find_device(requested)
|
||||
except ValueError as err:
|
||||
return {
|
||||
"ok": False,
|
||||
"version": VERSION,
|
||||
"tokenRequired": bool(TOKEN),
|
||||
"tokenAccepted": token_accepted,
|
||||
"device": requested,
|
||||
"deviceExists": False,
|
||||
"writable": False,
|
||||
"lastError": str(err),
|
||||
"lastPrintAt": _last_ok,
|
||||
}
|
||||
status = {
|
||||
"ok": False,
|
||||
"version": VERSION,
|
||||
@@ -79,23 +160,24 @@ def device_status(token_accepted=None):
|
||||
"tokenAccepted": token_accepted,
|
||||
"device": dev or DEVICE,
|
||||
"deviceConfigured": DEVICE,
|
||||
"deviceExists": dev is not None,
|
||||
"deviceRequested": requested or None,
|
||||
"deviceExists": dev is not None and os.path.exists(dev),
|
||||
"writable": False,
|
||||
"lastError": _last_error,
|
||||
"lastPrintAt": _last_ok,
|
||||
}
|
||||
if dev:
|
||||
if dev and os.path.exists(dev):
|
||||
status["writable"] = os.access(dev, os.W_OK)
|
||||
status["ok"] = status["writable"]
|
||||
return status
|
||||
|
||||
|
||||
def write_to_printer(data):
|
||||
def write_to_printer(data, requested=None):
|
||||
"""Schreibt einen Auftrag auf den Drucker. Wirft bei Fehlern OSError."""
|
||||
global _last_error, _last_ok
|
||||
|
||||
with _print_lock:
|
||||
dev = find_device()
|
||||
dev = find_device(requested)
|
||||
if dev is None:
|
||||
raise OSError("Kein Druckergeraet gefunden (/dev/usb/lp*). Kabel? Strom?")
|
||||
|
||||
@@ -133,16 +215,29 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.rstrip("/") in ("/status", "/health", ""):
|
||||
st = device_status(token_ok(self.headers.get("X-Print-Token")))
|
||||
pfad = self.path.split("?")[0].rstrip("/")
|
||||
|
||||
if pfad in ("/status", "/health", ""):
|
||||
st = device_status(
|
||||
token_ok(self.headers.get("X-Print-Token")),
|
||||
self.headers.get("X-Print-Device"),
|
||||
)
|
||||
self._send(200 if st["ok"] else 503, st)
|
||||
|
||||
elif pfad == "/devices":
|
||||
# Anders als /status mit Token: welche Geraete am Pi haengen, ist
|
||||
# zwar kein Geheimnis, aber auch keine Auskunft fuer jeden im Netz.
|
||||
if not token_ok(self.headers.get("X-Print-Token")):
|
||||
return self._send(403, {"ok": False, "error": "Token ungueltig"})
|
||||
self._send(200, {"ok": True, "devices": list_devices(), "fallback": find_device()})
|
||||
|
||||
else:
|
||||
self._send(404, {"ok": False, "error": "Unbekannter Pfad"})
|
||||
|
||||
def do_POST(self):
|
||||
global _last_error
|
||||
|
||||
if self.path.rstrip("/") != "/print":
|
||||
if self.path.split("?")[0].rstrip("/") != "/print":
|
||||
return self._send(404, {"ok": False, "error": "Unbekannter Pfad"})
|
||||
|
||||
if not token_ok(self.headers.get("X-Print-Token")):
|
||||
@@ -162,7 +257,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
data = self.rfile.read(length)
|
||||
|
||||
try:
|
||||
written = write_to_printer(data)
|
||||
written = write_to_printer(data, self.headers.get("X-Print-Device"))
|
||||
except ValueError as err:
|
||||
log("[abgelehnt] %s" % err)
|
||||
return self._send(400, {"ok": False, "error": str(err)})
|
||||
except OSError as err:
|
||||
_last_error = str(err)
|
||||
log(f"[fehler] {self.client_address[0]}: {err}")
|
||||
@@ -186,6 +284,8 @@ def main():
|
||||
st = device_status()
|
||||
log(f"Bondrucker-Bruecke {VERSION}")
|
||||
log(f" Geraet: {st['device']} (vorhanden: {st['deviceExists']}, beschreibbar: {st['writable']})")
|
||||
for g in list_devices():
|
||||
log(f" gefunden: {g['path']} {g['name']}")
|
||||
log(f" Lauscht: {HOST}:{PORT}")
|
||||
log(f" Token: {'gesetzt' if TOKEN else 'NICHT gesetzt - jeder im Netz darf drucken'}")
|
||||
|
||||
|
||||
@@ -22,6 +22,13 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
# Die Seite laedt die Portal-Kopfleiste von portal.handy-insel.de.
|
||||
# Wer hier eine Content-Security-Policy setzt, muss diese Quellen erlauben
|
||||
# (sonst blockiert der Browser die Leiste), jeweils zusaetzlich zu den
|
||||
# vorhandenen Quellen (die App nutzt Inline-Styles und onclick-Handler):
|
||||
# script-src ... https://portal.handy-insel.de
|
||||
# connect-src ... https://portal.handy-insel.de
|
||||
# img-src ... https://portal.handy-insel.de https://login.handy-insel.de
|
||||
|
||||
# CSV-Export darf auch bei vielen Einträgen dauern
|
||||
client_max_body_size 1m;
|
||||
|
||||
+258
-48
@@ -11,9 +11,12 @@ const state = {
|
||||
timers: [], // {id, customerName, isRunning, baseMs, syncedAt}
|
||||
rate: { rateAmount: 10, rateInterval: 10 },
|
||||
printerConfigured: false, // steuert, ob die Bon-Knoepfe erscheinen
|
||||
printerDevice: '', // gewaehltes Geraet am Pi, leer = automatisch
|
||||
printAutoOnStop: true, // Bon beim Beenden automatisch drucken
|
||||
tickHandle: null,
|
||||
syncHandle: null,
|
||||
setupWatch: null, // wartet auf die Anmeldung des Drucker-Pi
|
||||
setupCommand: '', // der Befehl, der auf dem Pi ausgefuehrt wird
|
||||
history: { offset: 0, limit: 50, reachedEnd: false }
|
||||
};
|
||||
|
||||
@@ -100,10 +103,13 @@ async function api(url, options = {}) {
|
||||
function switchView(id) {
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||
document.getElementById(id).classList.add('active');
|
||||
for (const btn of topbarNav.children) btn.classList.toggle('active', btn.dataset.view === id);
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('header').style.display = 'none';
|
||||
signedInName = null;
|
||||
renderTopbar();
|
||||
switchView('loginView');
|
||||
applyLoginMode();
|
||||
}
|
||||
@@ -158,28 +164,91 @@ async function enterApp() {
|
||||
startLoops();
|
||||
}
|
||||
|
||||
// Zeigt den angemeldeten Authentik-Nutzer oben rechts im Header.
|
||||
// Zeigt den angemeldeten Authentik-Nutzer: im Benutzer-Menue der
|
||||
// Portal-Kopfleiste, ohne Leiste oben rechts im eigenen Header.
|
||||
function renderCurrentUser() {
|
||||
const el = document.getElementById('currentUser');
|
||||
if (!el) return;
|
||||
const u = loggedInUser;
|
||||
el.textContent = u ? (u.displayName || u.username || '') : '';
|
||||
const name = u ? (u.displayName || u.username || '') : '';
|
||||
const el = document.getElementById('currentUser');
|
||||
el.textContent = name;
|
||||
el.title = u && u.email ? u.email : 'Angemeldet';
|
||||
signedInName = name || 'Angemeldet';
|
||||
renderTopbar();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
function confirmLogout() {
|
||||
const running = state.timers.filter(t => t.isRunning).length;
|
||||
if (running > 0 && !confirm(
|
||||
return running === 0 || confirm(
|
||||
`${running} Timer ${running === 1 ? 'läuft' : 'laufen'} noch.\n\n` +
|
||||
`Sie laufen serverseitig weiter und sind nach dem nächsten Anmelden wieder da. Trotzdem abmelden?`
|
||||
)) return;
|
||||
);
|
||||
}
|
||||
|
||||
// Eigener Abmelden-Knopf, nur sichtbar ohne Portal-Kopfleiste
|
||||
async function logout() {
|
||||
if (!confirmLogout()) return;
|
||||
stopLoops();
|
||||
state.timers = [];
|
||||
// GET-Navigation: beendet lokale Sitzung und – bei Authentik-Login – auch die SSO-Sitzung
|
||||
window.location.href = '/auth/logout';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Portal-Kopfleiste
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// topbar.js laeuft per defer erst nach diesem Skript, die Leiste steht also
|
||||
// fruehestens ab DOMContentLoaded im DOM. Navigation (slot="aktionen") und
|
||||
// Sitzungsanzeige (slot="benutzer") werden einmal eingehaengt, sobald die
|
||||
// Anmeldung steht. Danach aendern sich nur Text und Sichtbarkeit. Ist die
|
||||
// Leiste da, blendet das CSS den eigenen Header aus.
|
||||
const topbarNav = document.createElement('div');
|
||||
topbarNav.slot = 'aktionen';
|
||||
topbarNav.className = 'topbar-nav';
|
||||
for (const [view, icon, label, show] of [
|
||||
['timerView', '⏱️', 'Timer', () => showTimer()],
|
||||
['historyView', '📊', 'Historie', () => showHistory()],
|
||||
['settingsView', '⚙️', 'Einstellungen', () => showSettings()],
|
||||
]) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.dataset.view = view;
|
||||
btn.title = label;
|
||||
btn.append(icon, Object.assign(document.createElement('span'), { textContent: label }));
|
||||
btn.addEventListener('click', show);
|
||||
topbarNav.append(btn);
|
||||
}
|
||||
|
||||
const topbarSession = document.createElement('div');
|
||||
topbarSession.slot = 'benutzer';
|
||||
topbarSession.className = 'topbar-session';
|
||||
topbarSession.append(Object.assign(document.createElement('span'), { textContent: 'Timer – angemeldet als' }),
|
||||
document.createElement('strong'));
|
||||
let signedInName = null; // null = nicht angemeldet
|
||||
|
||||
function renderTopbar() {
|
||||
const signedIn = signedInName !== null;
|
||||
topbarNav.hidden = topbarSession.hidden = !signedIn;
|
||||
if (!signedIn) return;
|
||||
topbarSession.lastChild.textContent = signedInName;
|
||||
if (!topbarSession.isConnected) document.querySelector('hi-topbar')?.append(topbarNav, topbarSession);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', renderTopbar);
|
||||
|
||||
// "Abmelden" in der Leiste: erst die eigene Sitzung beenden, danach meldet
|
||||
// die Leiste bei Portal und Authentik ab. Abbrechen bei laufenden Timern
|
||||
// verhindert das ganze Abmelden.
|
||||
document.addEventListener('hi-abmelden', (e) => {
|
||||
if (!confirmLogout()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
stopLoops();
|
||||
state.timers = [];
|
||||
e.detail.warten(fetch('/api/logout', { method: 'POST', credentials: 'same-origin' }));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timer-Synchronisation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -562,6 +631,7 @@ async function loadSettings() {
|
||||
const settings = await api('/api/settings');
|
||||
state.rate = settings;
|
||||
state.printerConfigured = !!(settings.printer && settings.printer.configured);
|
||||
state.printerDevice = (settings.printer && settings.printer.device) || '';
|
||||
state.printAutoOnStop = settings.printAutoOnStop !== false;
|
||||
|
||||
document.getElementById('rateAmount').value = settings.rateAmount;
|
||||
@@ -576,6 +646,7 @@ async function loadSettings() {
|
||||
updateSettingsPreview();
|
||||
renderPrinterState(settings.printer);
|
||||
refreshPrinterStatus();
|
||||
loadDevices();
|
||||
} catch (e) {
|
||||
toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error');
|
||||
}
|
||||
@@ -668,6 +739,94 @@ async function refreshPrinterStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Druckerliste vom Pi holen.
|
||||
*
|
||||
* Sie kommt bewusst live und nicht aus dem Zwischenspeicher: wer hier auf
|
||||
* "Suchen" tippt, hat gerade ein Kabel umgesteckt und will genau das sehen.
|
||||
*
|
||||
* @param {boolean} lautstark Rueckmeldung geben - beim Knopfdruck ja, beim
|
||||
* stillen Laden der Einstellungen nein.
|
||||
*/
|
||||
async function loadDevices(lautstark = false) {
|
||||
const feld = document.getElementById('printerDevice');
|
||||
const gruppe = document.getElementById('printerDeviceGroup');
|
||||
const hinweis = document.getElementById('printerDeviceHint');
|
||||
if (!feld) return;
|
||||
|
||||
// Ohne eingerichteten Drucker gibt es nichts auszuwaehlen.
|
||||
gruppe.style.display = state.printerConfigured ? '' : 'none';
|
||||
if (!state.printerConfigured) return;
|
||||
|
||||
const gewaehlt = state.printerDevice || '';
|
||||
|
||||
try {
|
||||
const res = await api('/api/printer/devices');
|
||||
state.printerDevice = res.selected || '';
|
||||
|
||||
const automatisch = res.fallback
|
||||
? `automatisch (zurzeit ${res.fallback})`
|
||||
: 'automatisch';
|
||||
|
||||
feld.innerHTML = `<option value="">${escapeHtml(automatisch)}</option>` +
|
||||
res.devices.map(g => {
|
||||
const beschriftung = g.name ? `${g.path} — ${g.name}` : g.path;
|
||||
return `<option value="${escapeHtml(g.path)}">${escapeHtml(beschriftung)}</option>`;
|
||||
}).join('');
|
||||
|
||||
// Der eingestellte Drucker haengt gerade nicht dran. Er darf trotzdem
|
||||
// nicht aus der Liste fallen, sonst stellt ein Speichern ihn unbemerkt
|
||||
// auf "automatisch" um.
|
||||
const eingestellt = res.selected || '';
|
||||
if (eingestellt && !res.devices.some(g => g.path === eingestellt)) {
|
||||
feld.innerHTML += `<option value="${escapeHtml(eingestellt)}">` +
|
||||
`${escapeHtml(eingestellt)} — zurzeit nicht angeschlossen</option>`;
|
||||
}
|
||||
|
||||
feld.value = eingestellt;
|
||||
feld.disabled = false;
|
||||
hinweis.textContent = res.devices.length
|
||||
? 'Welches Gerät am Raspberry Pi bedruckt wird.'
|
||||
: 'Am Pi hängt gerade kein Drucker. Kabel prüfen, dann erneut suchen.';
|
||||
|
||||
if (lautstark) {
|
||||
toast(res.devices.length
|
||||
? `${res.devices.length} Drucker gefunden`
|
||||
: 'Am Pi hängt gerade kein Drucker', res.devices.length ? 'success' : 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
feld.innerHTML = `<option value="">${escapeHtml(gewaehlt || 'automatisch')}</option>`;
|
||||
feld.disabled = true;
|
||||
hinweis.textContent = 'Liste nicht abrufbar: ' + e.message;
|
||||
if (lautstark) toast('Drucker konnten nicht abgerufen werden: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Auswahl sofort speichern - ein eigener Speichern-Knopf waere hier
|
||||
* eine Falle, weil das Feld weit ueber dem Knopf steht. */
|
||||
async function saveDevice() {
|
||||
const feld = document.getElementById('printerDevice');
|
||||
const vorher = state.printerDevice || '';
|
||||
const neu = feld.value;
|
||||
|
||||
try {
|
||||
const antwort = await api('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
rateAmount: state.rate.rateAmount,
|
||||
rateInterval: state.rate.rateInterval,
|
||||
printerDevice: neu
|
||||
})
|
||||
});
|
||||
state.printerDevice = (antwort.printer && antwort.printer.device) || '';
|
||||
toast(neu ? 'Drucker gewählt: ' + neu : 'Drucker wird automatisch gewählt', 'success');
|
||||
await refreshPrinterStatus();
|
||||
} catch (e) {
|
||||
feld.value = vorher;
|
||||
toast('Auswahl nicht gespeichert: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function testPrint() {
|
||||
const btn = document.getElementById('printerTestBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
@@ -692,7 +851,7 @@ function toggleSetup() {
|
||||
box.style.display = offen ? 'none' : 'block';
|
||||
document.getElementById('setupToggleBtn').textContent =
|
||||
offen ? '🔧 Drucker einrichten' : '✕ Einrichtung schließen';
|
||||
if (!offen) document.getElementById('setupHost').focus();
|
||||
if (offen) stopSetupWatch();
|
||||
}
|
||||
|
||||
/** Protokoll der Einrichtung anzeigen - ein Kasten je Schritt. */
|
||||
@@ -711,59 +870,110 @@ function renderSetupLog(steps, laufend) {
|
||||
|
||||
el.innerHTML = zeilen + (laufend
|
||||
? `<div class="setup-step"><span class="setup-step-icon">⏳</span>
|
||||
<div><div class="setup-step-name">läuft …</div></div></div>`
|
||||
<div><div class="setup-step-name">wartet auf den Pi …</div></div></div>`
|
||||
: '');
|
||||
}
|
||||
|
||||
async function startProvision() {
|
||||
const host = document.getElementById('setupHost').value.trim();
|
||||
const port = document.getElementById('setupPort').value;
|
||||
const username = document.getElementById('setupUser').value.trim();
|
||||
const passwordEl = document.getElementById('setupPassword');
|
||||
const password = passwordEl.value;
|
||||
|
||||
if (!host) return toast('Bitte die Adresse des Pi eintragen.', 'error');
|
||||
if (!username) return toast('Bitte den Benutzernamen eintragen.', 'error');
|
||||
if (!password) return toast('Bitte das Passwort eintragen.', 'error');
|
||||
|
||||
if (state.printerConfigured && !confirm(
|
||||
'Es ist bereits ein Drucker eingerichtet.\n\n' +
|
||||
`Nach der Einrichtung druckt die App auf ${host}. Fortfahren?`
|
||||
)) return;
|
||||
|
||||
/** Einrichtungslink erzeugen und danach auf die Rueckmeldung des Pi warten. */
|
||||
async function startSetup() {
|
||||
const btn = document.getElementById('setupStartBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Wird eingerichtet …';
|
||||
renderSetupLog([], true);
|
||||
|
||||
try {
|
||||
const res = await api('/api/printer/provision', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ host, port, username, password })
|
||||
});
|
||||
const res = await api('/api/printer/setup-code', { method: 'POST' });
|
||||
|
||||
// Das Passwort hat seinen Zweck erfuellt und hat im Formular nichts
|
||||
// mehr verloren.
|
||||
passwordEl.value = '';
|
||||
state.setupCommand = res.command;
|
||||
document.getElementById('setupCommand').textContent = res.command;
|
||||
document.getElementById('setupLink').style.display = 'block';
|
||||
|
||||
renderSetupLog(res.steps, false);
|
||||
state.printerConfigured = true;
|
||||
const bis = new Date(res.expiresAt);
|
||||
document.getElementById('setupExpiry').textContent =
|
||||
`Der Link gilt einmal und läuft um ${bis.getHours()}:` +
|
||||
`${String(bis.getMinutes()).padStart(2, '0')} Uhr ab. ` +
|
||||
'Danach hier einfach einen neuen erzeugen.';
|
||||
|
||||
if (res.ready) {
|
||||
toast('Drucker eingerichtet und erreichbar. Ein Probedruck zeigt, ob Papier drin ist.', 'success');
|
||||
} else {
|
||||
toast('Einrichtung durchgelaufen, der Drucker antwortet aber noch nicht – siehe Protokoll.', 'error');
|
||||
}
|
||||
await refreshPrinterStatus();
|
||||
await syncTimers().catch(() => {});
|
||||
renderSetupLog([{ name: 'Link erzeugt', ok: true, detail: res.url }], true);
|
||||
startSetupWatch();
|
||||
toast('Link erzeugt. Jetzt am Pi ausführen.', 'success');
|
||||
} catch (e) {
|
||||
// Die Schritte bis zum Abbruch stecken in der Fehlerantwort und sind
|
||||
// das Wertvollste am ganzen Vorgang - deshalb trotzdem anzeigen.
|
||||
renderSetupLog(e.steps, false);
|
||||
toast('Einrichtung fehlgeschlagen: ' + e.message, 'error');
|
||||
toast('Link konnte nicht erzeugt werden: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 Jetzt einrichten';
|
||||
btn.textContent = '🔗 Neuen Link erzeugen';
|
||||
}
|
||||
}
|
||||
|
||||
function copySetupCommand() {
|
||||
const text = state.setupCommand || '';
|
||||
if (!text) return;
|
||||
|
||||
// navigator.clipboard gibt es nur auf HTTPS oder localhost. Im Laden laeuft
|
||||
// das hier auch mal ueber schlichtes HTTP - dann faellt es auf den alten
|
||||
// Weg zurueck, statt wortlos nichts zu tun.
|
||||
const alterWeg = () => {
|
||||
const feld = document.createElement('textarea');
|
||||
feld.value = text;
|
||||
feld.style.position = 'fixed';
|
||||
feld.style.opacity = '0';
|
||||
document.body.appendChild(feld);
|
||||
feld.select();
|
||||
try { document.execCommand('copy'); } catch (e) { /* dann eben abtippen */ }
|
||||
document.body.removeChild(feld);
|
||||
};
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => toast('Befehl kopiert', 'success'),
|
||||
() => { alterWeg(); toast('Befehl kopiert', 'success'); }
|
||||
);
|
||||
} else {
|
||||
alterWeg();
|
||||
toast('Befehl kopiert', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf die Anmeldung des Pi warten.
|
||||
*
|
||||
* Der Pi meldet sich bei der App, nicht umgekehrt - die App kann also nur
|
||||
* nachsehen. Alle drei Sekunden ist oft genug, um sich wie eine Rueckmeldung
|
||||
* anzufuehlen, und selten genug, um niemandem wehzutun.
|
||||
*/
|
||||
function startSetupWatch() {
|
||||
stopSetupWatch();
|
||||
state.setupWatch = setInterval(async () => {
|
||||
let z;
|
||||
try {
|
||||
z = await api('/api/printer/setup-status');
|
||||
} catch (e) {
|
||||
return; // Netz weg, Anmeldung abgelaufen - beim naechsten Mal wieder
|
||||
}
|
||||
|
||||
if (z.state === 'done') {
|
||||
stopSetupWatch();
|
||||
state.printerConfigured = true;
|
||||
renderSetupLog([
|
||||
{ name: 'Pi angemeldet', ok: true,
|
||||
detail: `${z.host}${z.device ? ' · Drucker ' + z.device : ''}` },
|
||||
{ name: 'Übernommen', ok: true,
|
||||
detail: 'Die App druckt ab jetzt auf diesen Pi. Ein Probedruck zeigt, ob Papier drin ist.' }
|
||||
], false);
|
||||
toast('Drucker eingerichtet: ' + z.host + ' — jetzt oben den Drucker auswählen.', 'success');
|
||||
await refreshPrinterStatus();
|
||||
await loadDevices();
|
||||
await syncTimers().catch(() => {});
|
||||
} else if (z.state === 'expired') {
|
||||
stopSetupWatch();
|
||||
renderSetupLog([{ name: 'Link abgelaufen', ok: false,
|
||||
detail: 'Es hat sich kein Pi gemeldet. Bitte einen neuen Link erzeugen.' }], false);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function stopSetupWatch() {
|
||||
if (state.setupWatch) {
|
||||
clearInterval(state.setupWatch);
|
||||
state.setupWatch = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+126
-37
@@ -1,10 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<!-- data-theme: die App ist immer dunkel, die Portal-Kopfleiste soll es auch sein -->
|
||||
<html lang="de" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Timer - Zeiterfassung</title>
|
||||
<link rel="icon" type="image/jpeg" href="/favicon.jpg">
|
||||
<!-- Zentrale Handy-Insel-Kopfleiste (App-Umschalter + Benutzermenue).
|
||||
Einzige externe Ressource der Seite; Details: portal.handy-insel.de -->
|
||||
<script src="https://portal.handy-insel.de/topbar.js" data-app="timer" defer></script>
|
||||
<style>
|
||||
/* Bewusst KEIN Google-Fonts-Import: kein externer Abruf zur Laufzeit.
|
||||
Der System-Font-Stack sieht praktisch identisch aus (Segoe UI /
|
||||
@@ -220,6 +224,7 @@
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.125rem;
|
||||
@@ -233,6 +238,7 @@
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4d35a9;
|
||||
@@ -644,6 +650,50 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Das Auswahlfeld erbt die Eingabefeld-Optik; die Liste selbst malt der
|
||||
Browser und braucht deshalb eine eigene, dunkle Fuellfarbe. */
|
||||
select option { background: #2d2d44; color: #ffffff; }
|
||||
|
||||
.device-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.device-row select { flex: 1; }
|
||||
.device-row .btn { flex-shrink: 0; }
|
||||
|
||||
.setup-link { margin-top: 1.25rem; }
|
||||
|
||||
/* Der Befehl muss vollstaendig lesbar sein - er wird abgetippt, wenn
|
||||
das Kopieren ins Leere geht (kein HTTPS, fremdes Tablet). Deshalb
|
||||
umbrechen statt abschneiden. */
|
||||
.setup-command {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.setup-command code {
|
||||
flex: 1;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
color: #d7d7ff;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.setup-command .btn { flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.setup-command { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* Protokoll der Einrichtung - Schritt fuer Schritt, damit im Fehlerfall
|
||||
sichtbar ist, wie weit es gekommen ist. */
|
||||
.setup-log {
|
||||
@@ -738,6 +788,49 @@
|
||||
.history-item-right { justify-content: center; }
|
||||
.filter-field, .filter-field input { width: 100%; }
|
||||
}
|
||||
|
||||
/* ---------- Portal-Kopfleiste ---------- */
|
||||
|
||||
/* Die Leiste steht im Fluss als erstes Kind von <body>; body hat nur
|
||||
min-height: 100vh, dadurch entsteht kein Ueberlauf. Beim Drucken
|
||||
soll sie nicht erscheinen. */
|
||||
hi-topbar { flex-shrink: 0; }
|
||||
@media print { hi-topbar { display: none !important; } }
|
||||
|
||||
/* Navigation, Benutzername und Abmelden sitzen in der Leiste. Der eigene
|
||||
Header bleibt nur als Rueckfall, wenn topbar.js nicht laedt
|
||||
(!important, weil app.js ihn per style.display einblendet). */
|
||||
hi-topbar ~ .container #header { display: none !important; }
|
||||
|
||||
/* Navigation rechts in der Leiste (slot="aktionen"), Text erbt Weiss */
|
||||
.topbar-nav { display: flex; gap: 2px; }
|
||||
.topbar-nav[hidden] { display: none; }
|
||||
.topbar-nav button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
height: 32px;
|
||||
padding: 0 0.7rem;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.topbar-nav button:hover { background: rgba(255, 255, 255, 0.14); }
|
||||
.topbar-nav button.active { background: rgba(255, 255, 255, 0.22); }
|
||||
.topbar-nav button:focus-visible { outline: 2px solid #cbbdf7; outline-offset: 1px; }
|
||||
/* Schmal: nur die Symbole, der Name steht im title */
|
||||
@media (max-width: 600px) {
|
||||
.topbar-nav button { padding: 0 0.55rem; font-size: 16px; }
|
||||
.topbar-nav button span { display: none; }
|
||||
}
|
||||
|
||||
/* Sitzungsanzeige im Benutzer-Menue (slot="benutzer"), Schriftfarbe kommt von der Leiste */
|
||||
.topbar-session { padding: 10px 16px; font-size: 13px; line-height: 1.4; }
|
||||
.topbar-session span { display: block; opacity: 0.7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -749,7 +842,7 @@
|
||||
<button class="btn btn-secondary" onclick="showTimer()">⏱️ Timer</button>
|
||||
<button class="btn btn-secondary" onclick="showHistory()">📊 Historie</button>
|
||||
<button class="btn btn-secondary" onclick="showSettings()">⚙️ Einstellungen</button>
|
||||
<button class="btn btn-secondary" onclick="logout()">🚪 Abmelden</button>
|
||||
<button id="logoutBtn" class="btn btn-secondary" onclick="logout()">🚪 Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -856,6 +949,19 @@
|
||||
|
||||
<div id="printerState" class="printer-state">Druckerzustand wird geprüft …</div>
|
||||
|
||||
<div class="form-group" id="printerDeviceGroup">
|
||||
<label for="printerDevice">Drucker am Pi</label>
|
||||
<div class="device-row">
|
||||
<select id="printerDevice" onchange="saveDevice()">
|
||||
<option value="">automatisch</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-sm"
|
||||
onclick="loadDevices(true)">🔄 Suchen</button>
|
||||
</div>
|
||||
<small id="printerDeviceHint">Welches Gerät am Raspberry Pi bedruckt wird.
|
||||
„Automatisch" nimmt den ersten gefundenen.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="receiptHeader">Kopfzeilen</label>
|
||||
<textarea id="receiptHeader" maxlength="240" rows="3"
|
||||
@@ -903,47 +1009,30 @@
|
||||
</div>
|
||||
|
||||
<div class="setup-box" id="setupBox" style="display: none;">
|
||||
<h3 style="margin-bottom: 0.35rem;">Neuen Drucker-Pi einrichten</h3>
|
||||
<h3 style="margin-bottom: 0.35rem;">Drucker-Pi einrichten</h3>
|
||||
<p style="color: rgba(255,255,255,0.7); font-size: 0.9rem; margin-bottom: 1.25rem;">
|
||||
Richtet den Druckdienst auf einem frisch aufgesetzten Raspberry Pi
|
||||
vollständig ein und übernimmt ihn anschließend. Der Pi braucht nur
|
||||
SSH, Python 3 und den Drucker am USB-Anschluss.
|
||||
Die App legt ein Einrichtungsskript bereit. Das wird auf dem Pi
|
||||
heruntergeladen und gestartet — dort wird auch ausgewählt, welcher
|
||||
Drucker verwendet werden soll. Danach meldet sich der Pi von selbst
|
||||
hier an. Der Pi braucht nur Python 3 und den Drucker am USB-Anschluss.
|
||||
</p>
|
||||
|
||||
<div class="setup-grid">
|
||||
<div class="form-group">
|
||||
<label for="setupHost">Adresse des Pi</label>
|
||||
<input type="text" id="setupHost" placeholder="192.168.8.157" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="setupPort">SSH-Port</label>
|
||||
<input type="number" id="setupPort" value="22" min="1" max="65535">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-grid" style="margin-top: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="setupUser">Benutzer</label>
|
||||
<input type="text" id="setupUser" value="pi" autocomplete="off">
|
||||
<small>Muss auf dem Pi sudo dürfen</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="setupPassword">Passwort</label>
|
||||
<input type="password" id="setupPassword" autocomplete="new-password">
|
||||
<small>Wird auch für sudo verwendet</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions" style="margin-top: 1.25rem; margin-bottom: 0;">
|
||||
<div class="settings-actions" style="margin-bottom: 0;">
|
||||
<button type="button" class="btn" id="setupStartBtn"
|
||||
onclick="startProvision()">🚀 Jetzt einrichten</button>
|
||||
onclick="startSetup()">🔗 Einrichtungslink erzeugen</button>
|
||||
</div>
|
||||
|
||||
<p class="setup-hint">
|
||||
Das Passwort wird einmal verwendet und danach verworfen — gespeichert
|
||||
wird nur ein neu erzeugtes Drucker-Token. Ein vorhandener Druckdienst
|
||||
auf demselben Pi wird dabei überschrieben.
|
||||
<div id="setupLink" class="setup-link" style="display: none;">
|
||||
<p class="setup-step-name" style="margin-bottom: 0.5rem;">
|
||||
Auf dem Pi im Terminal ausführen:
|
||||
</p>
|
||||
<div class="setup-command">
|
||||
<code id="setupCommand"></code>
|
||||
<button type="button" class="btn btn-secondary btn-sm"
|
||||
onclick="copySetupCommand()">📋 Kopieren</button>
|
||||
</div>
|
||||
<p class="setup-hint" id="setupExpiry"></p>
|
||||
</div>
|
||||
|
||||
<div class="setup-log" id="setupLog"></div>
|
||||
</div>
|
||||
@@ -966,6 +1055,6 @@
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/app.js?v=20260912-barcode"></script>
|
||||
<script src="/app.js?v=20261025-topbar-nav"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user