Files
timer-app/DEPLOY.md
T
adminandClaude Opus 5 1cd966a208
Deploy auf den Timer-Server / deploy (push) Failing after 2s
Deploy per Gitea Actions auf 192.168.1.5
Push auf main rollt den Stand selbst aus, statt ihn von Hand zu kopieren.
Der Runner (pve-runner) liegt im selben LAN wie der Zielhost.

Drei Dinge, die dabei nicht passieren duerfen und deshalb abgesichert sind:

- Die .env, das data-Verzeichnis und node_modules sind vom rsync-Abgleich
  ausgenommen. Das --delete raeumt zwar geloeschte Dateien weg, laesst diese
  Pfade aber in Ruhe (kein --delete-excluded).
- npm ci laeuft auf dem Zielhost, nicht im Runner-Container: bcrypt und
  sqlite3 sind native Module und muessen zur dortigen Umgebung passen.
  Aufgerufen wird es nur, wenn sich backend/package*.json geaendert hat.
- Neu gestartet wird nur bei Aenderungen unter backend/ oder deploy/.
  Eine geaenderte Frontend-Datei wird ausgeliefert, ohne den laufenden
  Dienst anzufassen.

Danach ein Health-Check gegen /health; bleibt die Antwort aus, schlaegt der
Lauf fehl und die letzten Journal-Zeilen stehen im Protokoll.

Die Einrichtung (Deploy-Benutzer, eng gefasste sudo-Regel, Variablen und
Secrets) steht in DEPLOY.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 17:56:35 +02:00

548 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 35 |
| `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 per `rsync` nach `/opt/timer-app`.
**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
# Schreibrecht auf das Anwendungsverzeichnis
chown -R deploy:timerapp /opt/timer-app && chmod -R g+rX /opt/timer-app
# 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 *, \
/bin/chown -R timerapp\:timerapp /opt/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 | `DEPLOY_OWNER` | `timerapp:timerapp` |
| 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`.