Die Einrichtung war 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. Jetzt macht das ein Knopf unter "Einstellungen": Adresse, Benutzer und Passwort eintragen, und die App meldet sich per SSH an, prueft Python/systemd/Druckergeraet, uebertraegt deploy/bondrucker/, startet install.sh mit einem selbst erzeugten Token, raeumt auf und prueft von aussen nach. Jeder Schritt steht mit Ergebnis im Protokoll. Damit das ueberhaupt Sinn ergibt, liegt der Druckerzugang jetzt in der Datenbank statt in der .env - sonst muesste hinterher doch wieder jemand auf den Server. Die PRINTER_*-Werte in der .env sind nur noch Startwerte beim allerersten Start, wie RATE_PER_10MIN auch. Zwei Fehler, die beim Testen der Einrichtung auffielen: install.sh startete den Dienst nicht neu, sondern nur "enable --now". Bei einer erneuten Einrichtung lief die Bruecke deshalb mit dem ALTEN Token weiter und lehnte jeden Bon mit HTTP 403 ab. Jetzt: restart. Die Gegenprobe fragte nur /status ab - und /status prueft kein Token. Ein Dienst mit altem Token galt damit faelschlich als bereit. Die Bruecke meldet in /status jetzt zusaetzlich, ob das mitgeschickte Token passt (Version 1.1), und die Einrichtung prueft das mit. Zugangsdaten werden einmal benutzt und danach vergessen; das erzeugte Token wird aus dem Protokoll entfernt, bevor es in den Browser oder ins Journal geht. Neu dabei: ssh2, reines JavaScript. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
651 lines
28 KiB
Markdown
651 lines
28 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:** 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).
|
||
|
||
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_TIMEOUT_MS` | `6000` | Abbruch, wenn der Pi nicht antwortet |
|
||
|
||
**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
|
||
|
||
### 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 | — |
|
||
|
||
**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** richtet einen frisch aufgesetzten
|
||
Raspberry Pi vollständig selbst ein: IP, Benutzer und Passwort eintragen,
|
||
Knopf drücken, fertig — rund drei Sekunden.
|
||
|
||
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.
|
||
|
||
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
|
||
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
|
||
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.
|
||
|
||
**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.
|
||
|
||
### 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
|
||
│ ├── provision.js ← richtet einen Drucker-Pi per SSH ein
|
||
│ ├── 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
|
||
└── install.sh
|
||
```
|
||
|
||
`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}` |
|
||
| `GET` | `/api/printer/status` | Erreichbarkeit und Zustand des Druckers |
|
||
| `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 |
|
||
| `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`.
|