Deploy auf den Timer-Server / deploy (push) Failing after 13s
rsync scheitert im Runner-Container am Docker-Host ('pipe: Permission
denied', auch im 22.04-Image). Das Paket geht jetzt per tar hinueber und
wird auf dem Server abgeglichen, wo rsync normal laeuft.
Dabei die Rechte geradegezogen: der Deploy-Benutzer besitzt den Code und
braucht dafuer kein sudo. Das frueher vorgesehene 'chown -R timerapp' nach
jedem Lauf haette ihm die Schreibrechte fuer den naechsten Lauf entzogen -
und die Einrichtungsanleitung haette dem Dienst das Datenverzeichnis
weggenommen. Jetzt: Code gehoert deploy, data/ gehoert timerapp.
Ein Diagnoseschritt meldet zu Beginn Benutzer, Werkzeuge, Rechte und
sudo-Regeln, damit solche Faelle im Protokoll sichtbar sind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
552 lines
22 KiB
Markdown
552 lines
22 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. 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.
|
||
|
||
---
|
||
|
||
## 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 auf den Server und
|
||
gleicht sie dort per `rsync` gegen `/opt/timer-app` ab. (Im Runner-Container
|
||
laesst der Docker-Host rsync nicht laufen - `pipe: Permission denied`.)
|
||
**Nicht angefasst** werden `backend/.env`, `data/` und `node_modules/` —
|
||
sie sind vom Abgleich ausgenommen und ueberleben auch das `--delete`,
|
||
das geloeschte Dateien auf dem Server aufraeumt.
|
||
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`.
|