Timer-App unter Versionsverwaltung stellen
Stand der laufenden Anwendung von 192.168.1.5:/opt/timer-app (systemd-Dienst timer-app, Port 3003). Zeiterfassung mit Kostenberechnung: Node/Express, SQLite, Anmeldung ausschliesslich ueber Authentik (OIDC, PKCE, RP-Logout) - der frueher vorhandene Passwort-Login ist entfernt. Nicht im Repo, bewusst: - backend/.env und die .env-Sicherung: enthalten Session-Secret und die Authentik-Zugangsdaten. Vorlage ist backend/.env.example. - data/: die Datenbank mit den echten Zeiterfassungen. - node_modules/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
# 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. Die App macht **keine ausgehenden Netzwerkverbindungen**.
|
||||
* **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:** 6 npm-Pakete, alle etabliert (express, express-session,
|
||||
express-rate-limit, sqlite3, bcrypt, dotenv).
|
||||
|
||||
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. |
|
||||
|
||||
`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
|
||||
|
||||
### 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 (~470 Zeilen)
|
||||
│ ├── 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
|
||||
```
|
||||
|
||||
`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 |
|
||||
| `DELETE` | `/api/timers/:id` | verwerfen, **nichts** speichern |
|
||||
| `GET` | `/api/settings` | Tarif lesen |
|
||||
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` |
|
||||
| `GET` | `/api/sessions` | Historie; Parameter `from`, `to`, `q`, `limit`, `offset` |
|
||||
| `DELETE` | `/api/sessions/:id` | Eintrag löschen |
|
||||
| `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.
|
||||
Reference in New Issue
Block a user