- Benutzername und "Abmelden" im eigenen Header entfallen, nur noch Rückfall, wenn topbar.js nicht lädt - Sitzungsanzeige als slot="benutzer" im Benutzer-Menü der Leiste - hi-abmelden: POST /api/logout beendet die Timer-Sitzung, laufende Timer fragen vorher nach (Abbrechen stoppt das ganze Abmelden) - html data-theme="dark": die Leiste ist dunkel wie die App, auch bei hellem System - README/DEPLOY: Abmelden, Launch-URL /auth/login, CSP-Hinweis Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
747 lines
33 KiB
Markdown
747 lines
33 KiB
Markdown
# Timer App — Übergabe- und Installationsdokument
|
||
|
||
**Für die Claude-Code-Instanz, die diese App in die Firmeninfrastruktur einbaut.**
|
||
|
||
Stand: 03.08.2026 · Version 2.0 · Getestet auf Debian 13, Node 20.19.2
|
||
|
||
---
|
||
|
||
## 0. Das Wichtigste in 60 Sekunden
|
||
|
||
Kleine, in sich geschlossene Node-App zur Zeiterfassung mit Kostenberechnung,
|
||
gedacht für den Einsatz im Ladengeschäft (Tresen-Tablet, mehrere Plätze parallel).
|
||
|
||
* **Keine externen Dienste.** Kein Cloud-Backend, keine API-Keys, keine Telemetrie,
|
||
keine CDN-Einbindung. Ausgehend spricht die App nur mit zwei Zielen im
|
||
eigenen Netz: Authentik (Anmeldung) und — falls eingerichtet — dem
|
||
Bondrucker-Pi (Abschnitt 6). Kein Ziel außerhalb des eigenen Netzes.
|
||
* **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:** 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**
|
||
scheitern.
|
||
|
||
---
|
||
|
||
## 1. Was die App fachlich tut
|
||
|
||
Ein Mitarbeiter startet am Tresen einen Timer, optional mit einem Namen
|
||
(„Tisch 2", „Herr Müller", „Reparatur Werkbank"). Die App zählt die Zeit und
|
||
rechnet daraus laufend einen Betrag aus. Beim Beenden wandert der Vorgang in die
|
||
Historie und kann später als CSV exportiert werden.
|
||
|
||
**Abrechnungsregel:** Jede *angefangene* Einheit wird voll berechnet.
|
||
Standard ist 10 € pro 10 Minuten, im Menü „Einstellungen" änderbar.
|
||
|
||
| Dauer | Betrag (bei 10 €/10 Min) |
|
||
|---|---|
|
||
| 1 Sekunde | 10 € |
|
||
| 10 Minuten | 10 € |
|
||
| 10 Min 1 Sek | 20 € |
|
||
| 25 Minuten | 30 € |
|
||
| 60 Minuten | 60 € |
|
||
|
||
Diese Regel ist bewusst so — sie war die fachliche Vorgabe. Falls im Betrieb
|
||
minutengenau abgerechnet werden soll, ist das eine Änderung an genau einer
|
||
Funktion (`calcAmount`, siehe Abschnitt 7).
|
||
|
||
---
|
||
|
||
## 2. Architektur — und warum sie so ist
|
||
|
||
### Timer leben auf dem Server, nicht im Browser
|
||
|
||
Das ist die zentrale Designentscheidung und der Grund für die meisten
|
||
Endpunkte. Ein laufender Timer ist eine Zeile in der Tabelle `active_timers`,
|
||
kein JavaScript-Objekt im Browser.
|
||
|
||
Was das im Ladenalltag bedeutet:
|
||
|
||
| Situation | Verhalten |
|
||
|---|---|
|
||
| Tab wird versehentlich geschlossen | Timer läuft weiter, ist nach erneutem Öffnen da |
|
||
| F5 / Seite neu geladen | Timer läuft weiter |
|
||
| Tablet-Akku leer, Neustart | Timer läuft weiter |
|
||
| Kollege schaut am zweiten Gerät nach | sieht dieselben Timer live |
|
||
| Dienst wird neu gestartet (Update) | Timer läuft weiter, **Anmeldung bleibt auch bestehen** |
|
||
| Browser über Nacht zu | Zeit läuft weiter wie eine Wanduhr |
|
||
|
||
**Der letzte Punkt ist eine bewusste fachliche Entscheidung:** Ein laufender
|
||
Timer zählt die reale verstrichene Zeit, weil der Kunde ja tatsächlich weiter
|
||
im Laden sitzt. Wer die Zeit anhalten will, drückt „Pause". Falls das für den
|
||
Firmeneinsatz anders gewünscht ist, siehe Abschnitt 9.
|
||
|
||
Der Browser zählt zwischen zwei Server-Abgleichen nur lokal hoch, damit die
|
||
Anzeige flüssig läuft (alle 250 ms). Abgeglichen wird alle 15 Sekunden und
|
||
zusätzlich sofort, wenn der Tab wieder sichtbar wird. Beim Rechnen wird bewusst
|
||
nur mit *Zeitdifferenzen* gearbeitet, nie mit absoluten Zeitstempeln — so ist
|
||
eine falsch gestellte Uhr auf dem Tablet unerheblich.
|
||
|
||
### Der Betrag entsteht auf dem Server
|
||
|
||
Der Browser berechnet den Betrag nur für die Anzeige. Was in die Historie
|
||
geschrieben wird, rechnet ausschließlich der Server (`POST /api/timers/:id/stop`).
|
||
Ein manipulierter Client kann den gespeicherten Betrag nicht beeinflussen.
|
||
|
||
### Eigener Sitzungsspeicher
|
||
|
||
`express-session` legt Sitzungen standardmäßig im Arbeitsspeicher ab — nach jedem
|
||
Neustart müssten sich alle Geräte neu anmelden. Da die Timer den Neustart
|
||
überleben, wäre das ein Bruch im Ablauf. Deshalb steckt in `server.js` eine
|
||
kleine `SqliteSessionStore`-Klasse (~50 Zeilen), die Sitzungen in derselben
|
||
SQLite-Datei ablegt. **Bewusst ohne Zusatzpaket**, um die Abhängigkeitsliste
|
||
kurz zu halten.
|
||
|
||
---
|
||
|
||
## 3. Die drei Stolpersteine — bitte zuerst lesen
|
||
|
||
### 3.1 `COOKIE_SECURE` (scheitert stumm!)
|
||
|
||
In der `.env` steht standardmäßig `COOKIE_SECURE=true`. Das heißt: Der Browser
|
||
nimmt das Sitzungs-Cookie **nur über HTTPS** an.
|
||
|
||
**Symptom bei falscher Einstellung:** Das Passwort wird akzeptiert (die API
|
||
antwortet mit `200`), aber der Nutzer landet sofort wieder auf der Login-Maske.
|
||
Endlosschleife, keine Fehlermeldung, nichts im Log.
|
||
|
||
| Deployment | Einstellung |
|
||
|---|---|
|
||
| Über Reverse Proxy mit HTTPS (`https://timer.firma.de`) | `COOKIE_SECURE=true` |
|
||
| Intern ohne TLS (`http://192.168.1.50:3003`) | `COOKIE_SECURE=false` |
|
||
| Über `http://localhost` zum Testen | `COOKIE_SECURE=false` |
|
||
|
||
### 3.2 `HOST` — Bindeadresse
|
||
|
||
Standard ist `HOST=127.0.0.1`. Die App ist dann **nur lokal** erreichbar, was
|
||
richtig ist, wenn ein Reverse Proxy davorsteht.
|
||
|
||
Ohne Reverse Proxy (App direkt im LAN): `HOST=0.0.0.0` setzen, sonst kommt kein
|
||
anderes Gerät heran. Dann aber unbedingt auch `COOKIE_SECURE=false` (siehe oben)
|
||
und `TRUST_PROXY=false`.
|
||
|
||
### 3.3 `node_modules` nicht kopieren
|
||
|
||
Das Paket `sqlite3` enthält kompilierte native Binärdateien. Ein aus dieser ZIP
|
||
oder von einem anderen Rechner kopiertes `node_modules` führt zu
|
||
`Error: invalid ELF header` oder `Module did not self-register`.
|
||
|
||
**Immer** auf dem Zielsystem installieren:
|
||
|
||
```bash
|
||
cd backend
|
||
npm ci # bevorzugt, nutzt package-lock.json
|
||
# oder: npm install
|
||
```
|
||
|
||
Falls `npm ci` mangels Compiler scheitert (`node-gyp`-Fehler), fehlen die
|
||
Build-Werkzeuge:
|
||
|
||
```bash
|
||
# Debian/Ubuntu
|
||
apt-get install -y python3 make g++
|
||
# RHEL/Alma
|
||
dnf install -y python3 make gcc-c++
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Installation Schritt für Schritt
|
||
|
||
```bash
|
||
# 1. Entpacken
|
||
unzip timer-app.zip -d /opt/
|
||
cd /opt/timer-app
|
||
|
||
# 2. Abhängigkeiten (auf dem Zielsystem bauen!)
|
||
cd backend
|
||
npm ci
|
||
|
||
# 3. Konfiguration anlegen
|
||
cp .env.example .env
|
||
|
||
# 4. Zufallswert für SESSION_SECRET erzeugen
|
||
npm run gen-secret
|
||
# -> Ausgabe in die .env bei SESSION_SECRET eintragen
|
||
|
||
# 5. Passwort-Hash erzeugen
|
||
npm run hash-password -- "EuerPasswort"
|
||
# -> Ausgabe in die .env bei PASSWORD_HASH eintragen
|
||
|
||
# 6. Datenverzeichnis sicherstellen
|
||
mkdir -p ../data
|
||
|
||
# 7. Testlauf im Vordergrund
|
||
node server.js
|
||
# Erwartete Ausgabe:
|
||
# Timer App laeuft auf http://127.0.0.1:3003
|
||
# Datenbank verbunden: .../data/timer.db
|
||
# Standardtarif gesetzt: 10 EUR pro 10 Minuten
|
||
# Datenbank bereit
|
||
|
||
# 8. Prüfen
|
||
curl http://127.0.0.1:3003/health
|
||
```
|
||
|
||
Die App **bricht beim Start bewusst ab**, wenn `SESSION_SECRET` oder das
|
||
Passwort fehlen — lieber laut scheitern als unsicher laufen.
|
||
|
||
---
|
||
|
||
## 5. Konfiguration (`.env`)
|
||
|
||
| Variable | Standard | Bedeutung |
|
||
|---|---|---|
|
||
| `PORT` | `3003` | Lauschender Port |
|
||
| `HOST` | `127.0.0.1` | Bindeadresse — siehe 3.2 |
|
||
| `TRUST_PROXY` | `1` | Anzahl Proxy-Ebenen davor; ohne Proxy `false` |
|
||
| `COOKIE_SECURE` | `true` | Cookie nur über HTTPS — siehe 3.1 |
|
||
| `SESSION_SECRET` | — | **Pflicht.** Zufallswert, `npm run gen-secret` |
|
||
| `PASSWORD_HASH` | — | bcrypt-Hash, `npm run hash-password -- "…"` |
|
||
| `PASSWORD` | — | Notfall-Alternative im Klartext; App warnt beim Start |
|
||
| `LOGIN_MAX_ATTEMPTS` | `20` | Anmeldeversuche pro Zeitfenster |
|
||
| `LOGIN_WINDOW_MIN` | `15` | Länge des Zeitfensters in Minuten |
|
||
| `DB_PATH` | `../data/timer.db` | Pfad zur SQLite-Datei |
|
||
| `RATE_PER_10MIN` | `10` | **Nur beim allerersten Start.** Danach steht der Tarif in der Datenbank und wird über die Oberfläche gepflegt. |
|
||
| `PRINTER_HOST` | leer | IP des Bondrucker-Pi. **Leer = Druckfunktion aus**, die App verhält sich wie vorher |
|
||
| `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
|
||
der Druckerzugang in der Datenbank und wird über die Oberfläche gepflegt; eine
|
||
spätere Änderung in der `.env` bleibt wirkungslos. Der Grund steht in
|
||
Abschnitt 6: ein Druckerwechsel soll kein Server-Zugang sein.
|
||
|
||
`SESSION_SECRET` muss ein *echter* Zufallswert sein. In der Vorgängerversion
|
||
stand dort versehentlich der literale Text `$(openssl rand -base64 32)` — die
|
||
Befehlssubstitution war beim Anlegen der Datei nie ausgeführt worden. Mit einem
|
||
erratbaren Secret lassen sich Sitzungs-Cookies fälschen und die Anmeldung
|
||
umgehen. Deshalb: Wert immer mit `npm run gen-secret` erzeugen, nie von Hand
|
||
ausdenken, nie aus einer Doku kopieren.
|
||
|
||
---
|
||
|
||
## 6. Betrieb
|
||
|
||
### systemd-Unit
|
||
|
||
Vorlage liegt bei unter `deploy/timer-app.service`. Anpassen: `WorkingDirectory`,
|
||
`User`, ggf. `ExecStart`-Pfad zu node.
|
||
|
||
```bash
|
||
cp deploy/timer-app.service /etc/systemd/system/
|
||
systemctl daemon-reload
|
||
systemctl enable --now timer-app
|
||
systemctl status timer-app
|
||
journalctl -u timer-app -f
|
||
```
|
||
|
||
Die Unit läuft in der Vorlage als eigener Benutzer (nicht root) und enthält
|
||
die üblichen Härtungsoptionen. Wenn ein eigener Benutzer angelegt wird, muss
|
||
ihm das `data/`-Verzeichnis gehören:
|
||
|
||
```bash
|
||
useradd --system --no-create-home --shell /usr/sbin/nologin timerapp
|
||
chown -R timerapp:timerapp /opt/timer-app/data
|
||
chown timerapp:timerapp /opt/timer-app/backend/.env
|
||
chmod 600 /opt/timer-app/backend/.env
|
||
```
|
||
|
||
### Reverse Proxy
|
||
|
||
Beispiele liegen bei: `deploy/Caddyfile.example` und `deploy/nginx.conf.example`.
|
||
|
||
Wichtig in beiden Fällen:
|
||
* `X-Forwarded-Proto` durchreichen (sonst greift `trust proxy` nicht richtig)
|
||
* `/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
|
||
Wesentliche.
|
||
|
||
Der Drucker hängt per USB an einem Raspberry Pi im Laden. Auf dem Pi läuft ein
|
||
kleiner Python-Dienst, der fertige ESC/POS-Daten per HTTP annimmt und in
|
||
`/dev/usb/lp0` schreibt. Der Bon selbst entsteht in dieser App
|
||
(`backend/receipt.js`) — auf dem Pi liegt bewusst keine Layout-Logik.
|
||
|
||
```
|
||
Timer-App ──HTTP POST /print──▶ Pi (bondrucker.service) ──▶ /dev/usb/lp0
|
||
receipt.js baut den Bon schiebt Bytes durch Epson TM-T88IV
|
||
```
|
||
|
||
Drei Wege führen zu einem Bon:
|
||
|
||
| Auslöser | Beleg | Timer danach |
|
||
|---|---|---|
|
||
| **🖨 Bon** auf der Timer-Karte | Zwischenstand | läuft unverändert weiter |
|
||
| **✓ Beenden** | Endbeleg | ist beendet und in der Historie |
|
||
| **🖨** in der Historie | Nachdruck desselben Belegs | — |
|
||
|
||
#### Strichcode
|
||
|
||
Unter dem Betrag steht ein Strichcode mit dem Artikelnamen, den die Kasse führt
|
||
(ab Werk `techniker`). Name und An/Aus stehen in den Einstellungen, also in der
|
||
Datenbank — nicht im Code und nicht in der `.env`.
|
||
|
||
Der Code steht ohne Beschriftung und ohne die sonst übliche Klarschrift darunter
|
||
(`GS H 0`): auf einem Kundenbeleg hat ein interner Artikelname nichts verloren.
|
||
|
||
Symbologie ist **CODE128, Zeichensatz B**. CODE39, sonst der Standard für solche
|
||
Fälle, kennt nur Großbuchstaben und könnte `techniker` gar nicht abbilden.
|
||
Zeichensatz B deckt den druckbaren ASCII-Bereich ab; alles darüber — Umlaute
|
||
zumal — weist `PUT /api/settings` ab, statt es still zu verschlucken.
|
||
|
||
Die Modulbreite wählt `escpos.js` selbst: so breit, wie die Papierbreite es
|
||
zulässt (Ruhezonen eingerechnet), denn breitere Balken liest ein Handscanner
|
||
zuverlässiger. Daraus folgt die Längengrenze — bei 42 Zeichen je Zeile sind es
|
||
17 Zeichen, bei 32 noch 12. Passt der Name nicht, druckt `receipt.js` ihn nur
|
||
in Klarschrift: ein abgeschnittener Code würde ohnehin von keinem Scanner
|
||
gelesen.
|
||
|
||
**Reihenfolge ist Absicht:** Beim Beenden wird erst gespeichert, dann gedruckt.
|
||
Ein leerer Papierhalter ist damit ein Papierproblem und kein Datenproblem — der
|
||
Vorgang steht in der Historie und lässt sich von dort nachdrucken. Die
|
||
Oberfläche sagt in dem Fall ausdrücklich, dass der Bon *nicht* gedruckt wurde.
|
||
|
||
Kopf- und Fußzeilen des Bons stehen in der Datenbank und werden unter
|
||
**Einstellungen** gepflegt, ebenso der Schalter „Bon beim Beenden automatisch
|
||
drucken". Der Probedruck dort beantwortet die Frage, ob es am Drucker oder an
|
||
der App liegt.
|
||
|
||
#### Einen Pi einrichten
|
||
|
||
⚙️ Einstellungen → **🔧 Drucker einrichten** → **🔗 Einrichtungslink erzeugen**.
|
||
Die App zeigt einen fertigen Befehl an, der auf dem Pi ausgeführt wird:
|
||
|
||
```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 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.
|
||
|
||
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 — 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 der Drucker neu eingerichtet oder die Adresse in den
|
||
Einstellungen angepasst ist — eine DHCP-Reservierung im Router erspart das.
|
||
|
||
### Backup
|
||
|
||
Alles Wichtige steckt in **einer Datei**: `data/timer.db`.
|
||
|
||
```bash
|
||
# konsistente Sicherung im laufenden Betrieb (WAL-Modus ist aktiv!)
|
||
sqlite3 /opt/timer-app/data/timer.db ".backup '/backup/timer-$(date +%F).db'"
|
||
```
|
||
|
||
⚠️ **Nicht** einfach `cp` auf eine laufende Datenbank — im WAL-Modus kann eine
|
||
so erzeugte Kopie unvollständig sein. Entweder `.backup` benutzen (wie oben)
|
||
oder den Dienst vorher stoppen.
|
||
|
||
Die `.env` gehört ebenfalls ins Backup (enthält Secret und Passwort-Hash),
|
||
aber getrennt und mit engeren Rechten.
|
||
|
||
---
|
||
|
||
## 7. Aufbau des Codes
|
||
|
||
```
|
||
timer-app/
|
||
├── DEPLOY.md ← dieses Dokument
|
||
├── README.md ← Kurzanleitung für Anwender
|
||
├── backend/
|
||
│ ├── server.js ← die gesamte Server-Logik
|
||
│ ├── escpos.js ← ESC/POS-Bausteine für den Bondrucker
|
||
│ ├── receipt.js ← Layout des Bons
|
||
│ ├── printer.js ← Aufruf der Brücke auf dem Pi
|
||
│ ├── setup.js ← Einrichtungscode und Skript für den Pi
|
||
│ ├── package.json
|
||
│ ├── package-lock.json
|
||
│ ├── .env.example
|
||
│ └── tools/hash-password.js ← Hilfsskript für den Passwort-Hash
|
||
├── frontend/
|
||
│ ├── index.html ← Markup + komplettes CSS
|
||
│ ├── app.js ← Oberflächenlogik
|
||
│ ├── logo.png ← ⚠️ Firmenlogo, ggf. austauschen
|
||
│ └── favicon.jpg
|
||
└── deploy/
|
||
├── timer-app.service
|
||
├── Caddyfile.example
|
||
├── nginx.conf.example
|
||
└── bondrucker/ ← Druckdienst für den Raspberry Pi
|
||
├── README.md ← Einrichtung des Pi
|
||
├── print-server.py ← der Dienst (nur Python-Standardbibliothek)
|
||
├── bondrucker.service
|
||
└── einrichten.sh ← Vorlage, die die App beim Abholen füllt
|
||
```
|
||
|
||
`server.js` ist in beschriftete Abschnitte gegliedert (Konfiguration, Datenbank,
|
||
Tarif & Berechnung, Sitzungsspeicher, Auth, Timer, Einstellungen, Historie).
|
||
|
||
### Datenmodell
|
||
|
||
```sql
|
||
-- Laufende Timer: eine Zeile pro Platz/Kunde
|
||
active_timers(
|
||
id, customer_name, started_at, accumulated_ms, is_running, created_at
|
||
)
|
||
-- started_at: ms-Zeitstempel des letzten Starts/Fortsetzens (NULL bei Pause)
|
||
-- accumulated_ms: vor der aktuellen Laufphase gesammelte Zeit
|
||
-- Vergangene Zeit = accumulated_ms + (jetzt - started_at), falls is_running
|
||
|
||
-- Abgeschlossene Vorgänge
|
||
sessions(
|
||
id, session_id, start_time, end_time, duration, amount,
|
||
customer_name, rate_amount, rate_interval, created_at
|
||
)
|
||
-- rate_amount/rate_interval halten fest, welcher Tarif beim Beenden galt,
|
||
-- damit eine spätere Tarifänderung alte Belege nicht rückwirkend verändert.
|
||
-- created_at ist in SEKUNDEN, alle anderen Zeiten in MILLISEKUNDEN (historisch).
|
||
|
||
settings(key, value) -- rate_amount, rate_interval
|
||
user_sessions(sid, expires, data) -- Anmelde-Sitzungen
|
||
```
|
||
|
||
Das Schema wird beim Start **automatisch migriert** (`ALTER TABLE ADD COLUMN`,
|
||
nur falls Spalte fehlt). Eine Datenbank der Vorgängerversion kann direkt
|
||
weiterverwendet werden; getestet mit 21 Altdatensätzen, die unverändert erhalten
|
||
blieben und in der Oberfläche als „ohne Namen" erscheinen.
|
||
|
||
### Die Abrechnungsregel ändern
|
||
|
||
Genau eine Funktion in `server.js`, plus die gleichnamige in `app.js` für
|
||
die Anzeige:
|
||
|
||
```js
|
||
function calcAmount(ms, rateAmount, rateInterval) {
|
||
if (!ms || ms <= 0) return 0;
|
||
const minutes = Math.ceil(ms / 60000); // angefangene Minute
|
||
const units = Math.ceil(minutes / rateInterval); // angefangene Einheit
|
||
return Math.round(units * rateAmount * 100) / 100;
|
||
}
|
||
```
|
||
|
||
Für minutengenaue Abrechnung z. B.:
|
||
`return Math.round((ms / 60000) * (rateAmount / rateInterval) * 100) / 100;`
|
||
|
||
---
|
||
|
||
## 8. API-Referenz
|
||
|
||
Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und
|
||
`/api/logout` erfordern eine gültige Sitzung (sonst `401`).
|
||
|
||
| Methode | Pfad | Zweck |
|
||
|---|---|---|
|
||
| `POST` | `/api/login` | `{password}` → Sitzung |
|
||
| `GET` | `/api/auth/status` | `{authenticated: bool}` |
|
||
| `POST` | `/api/logout` | Sitzung beenden |
|
||
| `GET` | `/api/timers` | alle laufenden Timer + aktueller Tarif |
|
||
| `POST` | `/api/timers` | `{customerName}` → neuer Timer |
|
||
| `PATCH` | `/api/timers/:id` | `{customerName}` umbenennen |
|
||
| `POST` | `/api/timers/:id/pause` | anhalten |
|
||
| `POST` | `/api/timers/:id/resume` | fortsetzen |
|
||
| `POST` | `/api/timers/:id/stop` | beenden **und** in Historie speichern; druckt den Bon, sofern eingerichtet. Optional `{print: bool}` überstimmt die Einstellung |
|
||
| `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, 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/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 |
|
||
| `GET` | `/api/sessions/export.csv` | CSV; gleiche Filterparameter |
|
||
| `GET` | `/health` | Status, ohne Anmeldung |
|
||
|
||
`from`/`to` sind Datumsangaben `YYYY-MM-DD` und werden in der **Zeitzone des
|
||
Servers** ausgewertet. Wenn Server und Laden in verschiedenen Zeitzonen stehen,
|
||
`TZ=Europe/Berlin` in der systemd-Unit setzen.
|
||
|
||
Die CSV ist für deutsches Excel vorbereitet: Semikolon als Trenner, UTF-8-BOM
|
||
(damit Umlaute stimmen), Komma als Dezimaltrennzeichen.
|
||
|
||
---
|
||
|
||
## 9. Bekannte Grenzen — bewusst so gelassen
|
||
|
||
Diese Punkte sind **keine Fehler**, sondern Entscheidungen. Sie stehen hier,
|
||
damit sie bei Bedarf eingeordnet werden können.
|
||
|
||
| Thema | Aktueller Stand | Wenn die Firma mehr braucht |
|
||
|---|---|---|
|
||
| **Ein gemeinsames Passwort** | Alle teilen sich ein Passwort, keine Benutzerkonten, keine 2FA | Für „wer hat welchen Vorgang erfasst" wären echte Konten nötig. Der Sitzungsspeicher liegt bereits in der DB, eine `users`-Tabelle wäre der nächste Schritt. |
|
||
| **Jeder sieht alles** | Wer angemeldet ist, sieht alle Timer und die gesamte Historie | Im Ladenbetrieb gewollt (Kollege übernimmt die Schicht). Mandantentrennung wäre ein größerer Umbau. |
|
||
| **Kein Audit-Log** | Löschen eines Historien-Eintrags ist endgültig und wird nicht protokolliert | Falls die Erfassung abrechnungsrelevant wird, ist ein `deleted_at` statt echtem Löschen sinnvoll (GoBD-Thema — bitte fachlich klären). |
|
||
| **Wanduhr-Verhalten** | Ein laufender Timer zählt auch nachts weiter | Wenn stattdessen automatisch pausiert werden soll: eine Obergrenze in `elapsedMs()` einbauen oder einen nächtlichen Auto-Pause-Job. |
|
||
| **Keine Mehrprozess-Fähigkeit** | Ein Node-Prozess, SQLite | Für die erwartete Last (ein Laden, wenige Geräte) um Größenordnungen ausreichend. Bei mehreren Filialen: PostgreSQL und echte Benutzerverwaltung. |
|
||
| **Logo** | `frontend/logo.png` ist das Logo des bisherigen Einsatzorts, 654 KB | Austauschen und dabei gern verkleinern (~50 KB reichen). Ebenso `favicon.jpg`. Der Seitentitel steht in `index.html` (`<title>`). |
|
||
| **Zeitzone** | Server-Zeitzone bestimmt Tagesgrenzen im Filter | `TZ` in der Unit setzen |
|
||
|
||
---
|
||
|
||
## 10. Fehlersuche
|
||
|
||
| Symptom | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Login akzeptiert, springt zurück zur Maske | `COOKIE_SECURE=true` ohne HTTPS | `COOKIE_SECURE=false` oder TLS davor |
|
||
| Von anderen Geräten nicht erreichbar | `HOST=127.0.0.1` ohne Proxy | `HOST=0.0.0.0` |
|
||
| `Error: invalid ELF header` | fremdes `node_modules` | löschen, `npm ci` |
|
||
| `node-gyp`-Fehler bei `npm ci` | Build-Werkzeuge fehlen | `apt install python3 make g++` |
|
||
| `FATAL: SESSION_SECRET ist nicht gesetzt` | `.env` fehlt/unvollständig | Abschnitt 4, Schritt 3–5 |
|
||
| `SQLITE_CANTOPEN` | `data/` fehlt oder Rechte | `mkdir -p data && chown` |
|
||
| Zeiten wirken um Stunden verschoben | Server-Zeitzone | `TZ=Europe/Berlin` in der Unit |
|
||
| Alle ausgeloggt nach Neustart | sollte **nicht** passieren | prüfen, ob `user_sessions` in der DB existiert |
|
||
|
||
Diagnose-Einstieg:
|
||
|
||
```bash
|
||
systemctl status timer-app
|
||
journalctl -u timer-app -n 50 --no-pager
|
||
curl -s http://127.0.0.1:3003/health
|
||
sqlite3 data/timer.db "select count(*) from sessions; select * from active_timers;"
|
||
```
|
||
|
||
---
|
||
|
||
## 11. Sicherheit — Stand und Empfehlungen
|
||
|
||
Vorhanden:
|
||
* Passwort als bcrypt-Hash (Kostenfaktor 12), nicht im Klartext im Code
|
||
* Sitzungs-ID wird beim Login neu vergeben (kein Session-Fixation)
|
||
* Cookie `httpOnly`, `sameSite=lax`, optional `secure`
|
||
* Ratenbegrenzung auf dem Login (20 Versuche / 15 Min, konfigurierbar)
|
||
* Alle SQL-Zugriffe mit gebundenen Parametern (keine String-Verkettung)
|
||
* Ausgabe im Frontend wird escaped (kein `innerHTML` mit Rohdaten aus der DB)
|
||
* Beträge werden serverseitig berechnet
|
||
|
||
Vor dem Produktivgang zu klären:
|
||
1. **Erreichbarkeit.** Muss die App aus dem Internet erreichbar sein oder reicht
|
||
das Firmen-LAN/VPN? Ein gemeinsames Passwort ohne 2FA gehört nach Möglichkeit
|
||
nicht offen ins Internet.
|
||
2. **Passwort wechseln.** Der Hash in dieser ZIP ist **nicht** gesetzt —
|
||
`.env.example` enthält keinen. Neu erzeugen (Abschnitt 4).
|
||
3. **TLS.** Wenn öffentlich erreichbar: Reverse Proxy mit Zertifikat und
|
||
`COOKIE_SECURE=true`.
|
||
4. **Personenbezug.** Sobald echte Kundennamen eingetippt werden, sind das
|
||
personenbezogene Daten — Aufbewahrungsdauer und Löschkonzept mit der
|
||
zuständigen Stelle klären. Ein Löschbefehl für alte Einträge:
|
||
```sql
|
||
DELETE FROM sessions WHERE created_at < strftime('%s','now','-90 days');
|
||
```
|
||
|
||
---
|
||
|
||
## 12. Was beim letzten Umbau geändert wurde (03.08.2026)
|
||
|
||
Für den Fall, dass jemand die Vorgängerversion kennt:
|
||
|
||
* Timer laufen jetzt serverseitig statt im Browser — überleben Reload, Neustart
|
||
und Gerätewechsel *(vorher ging bei F5 die gesamte Zeit verloren)*
|
||
* Mehrere Timer gleichzeitig, jeder mit eigenem Namen *(vorher genau einer)*
|
||
* Tarif serverseitig und geräteübergreifend *(vorher `localStorage`, pro Browser
|
||
unterschiedlich, und der Betrag kam vom Client)*
|
||
* Historie mit Datums- und Namensfilter, Seitenweise-Nachladen, Löschen-Knopf,
|
||
CSV-Export *(vorher: fest 100 Einträge, kein Filter, Löschen nur über die DB)*
|
||
* Anmeldung mit bcrypt statt Klartextvergleich; kein fest eingebautes
|
||
Ersatzpasswort mehr im Quelltext
|
||
* Sitzungen überleben Neustarts
|
||
* `HOST`, `COOKIE_SECURE`, `TRUST_PROXY`, `DB_PATH` konfigurierbar
|
||
* Kein Google-Fonts-Abruf mehr — die Schrift kam vorher bei jedem Seitenaufruf
|
||
von einem externen Server, was in Firmennetzen blockiert sein kann und
|
||
datenschutzrechtlich unnötig Angriffsfläche bietet. Jetzt System-Schriften.
|
||
* Sauberes Herunterfahren bei SIGTERM, WAL-Modus, Index auf `created_at`
|
||
|
||
Getestet: 36 automatisierte Prüfungen (Anmeldung, Parallelbetrieb, Pause/Weiter,
|
||
Neustart-Persistenz mit echtem Prozesswechsel, Speichern/Verwerfen, Tarifwechsel,
|
||
CSV, Filter, Löschen, Abmelden) sowie ein Durchlauf im echten Browser.
|
||
|
||
---
|
||
|
||
## 13. Wenn etwas unklar ist
|
||
|
||
Die App ist klein genug, um sie vollständig zu lesen: `server.js` ~470 Zeilen,
|
||
`app.js` ~460 Zeilen, beide durchgehend kommentiert und auf Deutsch beschriftet.
|
||
Im Zweifel ist das Lesen der Quelle schneller als das Raten.
|
||
|
||
---
|
||
|
||
## Automatischer Deploy per Gitea Actions
|
||
|
||
Jeder Push auf `main` in `git.handy-insel.de/kerimatilgan/timer-app` rollt den
|
||
Stand selbsttaetig auf den Server aus. Der Workflow liegt in
|
||
`.gitea/workflows/deploy.yaml` und laesst sich in der Gitea-Oberflaeche unter
|
||
"Actions" auch von Hand starten.
|
||
|
||
### Was der Workflow tut
|
||
|
||
1. Uebertraegt die versionierten Dateien als tar-Paket und entpackt sie ueber
|
||
`/opt/timer-app`. Kein rsync: im Runner-Container verbietet der Docker-Host
|
||
dessen `pipe()`-Aufruf, und auf dem Zielhost ist rsync nicht installiert.
|
||
**Nicht angefasst** werden `backend/.env`, `data/` und `node_modules/` —
|
||
sie liegen nicht im Repo und sind damit auch nicht im Paket.
|
||
Im Repo geloeschte Dateien werden auf dem Server entfernt: die Liste kommt
|
||
aus `git diff` gegen den zuletzt ausgerollten Commit, der als Merkzettel
|
||
in `/opt/timer-app/.deploy-commit` liegt.
|
||
2. `npm ci --omit=dev` **nur**, wenn sich `backend/package*.json` geaendert hat.
|
||
Das laeuft bewusst auf dem Zielhost: `bcrypt` und `sqlite3` sind native
|
||
Module und muessen zur dortigen Architektur passen.
|
||
3. Neustart des Dienstes **nur**, wenn sich etwas unter `backend/` oder
|
||
`deploy/` geaendert hat. Reine Frontend-Aenderungen brauchen keinen.
|
||
4. Health-Check gegen `/health` (15 Versuche, 30 s). Antwortet der Dienst
|
||
nicht, schlaegt der Lauf fehl und die letzten 40 Journal-Zeilen stehen
|
||
im Protokoll.
|
||
|
||
### Einmalige Einrichtung
|
||
|
||
Auf dem Zielhost einen Deploy-Benutzer anlegen, ihm das Verzeichnis geben und
|
||
den Neustart des einen Dienstes erlauben:
|
||
|
||
```bash
|
||
useradd -r -m -s /bin/bash deploy
|
||
mkdir -p /home/deploy/.ssh && chmod 700 /home/deploy/.ssh
|
||
echo '<oeffentlicher Schluessel>' >> /home/deploy/.ssh/authorized_keys
|
||
chown -R deploy:deploy /home/deploy/.ssh && chmod 600 /home/deploy/.ssh/authorized_keys
|
||
|
||
# Der Code gehoert dem Deploy-Benutzer, der Dienst liest ihn nur.
|
||
chown -R deploy:timerapp /opt/timer-app && chmod -R g+rX /opt/timer-app
|
||
|
||
# WICHTIG: das Datenverzeichnis gehoert dem Dienst - sonst kann die
|
||
# Anwendung ihre Datenbank nicht mehr schreiben.
|
||
chown -R timerapp:timerapp /opt/timer-app/data && chmod 750 /opt/timer-app/data
|
||
|
||
# Genau die drei Befehle ohne Passwort - nicht mehr
|
||
cat > /etc/sudoers.d/deploy-timer-app <<'EOF'
|
||
deploy ALL=(root) NOPASSWD: /bin/systemctl restart timer-app, \
|
||
/bin/journalctl -u timer-app *
|
||
EOF
|
||
chmod 440 /etc/sudoers.d/deploy-timer-app
|
||
visudo -c
|
||
```
|
||
|
||
Im Repo unter **Settings -> Actions** hinterlegen:
|
||
|
||
| Art | Name | Wert |
|
||
|---|---|---|
|
||
| Variable | `DEPLOY_HOST` | `192.168.1.5` |
|
||
| Variable | `DEPLOY_USER` | `deploy` |
|
||
| Variable | `DEPLOY_PATH` | `/opt/timer-app` |
|
||
| Variable | `DEPLOY_SERVICE` | `timer-app` |
|
||
| Variable | `HEALTH_URL` | `http://127.0.0.1:3003/health` |
|
||
| Secret | `DEPLOY_SSH_KEY` | privater Deploy-Schluessel (ganze Datei inkl. BEGIN/END-Zeilen) |
|
||
| Secret | `DEPLOY_KNOWN_HOSTS` | `ssh-keyscan 192.168.1.5` — Zeile(n) fuer den Zielhost |
|
||
|
||
### Grenzen
|
||
|
||
- Die `.env` wird **nie** ausgerollt. Kommt eine neue Einstellung dazu, gehoert
|
||
sie von Hand in die `.env` auf dem Server (Vorlage: `backend/.env.example`),
|
||
sonst startet der Dienst nach dem Deploy womoeglich nicht.
|
||
- Es gibt keinen automatischen Rollback. Faellt der Health-Check durch, laeuft
|
||
der Dienst weiter im Fehlerzustand — der Lauf ist rot und das Journal steht
|
||
im Protokoll. Zurueck geht es mit einem Revert-Commit auf `main`.
|