Compare commits

...

11 commits

Author SHA1 Message Date
Jesko Anschütz
bb35594211 fix(backend): Screen-ID mit doppelten Quotes in User-Zuordnung
printf "%q" im Go-Template erzeugte Go-quoted Strings ("..."), die als
Teil der screen_id an die DB übergeben wurden. FK-Constraint schlug fehl,
weil die ID mit eingebetteten Quotes keiner screens-Zeile entsprach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 00:02:42 +01:00
Jesko Anschütz
c470ec532b fix(backend): Error-Logging in Screen-User-Handlern + Tenant-Lookup-Refactoring
Fehlende slog.Error-Aufrufe in HandleAddUserToScreen, HandleCreateScreenUser,
HandleDeleteScreenUser und HandleRemoveUserFromScreen ergänzt — DB-Fehler
wurden bisher komplett geschluckt und waren nicht diagnostizierbar.

Tenant-Lookup in EnsureAdminUser und CreateScreenUser aus SQL-Subqueries
in eigene Queries extrahiert für bessere Fehlermeldungen bei fehlendem Tenant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 23:41:16 +01:00
Jesko Anschütz
73c3d74098 fix(csrf): CSRF-Token in Login-Fehlerseite fehlt — macht Retry-Versuch unmöglich
HandleLoginPost renderte Fehlerseiten (falsches Passwort, leere Felder) ohne
CSRFToken in den Template-Daten. Das hidden field <csrf_token> war leer, sodass
jeder weitere Submit-Versuch mit "Ungültiger CSRF-Token" scheiterte.

Fix: setCSRFCookie am Anfang des Handlers aufrufen und das Token in allen
renderError-Pfaden an das Template übergeben.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:22:58 +01:00
Jesko Anschütz
8e0501a012 Doku: Screen-Usserverwaltung (Phase 8)
Aktualisiert Dokumentation für Screen-User Management nach Backy's Implementierung:

docs/SCHEMA.md:
- users.role erweitert: 'admin' | 'screen_user' | 'tenant'
- Neue Tabelle: user_screen_permissions (user_id, screen_id, created_at, unique constraint, FK mit CASCADE)
- AuthStore: CreateScreenUser, ListScreenUsers, DeleteUser
- ScreenStore: GetAccessibleScreens, HasUserScreenAccess, AddUserToScreen, RemoveUserFromScreen, GetScreenUsers

docs/API-ENDPOINTS.md:
- POST /admin/users — Screen-User anlegen
- POST /admin/users/{userID}/delete — Screen-User löschen
- POST /admin/screens/{screenID}/users — User zu Screen hinzufügen
- POST /admin/screens/{screenID}/users/{userID}/remove — User von Screen entfernen

server/backend/README.md:
- AuthStore und ScreenStore Methoden dokumentiert
- Middleware RequireScreenAccess erklärt
- Migration 003_user_screen_permissions.sql erwähnt

DEVELOPMENT.md:
- users.role Werte dokumentiert (admin, screen_user, tenant)

Co-Authored-By: Backy (Screen-User Implementation) <noreply@anthropic.com>
2026-03-23 22:07:32 +01:00
Jesko Anschütz
d1d86126c8 Feature: Screen-User-Verwaltung mit rollenbasiertem Zugriff
Neue Rolle screen_user: User können sich einloggen und nur ihre
zugeordneten Bildschirme verwalten. Admins behalten vollen Zugriff.

- Migration 003: users.role-Spalte + user_screen_permissions (M:N)
- Store: CreateScreenUser, ListScreenUsers, DeleteUser,
         GetAccessibleScreens, HasUserScreenAccess,
         AddUserToScreen, RemoveUserFromScreen, GetScreenUsers
- Middleware: RequireScreenAccess enforces screen-level access
  für alle /manage/{screenSlug}-Routen
- 4 neue Admin-Handler: CreateScreenUser, DeleteScreenUser,
  AddUserToScreen, RemoveUserFromScreen (+4 Routes)
- Admin-UI: Tab "Benutzer" (anlegen/löschen) + Screen-User-Modal
  (User zuordnen/entfernen) direkt in der Bildschirm-Tabelle
- Login: screen_user wird nach Login zum ersten zugänglichen Screen
  weitergeleitet; kein Zugang zu /admin

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-23 22:06:05 +01:00
Jesko Anschütz
1e90bbbbc0 fix(auth): redirect tenant users to /tenant/{slug}/dashboard after login
Admin users continue to redirect to /manage/ as before. Tenant users
now land on their own dashboard at /tenant/{slug}/dashboard instead of
the incorrect /manage/{slug} path. The fix applies to both the
already-logged-in check in HandleLoginUI and the post-login switch in
HandleLoginPost.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-23 21:32:12 +01:00
Jesko Anschütz
dd3ec070f7 Security-Review + Phase 6: CSRF, Rate-Limiting, Tenant-Isolation, Screenshot, Ansible
### Security-Fixes (K1–K6, W1–W4, W7, N1, N5–N6, V1, V5–V7)
- K1: CSRF-Schutz via Double-Submit-Cookie (httpapi/csrf.go + csrf_helpers.go)
- K2: requireScreenAccess() in allen manage-Handlern (Tenant-Isolation)
- K3: Tenant-Check bei DELETE /api/v1/media/{id}
- K4: requirePlaylistAccess() + GetByItemID() für JSON-API Playlist-Routen
- K5: Admin-Passwort nur noch als [gesetzt] geloggt
- K6: POST /api/v1/screens/register mit Pre-Shared-Secret (MORZ_INFOBOARD_REGISTER_SECRET)
- W1: Race Condition bei order_index behoben (atomare Subquery in AddItem)
- W2: Graceful Shutdown mit 15s Timeout auf SIGTERM/SIGINT
- W3: http.MaxBytesReader (512 MB) in allen Upload-Handlern
- W4: err.Error() nicht mehr an den Client
- W7: Template-Execution via bytes.Buffer (kein partial write bei Fehler)
- N1: Rate-Limiting auf /login (5 Versuche/Minute pro IP, httpapi/ratelimit.go)
- N5: Directory-Listing auf /uploads/ deaktiviert (neuteredFileSystem)
- N6: Uploads nach Tenant getrennt (uploads/{tenantSlug}/)
- V1: Upload-Logik konsolidiert in internal/fileutil/fileutil.go
- V5: Cookie-Name als Konstante reqcontext.SessionCookieName
- V6: Strukturiertes Logging mit log/slog + JSON-Handler
- V7: DB-Pool wird im Graceful-Shutdown geschlossen

### Phase 6: Screenshot-Erzeugung
- player/agent/internal/screenshot/screenshot.go erstellt
- Integration in app.go mit MORZ_INFOBOARD_SCREENSHOT_EVERY Config

### UX: PDF.js Integration
- pdf.min.js + pdf.worker.min.js als lokale Assets eingebettet
- Automatisches Seitendurchblättern im Player

### Ansible: Neue Rollen
- signage_base, signage_server, signage_provision erstellt
- inventory.yml und site.yml erweitert

### Konzept-Docs
- GRUPPEN-KONZEPT.md, KAMPAGNEN-AKTIVIERUNG.md, MONITORING-KONZEPT.md
- PROVISION-KONZEPT.md, TEMPLATE-EDITOR.md, WATCHDOG-KONZEPT.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:06:35 +01:00
Jesko Anschütz
029fa39ffd Dokumentation: Security-Features und Upload-Konsolidierung (Phase 6)
Neue Packages und Module:
- fileutil: Shared Upload-Logik mit Tenant-Isolation
- httpapi/csrf.go: Double-Submit-Cookie CSRF-Schutz
- httpapi/ratelimit.go: Rate-Limiting für /login
- httpapi/uploads.go: neuteredFileSystem (kein Directory-Listing)
- httpapi/manage/csrf_helpers.go: CSRF-Helpers für Templates
- player/agent/internal/screenshot/screenshot.go: Periodische Screenshot-Erfassung

Neue Umgebungsvariablen:
- MORZ_INFOBOARD_REGISTER_SECRET: Pre-Shared-Secret für Agent-Registrierung
- MORZ_INFOBOARD_SCREENSHOT_EVERY: Screenshot-Intervall im Player-Agent (Sekunden)

Dokumentation aktualisiert:
- server/backend/README.md: Neue Packages und Env-Variable REGISTER_SECRET
- DEVELOPMENT.md: Beide neuen Env-Variablen mit Erklärungen
- docs/API-ENDPOINTS.md: Screenshot-Endpoint als "In Vorbereitung" dokumentiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:01:47 +01:00
Jesko Anschütz
931652a550 CLAUDE.md: Projektregeln für Doku-Pflege und Team-Workflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:24:26 +01:00
Jesko Anschütz
4268da7988 Doku-Sync: Auth, Tenant-Dashboard, Middleware, Schema nachgezogen
- SCHEMA.md: users-Tabelle korrigiert, sessions-Tabelle ergänzt
- API-ENDPOINTS.md: Auth-Routen + Tenant-Dashboard-Routen ergänzt
- SERVER-KONZEPT.md: Abschnitte Authentifizierung, Middleware-Kette, Tenant-Dashboard neu
- backend/README.md: komplett neu auf Basis aktueller Implementierung
- DEVELOPMENT.md: veraltete "nicht vorhanden"-Punkte bereinigt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:07:12 +01:00
Jesko Anschütz
0e66bfdb24 Tenant-Feature Phase 6: Session-Cleanup, Docker-Env, Security-Fixes, Doku
Session-Cleanup:
- app.go: stündlicher Ticker für CleanExpiredSessions mit Context-Shutdown

Docker/Infra:
- compose/.env.example: Vorlage für ADMIN_PASSWORD, DEV_MODE, DEFAULT_TENANT
- server-stack.yml: Backend-Service referenziert neue Env-Variablen

Security-Review (Larry):
- EnsureAdminUser: Admin-Check tenant-scoped statt global
- scanUser() (toter Code, falsche Spaltenanzahl) entfernt
- RequireTenantAccess: leerer tenantSlug nicht mehr als Bypass nutzbar
- Login: Dummy-bcrypt bei unbekanntem User gegen Timing-Leak
- Logout-Cookie: Secure-Flag konsistent mit Login gesetzt

Doku (Doris):
- DEVELOPMENT.md: Abschnitt "Lokale Entwicklung mit Login"
- TENANT-FEATURE-PLAN.md: Phase 3-5 Checkboxen abgehakt
- TODO.md: erledigte Punkte abgehakt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:39:39 +01:00
57 changed files with 5958 additions and 273 deletions

18
CLAUDE.md Normal file
View file

@ -0,0 +1,18 @@
# Projektregeln für Claude Code
## Team-Workflow
Aufgaben werden immer an das Subagenten-Team delegiert, nie direkt erledigt.
Rollen und Modelle: siehe Memory (`project_team_setup.md`).
## Dokumentation
Nach jeder Implementierung muss die betroffene Dokumentation **im gleichen Commit** aktualisiert werden:
- `docs/SCHEMA.md` — bei Datenbankänderungen
- `docs/API-ENDPOINTS.md` — bei neuen oder geänderten Routen
- `docs/SERVER-KONZEPT.md` — bei Architektur- oder Konzeptänderungen
- `server/backend/README.md` — bei neuen Packages, Endpunkten oder Konfigurationsvariablen
- `DEVELOPMENT.md` — bei neuen Env-Variablen oder Entwicklungsvoraussetzungen
Doris ist verantwortlich für die Doku-Pflege. Sie wird bei jeder Phase automatisch eingesetzt.

View file

@ -40,9 +40,9 @@ Bereits vorhanden:
Noch nicht vorhanden: Noch nicht vorhanden:
- admin-seitige Benutzerautentifizierung und Zugriffskontrolle
- Multi-Tenancy-Isolation auf API-Ebene
- produktives SSL/TLS-Handling fuer Deployment - produktives SSL/TLS-Handling fuer Deployment
- Docker-Secret-Integration fuer `MORZ_INFOBOARD_ADMIN_PASSWORD`
- Ansible-Variable `morz_admin_password` als Vault-Variable (Phase 6)
## Voraussetzungen auf dem Entwicklungsrechner ## Voraussetzungen auf dem Entwicklungsrechner
@ -118,6 +118,28 @@ Hinweis:
- auf dem aktuellen System dieser Session sind `make` und `go` nicht installiert; die Befehle sind fuer den Entwicklungsrechner vorbereitet - auf dem aktuellen System dieser Session sind `make` und `go` nicht installiert; die Befehle sind fuer den Entwicklungsrechner vorbereitet
## Lokale Entwicklung mit Login
Seit der Implementierung des Tenant-Features ist das Backend durch eine Session-basierte Authentifizierung geschuetzt. Fuer den lokalen Entwicklungsbetrieb muessen zwei zusaetzliche Umgebungsvariablen gesetzt werden:
- `MORZ_INFOBOARD_ADMIN_PASSWORD` legt das Passwort des initialen Admin-Users fest. Beim Backend-Start wird automatisch ein User `admin` angelegt (bzw. dessen Passwort aktualisiert), der dem Standard-Tenant `morz` zugeordnet ist. Bleibt die Variable leer, wird kein Admin angelegt und der Login-Bereich ist nicht nutzbar.
- `MORZ_INFOBOARD_DEV_MODE` setzt das Session-Cookie ohne das `Secure`-Flag, sodass er auch ueber unverschluesseltes HTTP (lokales `localhost`) uebertragen wird. Ohne dieses Flag wird der Cookie nur ueber HTTPS gesetzt und der Login schlaegt im lokalen Betrieb still fehl.
Empfohlener Start fuer die lokale Entwicklung:
```bash
cd server/backend
MORZ_INFOBOARD_ADMIN_PASSWORD=dev \
MORZ_INFOBOARD_DEV_MODE=true \
go run ./cmd/api
```
Danach ist der Login unter `http://localhost:8080/login` mit `admin` / `dev` erreichbar.
Hinweis: `MORZ_INFOBOARD_DEV_MODE=true` darf niemals in einer produktiven Umgebung gesetzt werden, da der Cookie dort ausschliesslich ueber HTTPS uebertragen werden soll.
---
## Lokaler Start ## Lokaler Start
### Backend lokal starten ### Backend lokal starten
@ -137,6 +159,15 @@ Konfigurierbar ueber:
- `MORZ_INFOBOARD_HTTP_ADDR` HTTP-Adresse (Standard: `:8080`) - `MORZ_INFOBOARD_HTTP_ADDR` HTTP-Adresse (Standard: `:8080`)
- `MORZ_INFOBOARD_STATUS_STORE_PATH` Pfad zur JSON-Datei fuer persistenten Status-Store; leer lassen fuer reinen In-Memory-Betrieb - `MORZ_INFOBOARD_STATUS_STORE_PATH` Pfad zur JSON-Datei fuer persistenten Status-Store; leer lassen fuer reinen In-Memory-Betrieb
- `MORZ_INFOBOARD_ADMIN_PASSWORD` Passwort fuer den initialen Admin-User (leer = kein EnsureAdminUser-Lauf)
- `MORZ_INFOBOARD_DEFAULT_TENANT` Slug des Standard-Tenants, dem der Admin-User zugeordnet wird (Standard: `morz`)
- `MORZ_INFOBOARD_REGISTER_SECRET` Pre-Shared-Secret fuer POST /api/v1/screens/register; leer = offen fuer alle
- `MORZ_INFOBOARD_DEV_MODE` wenn `true`: Session-Cookie wird ohne `Secure`-Flag gesetzt (nur fuer lokale Entwicklung)
**Hinweis zu `users.role`:**
- `admin` — hat Zugriff auf alle Admin-Funktionen und Screens
- `screen_user` — hat Zugriff nur auf Screens, fuer die explizit ein Eintrag in `user_screen_permissions` existiert
- `tenant` — hat Zugriff auf alle Screens seines Tenants (veraltet, noch nicht vollstaendig implementiert)
Beispiele: Beispiele:
@ -169,6 +200,8 @@ Optional:
- `MORZ_INFOBOARD_MQTT_USERNAME` MQTT-Benutzername - `MORZ_INFOBOARD_MQTT_USERNAME` MQTT-Benutzername
- `MORZ_INFOBOARD_MQTT_PASSWORD` MQTT-Passwort - `MORZ_INFOBOARD_MQTT_PASSWORD` MQTT-Passwort
- `MORZ_INFOBOARD_REGISTER_SECRET` Pre-Shared-Secret fuer Selbstregistrierung; muss mit Server-Konfiguration uebereinstimmen
- `MORZ_INFOBOARD_SCREENSHOT_EVERY` Intervall fuer periodische Screenshots in Sekunden (z.B. `300` fuer 5 Minuten; 0 oder leer = deaktiviert)
- `MORZ_INFOBOARD_CONFIG=/etc/signage/config.json` dateibasierte Konfiguration - `MORZ_INFOBOARD_CONFIG=/etc/signage/config.json` dateibasierte Konfiguration
Eine Beispielkonfiguration liegt in `player/config/config.example.json`. Eine Beispielkonfiguration liegt in `player/config/config.example.json`.
@ -262,11 +295,15 @@ Das Playbook erledigt:
## Empfohlene naechste Implementierungsschritte ## Empfohlene naechste Implementierungsschritte
1. Backend: einheitliches Fehlerformat und Routing-Grundstruktur anlegen Die Punkte 14 der urspruenglichen Liste (Fehlerformat, Routing, Status, MQTT) sind umgesetzt.
2. Backend: Konfigurations- und App-Lifecycle stabilisieren Offene Punkte aus Phase 6 des Tenant-Feature-Plans (`docs/TENANT-FEATURE-PLAN.md`):
3. Agent und Backend: den HTTP-Statuspfad als Grundlage fuer Identitaet, Persistenz und spaetere Admin-Vorschau erweitern
4. Agent: danach MQTT-spezifische Reachability und feinere Connectivity-Schwellenlogik aufsetzen 1. Docker-Secret fuer `MORZ_INFOBOARD_ADMIN_PASSWORD` in `compose/` einrichten
5. Danach die Netzwerk-, Sync- und Kommandopfade schrittweise produktionsnah ausbauen 2. Ansible-Variable `morz_admin_password` als Vault-Variable definieren
3. Code-Review durch Larry (SQL-Injection, Session-Fixation, bcrypt-Cost, Middleware-Reihenfolge)
4. End-to-End-Test-Checkliste in `docs/TEST-CHECKLIST-DEV.md` durchlaufen
5. Deployment: Image bauen, Migration `002_auth.sql` pruefen, Logs kontrollieren
6. Langfristig: Netzwerk-, Sync- und Kommandopfade produktionsnah ausbauen
## End-to-End-Entwicklungstest (Backend + Agent) ## End-to-End-Entwicklungstest (Backend + Agent)

64
TODO.md
View file

@ -47,7 +47,7 @@
- [x] Verzeichnislayout auf dem Player festlegen - [x] Verzeichnislayout auf dem Player festlegen
- [x] `player-agent` fachlich zuschneiden - [x] `player-agent` fachlich zuschneiden
- [x] `player-ui` fachlich zuschneiden (lokale Kiosk-Seite mit Splash + Sysinfo-Overlay) - [x] `player-ui` fachlich zuschneiden (lokale Kiosk-Seite mit Splash + Sysinfo-Overlay)
- [ ] Watchdog-Konzept fuer Browser und Agent definieren - [x] Watchdog-Konzept fuer Browser und Agent definieren
- [x] Offline-Overlay-Verhalten spezifizieren - [x] Offline-Overlay-Verhalten spezifizieren
- [x] Fehlerbehandlung fuer Web-Inhalte und Timeouts ausarbeiten - [x] Fehlerbehandlung fuer Web-Inhalte und Timeouts ausarbeiten
- [x] Display-Steuerung fuer An/Aus, Rotation und Neustart planen - [x] Display-Steuerung fuer An/Aus, Rotation und Neustart planen
@ -57,17 +57,17 @@
- [x] API-Backend fachlich schneiden - [x] API-Backend fachlich schneiden
- [x] Admin-Oberflaeche in Hauptbereiche aufteilen - [x] Admin-Oberflaeche in Hauptbereiche aufteilen
- [ ] Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen - [x] Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen
- [ ] Firmen-/Tenant-Oberfläche → siehe docs/TENANT-FEATURE-PLAN.md - [x] Firmen-/Tenant-Oberfläche → siehe docs/TENANT-FEATURE-PLAN.md
- [x] Storage-Konzept fuer Uploads, Cache-Dateien und Screenshots festlegen - [x] Storage-Konzept fuer Uploads, Cache-Dateien und Screenshots festlegen
- [x] Authentifizierungskonzept festlegen - [x] Authentifizierungskonzept festlegen
- [x] Mandantentrennung im Datenmodell und in den APIs absichern - [x] Mandantentrennung im Datenmodell und in den APIs absichern
- [ ] Logging- und Monitoring-Konzept definieren - [x] Logging- und Monitoring-Konzept definieren
- [ ] Template-Editor fuer globale Kampagnen fachlich schneiden - [x] Template-Editor fuer globale Kampagnen fachlich schneiden
- [ ] Aktivierungsoberflaeche fuer saisonale oder temporäre Kampagnen planen - [x] Aktivierungsoberflaeche fuer saisonale oder temporäre Kampagnen planen
- [ ] Gruppierung oder Slot-Modell fuer monitoruebergreifende Layouts planen - [x] Gruppierung oder Slot-Modell fuer monitoruebergreifende Layouts planen
- [x] Provisionierungs-UI fuer neue Screens fachlich und technisch schneiden - [x] Provisionierungs-UI fuer neue Screens fachlich und technisch schneiden
- [ ] Jobrunner-Konzept fuer Ansible-gestuetzte Erstinstallation planen - [x] Jobrunner-Konzept fuer Ansible-gestuetzte Erstinstallation planen
## Phase 5 - Prototyping ## Phase 5 - Prototyping
@ -89,18 +89,18 @@
- [x] Docker-Compose-Setup fuer den Server anlegen - [x] Docker-Compose-Setup fuer den Server anlegen
- [x] systemd-Units fuer den Player erstellen - [x] systemd-Units fuer den Player erstellen
- [x] Chromium-Kiosk-Startskript erstellen - [x] Chromium-Kiosk-Startskript erstellen
- [ ] Screenshot-Erzeugung auf dem Player integrieren - [x] Screenshot-Erzeugung auf dem Player integrieren
- [x] Heartbeat- und Statusmeldungen integrieren - [x] Heartbeat- und Statusmeldungen integrieren
- [x] MQTT-Playlist-Change-Synchronisation mit Backend-Debounce (2s) und Agent-Debounce (3s) implementiert - [x] MQTT-Playlist-Change-Synchronisation mit Backend-Debounce (2s) und Agent-Debounce (3s) implementiert
- [ ] Fehler- und Wiederanlaufverhalten verifizieren - [ ] Fehler- und Wiederanlaufverhalten verifizieren
## Phase 7 - Ansible-Automatisierung ## Phase 7 - Ansible-Automatisierung
- [ ] Rolle `signage_base` erstellen - [x] Rolle `signage_base` erstellen
- [x] Rolle `signage_player` erstellen - [x] Rolle `signage_player` erstellen
- [x] Rolle `signage_display` erstellen - [x] Rolle `signage_display` erstellen
- [ ] Rolle `signage_server` erstellen - [x] Rolle `signage_server` erstellen
- [ ] Rolle `signage_provision` erstellen - [x] Rolle `signage_provision` erstellen
- [x] Inventar-/Variablenmodell fuer mehrere Monitore entwerfen - [x] Inventar-/Variablenmodell fuer mehrere Monitore entwerfen
- [x] Screen-spezifische Variablen wie `screen_id`, Rotation und Aufloesung abbilden - [x] Screen-spezifische Variablen wie `screen_id`, Rotation und Aufloesung abbilden
- [x] Erstinstallation eines neuen Players automatisieren - [x] Erstinstallation eines neuen Players automatisieren
@ -145,7 +145,7 @@
- [x] Screen-Online/Offline-Status in Admin-Tabelle anzeigen (aus /status-Endpoint befuellen) - [x] Screen-Online/Offline-Status in Admin-Tabelle anzeigen (aus /status-Endpoint befuellen)
- [x] Playlist-Tabelle in overflow-x Wrapper einwickeln (Responsive auf kleinen Screens) - [x] Playlist-Tabelle in overflow-x Wrapper einwickeln (Responsive auf kleinen Screens)
- [x] PDF-Darstellung: Sidebar und Toolbar im Chromium PDF-Viewer ausblenden (URL-Parameter navpanes=0, toolbar=0) - [x] PDF-Darstellung: Sidebar und Toolbar im Chromium PDF-Viewer ausblenden (URL-Parameter navpanes=0, toolbar=0)
- [ ] PDF-Darstellung: PDF.js fuer automatisches Seitendurchblaettern integrieren - [x] PDF-Darstellung: PDF.js fuer automatisches Seitendurchblaettern integrieren
### Mittlere Prioritaet ### Mittlere Prioritaet
@ -171,6 +171,44 @@
- [x] Fix: /api/startup-token setzt Cache-Control: no-store Header (Server + Client) - [x] Fix: /api/startup-token setzt Cache-Control: no-store Header (Server + Client)
- [x] Fix: TestAssetsServed Nil-Dereferenz durch tote Goroutine behoben - [x] Fix: TestAssetsServed Nil-Dereferenz durch tote Goroutine behoben
## Security & Code-Review (Opus, 2026-03-23)
### Kritisch — Sicherheitslücken
- [x] **K2** Tenant-Isolation für `/manage/{screenSlug}/*`: `requireScreenAccess()` in allen manage-Handlern
- [x] **K3** `DELETE /api/v1/media/{id}`: Tenant-Check via reqcontext.UserFromContext
- [x] **K4** JSON-API Playlist-Routen (`/items`, `/playlists/*/items`, `/order`, `/duration`): `requirePlaylistAccess()` + `GetByItemID()` im Store
- [x] **K1** CSRF-Schutz: Double-Submit-Cookie-Pattern (`httpapi/csrf.go`); JS-Injection in alle Templates; Middleware in Router
- [x] **K6** `POST /api/v1/screens/register`: Pre-Shared-Secret via `MORZ_INFOBOARD_REGISTER_SECRET` (Header `X-Register-Secret`); Player-Agent sendet Secret mit
- [x] **K5** Admin-Passwort aus Log entfernt — nur `[gesetzt]` wird geloggt
### Wichtig — Robustheit
- [x] **N5** Directory-Listing auf `/uploads/` deaktiviert via `neuteredFileSystem` (`httpapi/uploads.go`)
- [x] **N6** Uploads nach Tenant getrennt: `fileutil.SaveUploadedFile()` legt Dateien in `uploads/{tenantSlug}/` ab
- [x] **W1** Race Condition bei `order_index` behoben: atomare Subquery in `AddItem()`
- [x] **W2** Graceful Shutdown implementiert: `http.Server.Shutdown()` mit 15s Timeout auf SIGTERM/SIGINT
- [x] **W3** Upload mit `http.MaxBytesReader` begrenzt (512 MB) in allen drei Upload-Handlern
- [x] **W4** `err.Error()` nicht mehr an den Client — generische Fehlermeldungen, Details serverseitig
- [x] **W7** Template-Execution-Errors: `bytes.Buffer`-Rendering, erst bei Erfolg an Client senden (`renderTemplate()`)
### Verbesserung — Wartbarkeit
- [ ] **V3** Keine Tests für Auth, Middleware, Tenant-Handler (gesamter Phase-1-5-Code ohne Abdeckung)
- [x] **V1** Upload-Logik konsolidiert in `internal/fileutil/fileutil.go` (`SaveUploadedFile`)
- [x] **V5** Cookie-Name als Konstante `reqcontext.SessionCookieName` — manage/auth.go und middleware.go nutzen sie
- [x] **V6** Strukturiertes Logging: `log/slog` mit JSON-Handler in `main.go`; `app.go` nutzt `slog.Info/slog.Error`
- [x] **V7** DB-Pool wird im Graceful-Shutdown-Handler geschlossen (`a.dbPool.Close()`)
### Nice-to-have — Features
- [x] **N1** Rate-Limiting auf `/login`: In-Memory Sliding-Window (5 Versuche/Minute pro IP) via `httpapi/ratelimit.go`
- [ ] **N2** Passwort-Änderung / Self-Service-Reset
- [ ] **N3** Tenant-User-Management im Admin-UI
- [ ] **N4** Session-TTL via Config-Variable steuerbar (aktuell hardcoded 8h)
**Hinweis K6:** `MORZ_INFOBOARD_REGISTER_SECRET` muss in `server/.env` / `docker-compose.yml` und in der Player-Config (`MORZ_INFOBOARD_REGISTER_SECRET` oder `register_secret` in `config.json`) identisch gesetzt werden. Wenn die Variable leer ist, bleibt der Endpoint offen (Rückwärtskompatibilität).
## Querschnittsthemen ## Querschnittsthemen
- [ ] Datensicherung fuer Datenbank und Medien einplanen - [ ] Datensicherung fuer Datenbank und Medien einplanen

View file

@ -5,3 +5,8 @@ all:
hosts: hosts:
info10: info10:
info01-dev: info01-dev:
signage_servers:
hosts:
dockerbox:
# ansible_host: 10.0.0.70
# ansible_user: admin

View file

@ -0,0 +1,12 @@
---
signage_user: morz
signage_timezone: "Europe/Berlin"
signage_base_packages:
- curl
- ca-certificates
- rsync
- htop
- vim-tiny
- bash-completion
- ntp

View file

@ -0,0 +1,12 @@
---
- name: Restart cron
ansible.builtin.systemd:
name: cron
state: restarted
become: true
- name: Restart journald
ansible.builtin.systemd:
name: systemd-journald
state: restarted
become: true

View file

@ -0,0 +1,55 @@
---
- name: Update apt cache and upgrade installed packages
ansible.builtin.apt:
update_cache: true
upgrade: dist
cache_valid_time: 3600
become: true
- name: Install base packages
ansible.builtin.apt:
name: "{{ signage_base_packages }}"
state: present
become: true
- name: Set system timezone
community.general.timezone:
name: "{{ signage_timezone }}"
become: true
notify: Restart cron
- name: Ensure NTP service is enabled and running
ansible.builtin.systemd:
name: ntp
enabled: true
state: started
become: true
- name: Ensure journald drop-in directory exists
ansible.builtin.file:
path: /etc/systemd/journald.conf.d
state: directory
owner: root
group: root
mode: "0755"
become: true
- name: Configure journald volatile storage (RAM only, schont SD-Karte)
ansible.builtin.copy:
dest: /etc/systemd/journald.conf.d/morz-volatile.conf
content: |
[Journal]
Storage=volatile
RuntimeMaxUse=20M
owner: root
group: root
mode: "0644"
become: true
notify: Restart journald
- name: Ensure signage user exists
ansible.builtin.user:
name: "{{ signage_user }}"
create_home: true
state: present
become: true

View file

@ -0,0 +1,16 @@
---
# Admin token used to authenticate against the server API
# Must be overridden in group_vars, host_vars or vault.
signage_admin_token: ""
# Server base URL reachable from the Ansible controller
signage_server_base_url: "http://10.0.0.70:8080"
# SSH public key to deploy to the signage user
signage_ssh_public_key: ""
# User that Ansible should permanently manage (after bootstrapping)
signage_user: morz
# Config dir on the target (shared with signage_player role)
signage_config_dir: /etc/signage

View file

@ -0,0 +1,3 @@
---
# No handlers required for provisioning role.
# Handlers are intentionally empty provisioning tasks are one-shot.

View file

@ -0,0 +1,57 @@
---
- name: Ensure signage user exists
ansible.builtin.user:
name: "{{ signage_user }}"
create_home: true
state: present
become: true
- name: Ensure .ssh directory exists for signage user
ansible.builtin.file:
path: "/home/{{ signage_user }}/.ssh"
state: directory
owner: "{{ signage_user }}"
group: "{{ signage_user }}"
mode: "0700"
become: true
- name: Deploy SSH public key for signage user
ansible.builtin.authorized_key:
user: "{{ signage_user }}"
key: "{{ signage_ssh_public_key }}"
state: present
become: true
when: signage_ssh_public_key | length > 0
- name: Ensure config directory exists
ansible.builtin.file:
path: "{{ signage_config_dir }}"
state: directory
owner: root
group: root
mode: "0755"
become: true
- name: Deploy vars.yml template for player config
ansible.builtin.template:
src: vars.yml.j2
dest: "{{ signage_config_dir }}/vars.yml"
owner: root
group: "{{ signage_user }}"
mode: "0640"
become: true
- name: Register screen at server via API
ansible.builtin.uri:
url: "{{ signage_server_base_url }}/api/v1/screens/register"
method: POST
body_format: json
body:
slug: "{{ screen_id }}"
name: "{{ screen_name | default(screen_id) }}"
orientation: "{{ screen_orientation | default('landscape') }}"
headers:
Content-Type: application/json
status_code: [200, 201]
delegate_to: localhost
when: screen_id is defined

View file

@ -0,0 +1,16 @@
# Managed by Ansible signage_provision role
# Do not edit manually on the device.
screen_id: "{{ screen_id }}"
screen_name: "{{ screen_name | default(screen_id) }}"
screen_orientation: "{{ screen_orientation | default('landscape') }}"
morz_server_base_url: "{{ morz_server_base_url | default(signage_server_base_url) }}"
morz_mqtt_broker: "{{ morz_mqtt_broker | default('') }}"
morz_mqtt_username: "{{ morz_mqtt_username | default('') }}"
morz_mqtt_password: "{{ morz_mqtt_password | default('') }}"
morz_heartbeat_every_seconds: {{ morz_heartbeat_every_seconds | default(30) }}
morz_status_report_every_seconds: {{ morz_status_report_every_seconds | default(60) }}
morz_player_listen_addr: "{{ morz_player_listen_addr | default('127.0.0.1:8090') }}"
morz_player_content_url: "{{ morz_player_content_url | default('') }}"

View file

@ -0,0 +1,26 @@
---
signage_server_deploy_dir: /srv/docker/info-board-neu
signage_server_data_dir: /srv/docker/info-board-neu/data
# Backend
morz_http_addr: ":8080"
morz_database_url: "postgres://morz_infoboard:morz_infoboard@db:5432/morz_infoboard?sslmode=disable"
morz_upload_dir: /app/uploads
morz_status_store_path: /app/data/status
morz_default_tenant: morz
morz_dev_mode: "false"
# Admin password must be overridden in group_vars or vault
morz_admin_password: ""
# MQTT
morz_mqtt_broker: ""
morz_mqtt_username: ""
morz_mqtt_password: ""
# Firewall
signage_server_ufw_enabled: true
signage_server_ufw_allow_https: true
signage_server_ufw_allow_mqtt: true
signage_server_mqtt_port: "1883"
signage_server_https_port: "443"

View file

@ -0,0 +1,7 @@
---
- name: Restart morz-server stack
community.docker.docker_compose_v2:
project_src: "{{ signage_server_deploy_dir }}"
state: present
pull: always
become: true

View file

@ -0,0 +1,130 @@
---
- name: Install Docker dependencies
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
state: present
update_cache: true
become: true
- name: Create Docker apt keyring directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
owner: root
group: root
mode: "0755"
become: true
- name: Add Docker GPG key
ansible.builtin.get_url:
url: https://download.docker.com/linux/debian/gpg
dest: /etc/apt/keyrings/docker.asc
owner: root
group: root
mode: "0644"
become: true
- name: Add Docker apt repository
ansible.builtin.apt_repository:
repo: >-
deb [arch={{ ansible_architecture | replace('x86_64', 'amd64') | replace('aarch64', 'arm64') }}
signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/debian
{{ ansible_distribution_release }} stable
state: present
filename: docker
become: true
- name: Install Docker Engine and Compose plugin
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
update_cache: true
become: true
- name: Ensure Docker service is enabled and running
ansible.builtin.systemd:
name: docker
enabled: true
state: started
become: true
- name: Create server deploy directory
ansible.builtin.file:
path: "{{ signage_server_deploy_dir }}"
state: directory
owner: root
group: root
mode: "0750"
become: true
- name: Create server data directory
ansible.builtin.file:
path: "{{ signage_server_data_dir }}"
state: directory
owner: root
group: root
mode: "0750"
become: true
- name: Create uploads directory
ansible.builtin.file:
path: "{{ signage_server_deploy_dir }}/uploads"
state: directory
owner: root
group: root
mode: "0750"
become: true
- name: Deploy docker-compose.yml
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ signage_server_deploy_dir }}/docker-compose.yml"
owner: root
group: root
mode: "0640"
become: true
notify: Restart morz-server stack
- name: Deploy server environment file
ansible.builtin.template:
src: env.j2
dest: "{{ signage_server_deploy_dir }}/.env"
owner: root
group: root
mode: "0600"
become: true
notify: Restart morz-server stack
- name: Allow HTTPS through ufw
community.general.ufw:
rule: allow
port: "{{ signage_server_https_port }}"
proto: tcp
comment: morz-infoboard HTTPS
become: true
when: signage_server_ufw_enabled and signage_server_ufw_allow_https
- name: Allow MQTT through ufw
community.general.ufw:
rule: allow
port: "{{ signage_server_mqtt_port }}"
proto: tcp
comment: morz-infoboard MQTT
become: true
when: signage_server_ufw_enabled and signage_server_ufw_allow_mqtt
- name: Pull and start morz-server stack
community.docker.docker_compose_v2:
project_src: "{{ signage_server_deploy_dir }}"
state: present
pull: always
become: true

View file

@ -0,0 +1,58 @@
---
# Managed by Ansible signage_server role
# Do not edit manually on the server.
services:
backend:
image: git.az-it.net/az/morz-infoboard/backend:latest
restart: unless-stopped
ports:
- "8080:8080"
environment:
MORZ_INFOBOARD_HTTP_ADDR: "${MORZ_HTTP_ADDR}"
MORZ_INFOBOARD_DATABASE_URL: "${MORZ_DATABASE_URL}"
MORZ_INFOBOARD_UPLOAD_DIR: /app/uploads
MORZ_INFOBOARD_STATUS_STORE_PATH: /app/data/status
MORZ_INFOBOARD_MQTT_BROKER: "${MORZ_MQTT_BROKER}"
MORZ_INFOBOARD_MQTT_USERNAME: "${MORZ_MQTT_USERNAME}"
MORZ_INFOBOARD_MQTT_PASSWORD: "${MORZ_MQTT_PASSWORD}"
MORZ_INFOBOARD_ADMIN_PASSWORD: "${MORZ_ADMIN_PASSWORD}"
MORZ_INFOBOARD_DEFAULT_TENANT: "${MORZ_DEFAULT_TENANT}"
MORZ_INFOBOARD_DEV_MODE: "${MORZ_DEV_MODE}"
volumes:
- ./uploads:/app/uploads
- ./data:/app/data
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: morz_infoboard
POSTGRES_PASSWORD: "${MORZ_DB_PASSWORD}"
POSTGRES_DB: morz_infoboard
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U morz_infoboard"]
interval: 10s
timeout: 5s
retries: 5
mqtt:
image: eclipse-mosquitto:2
restart: unless-stopped
ports:
- "1883:1883"
- "9001:9001"
volumes:
- ./mosquitto/config:/mosquitto/config:ro
- mosquitto_data:/mosquitto/data
- mosquitto_log:/mosquitto/log
volumes:
db_data:
mosquitto_data:
mosquitto_log:

View file

@ -0,0 +1,16 @@
# Managed by Ansible signage_server role
# Do not edit manually on the server.
MORZ_HTTP_ADDR={{ morz_http_addr }}
MORZ_DATABASE_URL={{ morz_database_url }}
MORZ_DB_PASSWORD={{ morz_db_password | default('morz_infoboard') }}
MORZ_UPLOAD_DIR={{ morz_upload_dir }}
MORZ_STATUS_STORE_PATH={{ morz_status_store_path }}
MORZ_DEFAULT_TENANT={{ morz_default_tenant }}
MORZ_DEV_MODE={{ morz_dev_mode }}
MORZ_ADMIN_PASSWORD={{ morz_admin_password }}
MORZ_MQTT_BROKER={{ morz_mqtt_broker }}
MORZ_MQTT_USERNAME={{ morz_mqtt_username }}
MORZ_MQTT_PASSWORD={{ morz_mqtt_password }}

View file

@ -1,7 +1,33 @@
--- ---
# Provision a fresh player (run once per new screen)
- name: Provision new Signage Player
hosts: signage_players
gather_facts: false
tags: [provision]
roles:
- signage_provision
# Base system setup for all signage nodes
- name: Base setup for Signage Players
hosts: signage_players
gather_facts: true
tags: [base, player]
roles:
- signage_base
# Deploy Morz Infoboard Player Agent and Kiosk Display
- name: Deploy Morz Infoboard Player Agent - name: Deploy Morz Infoboard Player Agent
hosts: signage_players hosts: signage_players
gather_facts: false gather_facts: false
tags: [player]
roles: roles:
- signage_player - signage_player
- signage_display - signage_display
# Deploy Morz Infoboard Central Server
- name: Deploy Morz Infoboard Central Server
hosts: signage_servers
gather_facts: true
tags: [server]
roles:
- signage_server

View file

@ -33,6 +33,9 @@ services:
MORZ_INFOBOARD_DATABASE_URL: "postgres://morz_infoboard:morz_infoboard@postgres:5432/morz_infoboard?sslmode=disable" MORZ_INFOBOARD_DATABASE_URL: "postgres://morz_infoboard:morz_infoboard@postgres:5432/morz_infoboard?sslmode=disable"
MORZ_INFOBOARD_UPLOAD_DIR: "/uploads" MORZ_INFOBOARD_UPLOAD_DIR: "/uploads"
MORZ_INFOBOARD_MQTT_BROKER: "tcp://mosquitto:1883" MORZ_INFOBOARD_MQTT_BROKER: "tcp://mosquitto:1883"
MORZ_INFOBOARD_ADMIN_PASSWORD: "${MORZ_INFOBOARD_ADMIN_PASSWORD}"
MORZ_INFOBOARD_DEV_MODE: "${MORZ_INFOBOARD_DEV_MODE:-false}"
MORZ_INFOBOARD_DEFAULT_TENANT: "${MORZ_INFOBOARD_DEFAULT_TENANT:-morz}"
volumes: volumes:
- uploads:/uploads - uploads:/uploads
depends_on: depends_on:

View file

@ -507,6 +507,122 @@ Spezialendpoint zur Auflösung von Nachrichten-Wand-Anfragen (noch in Entwicklun
--- ---
## Authentifizierung (Web-Formulare)
Alle Auth-Routen erfordern keine vorherige Authentifizierung.
### GET /login
Zeigt das Login-Formular.
- Wenn ein gueltiges `morz_session`-Cookie vorhanden ist, wird direkt zum jeweiligen Dashboard
weitergeleitet (`/admin` fuer Admins, `/tenant/{slug}/dashboard` fuer Tenant-User).
**Response:** HTML-Seite mit Benutzername/Passwort-Formular und optionaler Flash-Message.
---
### POST /login
Verarbeitet die Login-Eingabe.
**Request (Form-Encoded):**
```
username=admin&password=geheim
```
**Verhalten:**
- Passwort wird per `bcrypt.CompareHashAndPassword` geprueft
- Bei Erfolg wird ein `morz_session`-Cookie gesetzt (HttpOnly, Secure, 24h TTL)
- Weiterleitung je nach Rolle: `admin``/admin`, `tenant``/tenant/{slug}/dashboard`
- Bei Fehler: Rueckkehr zur Login-Seite mit Flash-Message
**Status:**
- `303 See Other` — Erfolg, Weiterleitung
- `303 See Other` — Fehler, Rueckkehr zur Login-Seite mit `?msg=`
---
### POST /logout
Meldet den aktuellen Benutzer ab.
**Verhalten:**
- Session wird in der DB geloescht (`DeleteSession`)
- Cookie wird mit `MaxAge=-1` geloescht
- Weiterleitung zu `/login`
**Status:**
- `303 See Other`
---
## Tenant Self-Service Dashboard (Web-Formulare)
Alle Tenant-Routen erfordern `RequireAuth` + `RequireTenantAccess`.
Admins koennen auf jeden Tenant zugreifen; Tenant-User nur auf ihren eigenen.
### GET /tenant/{tenantSlug}/dashboard
Zeigt das Tenant-Self-Service-Dashboard.
**Tabs:**
- Tab A "Meine Monitore" — Screen-Karten mit Live-Status (via JS-Fetch aus `/api/v1/screens/status`)
- Tab B "Mediathek" — Upload-Formular und Dateiliste
**Query-Parameter:**
- `tab=media` — oeffnet direkt Tab B (z. B. nach Upload-Redirect)
- `flash=uploaded` / `flash=deleted` — zeigt Erfolgs-Flash-Message
**Response:** HTML-Seite.
---
### POST /tenant/{tenantSlug}/upload
Laedt ein Medium fuer den Tenant hoch.
**Request (Multipart Form):**
```
type: image (oder video, pdf)
title: Mein Bild
file: <binary data>
```
oder fuer eine Web-URL:
```
type: web
title: Externe Website
url: http://example.com
```
**Verhalten:**
- Datei wird in `MORZ_INFOBOARD_UPLOAD_DIR` gespeichert
- MIME-Typ wird aus dem Upload-Header abgeleitet
- Max. Upload-Groesse: 512 MB
**Status:**
- `303 See Other``/tenant/{slug}/dashboard?tab=media&flash=uploaded`
- `400 Bad Request` — fehlender Typ oder Datei
- `404 Not Found` — Tenant nicht vorhanden
---
### POST /tenant/{tenantSlug}/media/{mediaId}/delete
Loescht ein Medien-Asset des Tenants.
**Verhalten:**
- Eigentuemer-Pruefung: `asset.TenantID` muss mit dem Tenant uebereinstimmen
- Physische Datei wird geloescht sofern vorhanden
**Status:**
- `303 See Other``/tenant/{slug}/dashboard?tab=media&flash=deleted`
- `403 Forbidden` — Asset gehoert nicht diesem Tenant
- `404 Not Found` — Tenant oder Asset nicht vorhanden
---
## Admin UI (Web-Formulare) ## Admin UI (Web-Formulare)
### GET /admin ### GET /admin
@ -578,6 +694,89 @@ Rückleitung zur Admin-Seite.
--- ---
## Screen-User Management (Admin)
### POST /admin/users
Erstellt einen neuen Screen-User für einen Tenant (Admin-Formular).
**Request-Body (Form-Encoded):**
```
username=screenuser1&password=geheim
```
**Verhalten:**
- Neuer User mit `role = 'screen_user'` wird angelegt
- Passwort wird per bcrypt gehasht
- User wird dem aktuellen Tenant zugeordnet
**Status:**
- `200 OK` oder `201 Created` — Screen-User erstellt
- `400 Bad Request` — Fehlende oder ungültige Parameter, Username bereits vorhanden
- `500 Internal Server Error` — DB-Fehler
Rückleitung zur Admin-Seite.
---
### POST /admin/users/{userID}/delete
Löscht einen Screen-User und alle zugeordneten Screen-Permissions.
**Verhalten:**
- User mit Rolle `screen_user` wird gelöscht
- Alle Einträge in `user_screen_permissions` für diesen User werden gelöscht
**Status:**
- `200 OK` — Screen-User gelöscht
- `404 Not Found` — User nicht vorhanden oder falscher Typ
- `500 Internal Server Error` — DB-Fehler
Rückleitung zur Admin-Seite.
---
### POST /admin/screens/{screenID}/users
Fügt einen Screen-User zu einem Screen hinzu.
**Request-Body (Form-Encoded):**
```
user_id=<userID>
```
**Verhalten:**
- Eintrag in `user_screen_permissions` wird erstellt
- User muss vom Typ `screen_user` sein
- Unique-Constraint verhindert Duplikate
**Status:**
- `200 OK` — User zu Screen hinzugefügt
- `400 Bad Request` — Fehlende Parameter, User bereits hinzugefügt
- `404 Not Found` — Screen oder User nicht vorhanden
- `500 Internal Server Error` — DB-Fehler
Rückleitung zur Admin-Seite oder zum Screen-Detail.
---
### POST /admin/screens/{screenID}/users/{userID}/remove
Entfernt einen Screen-User von einem Screen.
**Verhalten:**
- Eintrag in `user_screen_permissions` wird gelöscht
- User behält seine Existenz; nur die Permission wird entfernt
**Status:**
- `200 OK` — User von Screen entfernt
- `404 Not Found` — Screen, User oder Permission nicht vorhanden
- `500 Internal Server Error` — DB-Fehler
Rückleitung zur Admin-Seite oder zum Screen-Detail.
---
## Playlist Management UI (Web-Formulare) ## Playlist Management UI (Web-Formulare)
### GET /manage/{screenSlug} ### GET /manage/{screenSlug}
@ -825,8 +1024,35 @@ Typische HTTP-Status:
--- ---
## In Vorbereitung (Phase 6 / künftig)
Die folgenden Endpoints sind derzeit vorbereitet, aber noch nicht vollständig implementiert:
- `POST /api/v1/player/screenshot` — Upload von Player-Screenshots an den Backend-Server
- Wird vom Agent unter `player/agent/internal/screenshot/screenshot.go` mit dem Intervall `MORZ_INFOBOARD_SCREENSHOT_EVERY` aufgerufen
- Multipart-Request mit `screen_id`, `screenshot` (Datei), `mime_type`
- Benötigt Backend-Handler für Persistierung und/oder Verarbeitung
---
## Änderungshistorie ## Änderungshistorie
- **2026-03-23 (Update):** Screen-User Management Endpoints (Doris / Doku-Review)
- `POST /admin/users` — Screen-User anlegen
- `POST /admin/users/{userID}/delete` — Screen-User löschen
- `POST /admin/screens/{screenID}/users` — User zu Screen hinzufügen
- `POST /admin/screens/{screenID}/users/{userID}/remove` — User von Screen entfernen
- **2026-03-23 (Update):** Security-Enhancements und Upload-Konsolidierung (Doris / Doku-Review)
- CSRF-Schutz (Double-Submit-Cookie) in `internal/httpapi/csrf.go`
- Rate-Limiting für `/login` in `internal/httpapi/ratelimit.go`
- Upload-Logik konsolidiert in `internal/fileutil/fileutil.go` und `internal/httpapi/uploads.go`
- Neue Env-Variable `MORZ_INFOBOARD_REGISTER_SECRET` dokumentiert
- Screenshot-Modul im Agent vorbereitet mit `MORZ_INFOBOARD_SCREENSHOT_EVERY`
- **2026-03-23 (Update):** Auth- und Tenant-Dashboard-Endpoints ergaenzt (Doris / Doku-Review)
- `GET /login`, `POST /login`, `POST /logout` dokumentiert
- `GET /tenant/{tenantSlug}/dashboard` dokumentiert
- `POST /tenant/{tenantSlug}/upload` dokumentiert
- `POST /tenant/{tenantSlug}/media/{mediaId}/delete` dokumentiert
- **2026-03-23:** Initiale Dokumentation aller HTTP-Endpoints basierend auf Code-Review - **2026-03-23:** Initiale Dokumentation aller HTTP-Endpoints basierend auf Code-Review
- Alle Screen-Management-Endpoints dokumentiert - Alle Screen-Management-Endpoints dokumentiert
- Alle Playlist-Management-Endpoints dokumentiert - Alle Playlist-Management-Endpoints dokumentiert

535
docs/GRUPPEN-KONZEPT.md Normal file
View file

@ -0,0 +1,535 @@
# Info-Board Neu - Gruppierungs- und Slot-Modell fuer monitoruebergreifende Layouts
## Ziel
Dieses Dokument definiert, wie Screens in Gruppen und Slots organisiert werden.
Gruppen und Slots sind notwendig fuer:
- **Massenaktionen** — mehrere Screens mit einer Kampagne ansprechen
- **Monitorwaende** — Schriftzuege und Layouts auf mehrere Screens verteilen
- **zukuenftige Skalierbarkeit** — neue Displays ohne Neustrukturierung hinzufuegen
Siehe auch `docs/TEMPLATE-KONZEPT.md` fuer Template-Typen, die Gruppen/Slots verwenden.
## 1. Screen-Gruppen
### Konzept
Eine Gruppe ist eine semantische Zusammenfassung mehrerer Screens.
**Beispiele:**
- `all` — alle Screens im System
- `wall-all` — alle 9 Infowand-Screens
- `wall-row-1` — die 3 Screens der ersten Reihe
- `wall-row-2` — die 3 Screens der zweiten Reihe
- `single-all` — alle Einzelanzeigen (z.B. Vertretungsplan-Displays)
- `outdoor` — alle Aussenanzeigetafeln
### Typen von Gruppen
#### Physische Gruppen
Spiegeln die **reale Anordnung** wider:
- `wall-all` — alle Displays einer Infowand
- `wall-row-1`, `wall-row-2`, `wall-row-3` — Reihen einer Wand
- `wall-column-1`, `wall-column-2`, `wall-column-3` — Spalten einer Wand
#### Funktionale Gruppen
Spiegeln den **Verwendungszweck** wider:
- `main-hall-all` — alle Displays im Hauptkorridor
- `cafeteria-all` — alle Displays in der Kaffeteria
- `info-all` — alle Informationsanzeigen
#### Typen-Gruppen
Spiegeln das **Geraetemodell** wider:
- `portrait-all` — alle Displays im Hochformat
- `landscape-all` — alle Displays im Querformat
- `4k-displays` — nur 4K-Monitore
#### Tenant-Gruppen (Phase 2)
Spiegeln die **Mandanten-Zugehoerigkeit** wider:
- `tenant-xyz-all` — alle Displays fuer Mandant XYZ
- `tenant-xyz-public` — nur oeffentliche Displays des Mandants
### Hierarchische Struktur
Gruppen koennen verschachtelt sein:
```
all
├── wall-all
│ ├── wall-row-1
│ │ ├── info01
│ │ ├── info02
│ │ └── info03
│ ├── wall-row-2
│ │ ├── info04
│ │ ├── info05
│ │ └── info06
│ └── wall-row-3
│ ├── info07
│ ├── info08
│ └── info09
├── single-all
│ ├── info10 (Vertretungsplan 1)
│ └── info11 (Vertretungsplan 2)
└── fallback-displays
└── [none currently]
```
**Automatische Inferenz:**
Ein Screen kann in mehreren Gruppen sein:
```
info01:
- all
- wall-all
- wall-row-1
- portrait-all
- online-displays (automatisch basierend auf Status)
```
## 2. Slot-Modell
### Konzept
Slots beschreiben **feste Positionen innerhalb eines Layouts**.
Sie werden hauptsaechlich fuer `message_wall`-Templates verwendet, um Ausschnitte von Grossmotiven auf einzelne Screens zu verteilen.
**Beispiel: 3x3 Infowand**
```
┌─────────────────────────────────┐
│ [0,0] [0,1] [0,2] │ Slot wall-r1-c1, wall-r1-c2, wall-r1-c3
├─────────────────────────────────┤
│ [1,0] [1,1] [1,2] │ Slot wall-r2-c1, wall-r2-c2, wall-r2-c3
├─────────────────────────────────┤
│ [2,0] [2,1] [2,2] │ Slot wall-r3-c1, wall-r3-c2, wall-r3-c3
└─────────────────────────────────┘
```
**Slot-Nomenclatur:**
- `wall-r{reihe}-c{spalte}` (Zeile/Spalte im 0er-System oder 1er-System)
- `wall-slot-{nummer}` (durchnummeriert, z.B. wall-slot-0 bis wall-slot-8)
### Geometrische Definition
Fuer jeden Slot wird definiert:
```json
{
"slot_id": "wall-r1-c1",
"row": 0,
"col": 0,
"layout_name": "3x3_grid",
"crop_x": 0,
"crop_y": 0,
"crop_width": 640,
"crop_height": 1080,
"assigned_screen_id": "info01"
}
```
Diese Werte sind:
- **serverseitig generiert** — Admin muss nicht manuell Pixel-Koordinaten eingeben
- **automatisch skalierbar** — bei verschiedenen Aufloesungen
## 3. Datenmodell
### Tabelle `screen_groups`
```sql
CREATE TABLE screen_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
group_type TEXT NOT NULL CHECK (group_type IN (
'physical', 'functional', 'device_type', 'tenant', 'custom'
)),
parent_group_id UUID REFERENCES screen_groups(id),
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
**Beispiele:**
```sql
INSERT INTO screen_groups (slug, name, group_type)
VALUES
('all', 'Alle Screens', 'custom'),
('wall-all', 'Infowand - Alle', 'physical'),
('wall-row-1', 'Infowand - Reihe 1', 'physical'),
('single-all', 'Einzelanzeigen', 'functional'),
('portrait-all', 'Hochformat', 'device_type');
```
### Tabelle `screen_group_members`
```sql
CREATE TABLE screen_group_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES screen_groups(id) ON DELETE CASCADE,
screen_id UUID NOT NULL REFERENCES screens(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(group_id, screen_id)
);
```
**Beispiel:**
```sql
INSERT INTO screen_group_members (group_id, screen_id)
SELECT
(SELECT id FROM screen_groups WHERE slug = 'wall-row-1'),
id
FROM screens
WHERE slug IN ('info01', 'info02', 'info03');
```
### Tabelle `layout_definitions`
```sql
CREATE TABLE layout_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
layout_type TEXT NOT NULL CHECK (layout_type IN (
'3x3_grid', '2x2_grid', '1x9_row', '9x1_column', 'custom'
)),
rows INT NOT NULL,
cols INT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
**Beispiel:**
```sql
INSERT INTO layout_definitions (slug, name, layout_type, rows, cols)
VALUES ('3x3_infowand', 'Infowand 3x3', '3x3_grid', 3, 3);
```
### Tabelle `layout_slots`
```sql
CREATE TABLE layout_slots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
layout_id UUID NOT NULL REFERENCES layout_definitions(id) ON DELETE CASCADE,
slot_slug TEXT NOT NULL,
row INT NOT NULL,
col INT NOT NULL,
UNIQUE(layout_id, slot_slug)
);
```
**Beispiel:**
```sql
INSERT INTO layout_slots (layout_id, slot_slug, row, col)
SELECT
(SELECT id FROM layout_definitions WHERE slug = '3x3_infowand'),
'wall-r' || (r) || '-c' || (c),
r - 1, c - 1
FROM
CROSS JOIN LATERAL (SELECT GENERATE_SERIES(1, 3) AS r)
CROSS JOIN LATERAL (SELECT GENERATE_SERIES(1, 3) AS c);
```
### Tabelle `slot_screen_assignments`
```sql
CREATE TABLE slot_screen_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
layout_id UUID NOT NULL REFERENCES layout_definitions(id),
slot_id UUID NOT NULL REFERENCES layout_slots(id) ON DELETE CASCADE,
screen_id UUID NOT NULL REFERENCES screens(id),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(layout_id, slot_id, screen_id)
);
```
**Beispiel:**
```sql
-- Zuordnung: Slot wall-r1-c1 → Screen info01 (in 3x3-Layout)
INSERT INTO slot_screen_assignments (layout_id, slot_id, screen_id)
SELECT
l.id,
ls.id,
s.id
FROM
layout_definitions l,
layout_slots ls,
screens s
WHERE
l.slug = '3x3_infowand'
AND ls.layout_id = l.id
AND ls.slot_slug = 'wall-r1-c1'
AND s.slug = 'info01';
```
## 4. Admin-Verwaltung
### Gruppen verwalten
**Seite:** Admin → Gruppen
```
┌──────────────────────────────────────────┐
│ Screen-Gruppen │
├──────────────────────────────────────────┤
│ │
│ Gruppe Typ Screens│
│────────────────────────────────────────│
│ all custom 13 │
│ wall-all physical 9 │
│ wall-row-1 physical 3 │
│ wall-row-2 physical 3 │
│ wall-row-3 physical 3 │
│ single-all functional 2 │
│ portrait-all device_type 12 │
│ │
│ [+ Neue Gruppe] [Gruppe bearbeiten] │
└──────────────────────────────────────────┘
```
### Gruppe erstellen/bearbeiten
```
┌──────────────────────────────────────────┐
│ Neue Gruppe │
├──────────────────────────────────────────┤
│ │
│ Name * │
│ [ Infowand Reihe 2 __________________ ] │
│ slug: wall-row-2 (automatisch) │
│ │
│ Gruppentyp * │
│ ⦿ physical (Wand-Anordnung) │
│ ○ functional (Verwendungszweck) │
│ ○ device_type (Geraetetyp) │
│ ○ tenant (Mandant) │
│ ○ custom (benutzerdefiniert) │
│ │
│ Beschreibung │
│ [ Die obere Reihe der Infowand ______ ] │
│ │
│ Screens hinzufuegen │
│ [ Suchfeld: "info" ] │
│ □ info01 ← obere Reihe │
│ □ info02 ← obere Reihe │
│ ☑ info03 ← obere Reihe │
│ □ info04 │
│ ... (nur unzugeordnete zeigen) │
│ │
│ Ausgewaehlte Screens │
│ info03 (portrait, online) │
│ [ + weitere hinzufuegen ] │
│ │
│ Uebergruppe │
│ [Dropdown: all > wall-all] │
│ (optional, zur Hierarchie) │
│ │
│ [Speichern] [Abbrechen] │
└──────────────────────────────────────────┘
```
### Layout-Definition erstellen (fuer Message-Wall)
**Seite:** Admin → Layouts
```
┌──────────────────────────────────────────┐
│ Layout-Definitionen │
├──────────────────────────────────────────┤
│ │
│ Layout-Name Typ Grid Slots│
│─────────────────────────────────────────│
│ 3x3 Infowand 3x3_grid 3x3 9 │
│ Vertretungsplan 2x2_grid 2x2 4 │
│ News-Lauf 1x9_row 1x9 9 │
│ │
│ [+ Neues Layout] [Bearbeiten] │
└──────────────────────────────────────────┘
```
Detailseite eines Layouts:
```
Layout: 3x3 Infowand
Visualisierung:
┌─────────┬─────────┬─────────┐
│ Slot 1 │ Slot 2 │ Slot 3 │
├─────────┼─────────┼─────────┤
│ Slot 4 │ Slot 5 │ Slot 6 │
├─────────┼─────────┼─────────┤
│ Slot 7 │ Slot 8 │ Slot 9 │
└─────────┴─────────┴─────────┘
Slot-Zuordnungen:
Slot 1 (wall-r1-c1) → Screen info01 (portrait, 1920x1080)
Slot 2 (wall-r1-c2) → Screen info02 (portrait, 1920x1080)
...
[Screen-Zuordnungen aendernx] [Layout loeschen]
```
## 5. Anwendung in Kampagnen
### Kampagne auf Gruppe anwenden
**Beispiel:** Admin aktiviert Weihnachtsmotiv auf `wall-all`:
```
Template: Weihnachtsmotiv 2025 (full_screen_media)
Zielgruppe auswaehlen:
⦿ Alle Screens
○ Nach Gruppe:
[Dropdown: wall-all ]
oder wall-row-1, single-all, ...
○ Einzelne Screens
→ Kampagne wird auf alle 9 Screens in wall-all aktiviert
→ Jeder Screen zeigt dasselbe Motiv
→ (Portrait/Landscape-Varianten werden serverseitig beruecksichtigt)
```
### Message-Wall-Kampagne mit Slot-Modell
**Beispiel:** Admin teilt Schriftzug auf Infowand auf:
```
Template: Schriftzug (message_wall)
Layout: 3x3 Infowand
Zielgruppe: wall-all (auto-expandiert zu Slots)
Gesamte Grafik hochladen oder zeichnen
System generiert automatisch:
- Slot wall-r1-c1 → Ausschnitt x0-640 y0-1080 → Screen info01
- Slot wall-r1-c2 → Ausschnitt 640-1280 y0-1080 → Screen info02
- Slot wall-r1-c3 → Ausschnitt 1280-1920 y0-1080 → Screen info03
- ... (9 Zuweisungen insgesamt)
Kampagne aktivieren
Jeder Screen ladet seinen zustaendigen Ausschnitt
Schriftzug erscheint verteilt ueber alle 9 Screens
```
## 6. Automatische Gruppe-Inferenz
Der Server kann bestimmte Gruppen automatisch generieren:
```python
# Automatisch generierte Gruppen
all:
- alle Screens im System (manuelle Verwaltung nicht noetig)
online-all:
- alle Screens, die gerade online sind
- wird alle 5 Min aktualisiert
offline-all:
- alle Screens, die gerade offline sind
portrait-all:
- alle Screens mit Orientierung = "portrait"
landscape-all:
- alle Screens mit Orientierung = "landscape"
device_type_*:
- fuer jeden konfigurieren Screen-Typ (z.B. device_type_raspberry_pi)
region_*:
- optional: auf Basis von Geo-Daten oder Tags
```
Diese automatischen Gruppen sind **read-only** im Admin-UI, aber voll verwendbar fuer Kampagnen.
## 7. Beispiel: Neuinstallation einer Infowand
**Szenario:** Admin installiert neue 3x3-Infowand mit Screens info01-info09.
**Schritte:**
1. **Screens anlegen** (via Provisionierungs-UI oder direkt)
```
info01, info02, ..., info09
Alle: Orientierung portrait, Geraetetyp "raspberry_pi"
```
2. **Gruppen anlegen**
```
screen_groups:
- slug: wall-all, name: "Infowand Alle", type: physical
- slug: wall-row-1, name: "Infowand Reihe 1", type: physical
- slug: wall-row-2, name: "Infowand Reihe 2", type: physical
- slug: wall-row-3, name: "Infowand Reihe 3", type: physical
```
3. **Screens den Gruppen zuordnen**
```
wall-all: info01-info09
wall-row-1: info01, info02, info03
wall-row-2: info04, info05, info06
wall-row-3: info07, info08, info09
```
4. **Layout definieren**
```
layout_definitions:
- slug: 3x3_infowand, rows: 3, cols: 3
layout_slots:
- wall-r1-c1, wall-r1-c2, wall-r1-c3 (row 0)
- wall-r2-c1, wall-r2-c2, wall-r2-c3 (row 1)
- wall-r3-c1, wall-r3-c2, wall-r3-c3 (row 2)
slot_screen_assignments:
- wall-r1-c1 → info01
- wall-r1-c2 → info02
- ... (9 gesamt)
```
5. **Kampagnen verwenden**
```
Template: Schriftzug
Zielgruppe: wall-all
Layout: 3x3_infowand
→ Kampagne kann sofort aktiviert werden
```
## 8. Zusammenfassung
Das Gruppierungs- und Slot-Modell:
- **ist flexibel** — physische, funktionale und typen-basierte Gruppen
- **ist hierarchisch** — Gruppen koennen Untergruppen enthalten
- **ist automatisch** — Gruppen wie "all" und "online-all" werden inferiert
- **ist geometrisch** — Slots definieren Layouts fuer verteilte Motive
- **ist skalierbar** — neue Screens werden einfach Gruppen zugeordnet
- **ist intuitiv** — Admin-UI zeigt Zuordnungen und Vorschauen

View file

@ -0,0 +1,483 @@
# Info-Board Neu - Aktivierungsoberflaeche fuer saisonale und temporaere Kampagnen
## Ziel
Die Aktivierungsoberflaeche ermoeglicht es dem Admin, Kampagnen zeitlich und gezielt auf Screens auszurollen — sofort oder geplant.
Dieses Dokument beschreibt:
- die Aktivierungs-Workflows im Admin-UI
- zeitgesteuerte Aktivierung (Scheduler)
- Screen-Zuordnung und Vorschau
- Status und Kontrolle waehrend der Laufzeit
Siehe auch `docs/TEMPLATE-EDITOR.md` fuer die Template-Verwaltung und `docs/TEMPLATE-KONZEPT.md` fuer konzeptionelle Grundlagen.
## 1. Aktivierungs-Workflows
### Workflow 1 — Schnelle Sofort-Aktivierung
**Szenario:** Admin hat ein Template und will es sofort starten.
**Weg:**
Admin → Templates → [Template] → "Aktivieren"
```
┌──────────────────────────────────────────┐
│ Kampagne starten: Weihnachtsmotiv 2025 │
├──────────────────────────────────────────┤
│ │
│ Kampagnen-Name (eindeutig) │
│ [ Weihnachten 2025 _________________] │
│ Vorschau: morz_campaign_xmas2025 │
│ │
│ Zielgruppe pruefen │
│ aus Template: Alle Screens (13) │
│ [Gruppe aendernx] [Screens aendernx] │
│ │
│ Dauer │
│ ⦿ Sofort starten │
│ gueltig ab jetzt │
│ ○ Geplant starten │
│ [Datum/Uhrzeit auswaehlen] │
│ │
│ Gueltig bis │
│ [Datum/Uhrzeit auswaehlen] │
│ oder [ ] unbegrenzt │
│ │
│ Prioritaet gegenueber Playlist │
│ [10____________] hoeher = wichtiger │
│ Standardwert: 1 │
│ │
│ Auto-Deaktivierung bei Ablauf? │
│ ⦿ Ja, danach Fallback zeigen │
│ ○ Nein, manuell deaktivieren │
│ │
│ Vorschau betroffener Screens │
│ [Screenshot-Vorschau mit Kampagnen- │
│ Inhalt fuer ausgew. Screens] │
│ │
│ [Aktivieren] [Abbrechen] │
└──────────────────────────────────────────┘
```
**Aktion:**
- Server speichert Kampagne mit `active = true`, `valid_from = NOW()`
- Server expandiert Zielgruppe in konkrete Screens
- Alle betroffenen Screens erhalten MQTT-Signal `playlist-changed` (obwohl Playlist gleich, aber Kampagnen-Prioritaet aendert sich)
- Screens synchonisieren und laden neue Kampagnen-Inhalte
### Workflow 2 — Geplante Aktivierung
**Szenario:** Admin bereitet eine Kampagne vor, soll aber erst am naechsten Tag 8:00 Uhr starten.
**Weg:**
Admin → Templates → [Template] → "Aktivieren" → "Geplant starten"
```
┌──────────────────────────────────────────┐
│ Geplante Aktivierung: Ostern 2025 │
├──────────────────────────────────────────┤
│ │
│ Kampagnen-Name │
│ [ Ostern_Dekoration_2025 ____________ ] │
│ │
│ Startdatum und -uhrzeit │
│ [2025-04-14] [08:00] [Kalender/Uhr] │
│ │
│ Enddatum und -uhrzeit (optional) │
│ [2025-04-21] [20:00] [Kalender/Uhr] │
│ oder [ ] Kein Enddatum │
│ │
│ Prioritaet │
│ [1_____________] │
│ │
│ Auto-Deaktivierung? │
│ ⦿ Ja │
│ ○ Nein │
│ │
│ Status │
│ ◯ GEPLANT — wird am 2025-04-14 08:00 │
│ aktiviert │
│ │
│ Erinnerung setzen (optional) │
│ [ ] Erinnerungs-Email 1 Tag vorher │
│ [ ] Erinnerungs-Email 1 Stunde vorher │
│ │
│ [Planen & Speichern] [Abbrechen] │
└──────────────────────────────────────────┘
```
**Aktion:**
- Server speichert Kampagne mit `active = false`, `valid_from = 2025-04-14 08:00`
- Server erstellt inneren Scheduler-Job
- Admin sieht Kampagne in Liste mit Status "GEPLANT"
- Um geplanten Zeitpunkt:
- Scheduler setzt `campaigns.active = true`
- MQTT-Signal an alle betroffenen Screens
- Optionale Erinnerungs-Email an Admin
### Workflow 3 — Schnelle Deaktivierung
**Szenario:** Kampagne laeuft, Admin will sie sofort stoppen.
**Weg:**
Admin → Kampagnen → [laufende Kampagne] → "Deaktivieren"
```
┌──────────────────────────────────────────┐
│ Kampagne deaktivieren? │
├──────────────────────────────────────────┤
│ │
│ Kampagne: Weihnachten 2025 │
│ Status: AKTIV seit 2025-12-01 09:00 │
│ Betroffene Screens: 13 │
│ │
│ Aktion: │
│ ⦿ Sofort deaktivieren │
│ Screens zeigen danach wieder │
│ Tenant-Playlist oder Fallback │
│ │
│ ○ Mit Verzoegerung (Fade-Out) │
│ [2 Min] [5 Min] [Uhr auswaehlen] │
│ Nuetzlich: Licht dimmen, Musik leiser │
│ etc. vor Inhalt-Wechsel │
│ │
│ [Ja, deaktivieren] [Abbrechen] │
└──────────────────────────────────────────┘
```
**Aktion:**
- Server setzt `campaigns.active = false`
- Server sendet MQTT-Signal an Screens
- Screens wechseln sofort (oder mit Verzoegerung) zu Fallback/Playlist
- Kampagne verschwindet aus "Aktive Kampagnen"-Liste
## 2. Zeitplanung und Scheduler
### Automatisierte Scheduler-Jobs
Der Server laeuft einen einfachen Scheduler als Goroutine oder als separaten Service.
```go
// Pseudocode
type CampaignScheduler interface {
RegisterJob(campaignID, activateAt, deactivateAt time.Time)
RunScheduler(ctx context.Context)
}
// Beim Starten
func init() {
scheduler := NewCampaignScheduler()
go scheduler.RunScheduler(ctx)
}
// Im Hintergrund
func (s *CampaignScheduler) RunScheduler(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Checke alle geplanten Kampagnen
campaigns := db.GetScheduledCampaigns()
for _, c := range campaigns {
if time.Now() >= c.ValidFrom && !c.Active {
// Aktiviere die Kampagne
s.ActivateCampaign(c.ID)
}
if c.ValidUntil != nil && time.Now() >= *c.ValidUntil && c.Active {
// Deaktiviere die Kampagne
s.DeactivateCampaign(c.ID)
}
}
}
}
}
```
### Persistenz ueber Restart
Scheduler-Jobs werden in der Datenbank gespeichert (Spalten `valid_from`, `valid_until`, `active` in `campaigns`-Tabelle).
Beim Neustart des Servers:
1. Server laedt alle geplanten/aktiven Kampagnen
2. Scheduler prueft bei jedem Takt (1 Min), ob eine Aktivierung/Deaktivierung faellig ist
3. Kein Datenverlust, kein komplexes Job-Persisting noetig
### Erinnerungen und Notifications
**Optional (Phase 2):**
- Email-Erinnerung N Stunden vor Aktivierung
- Webhook-Notification fuer externe Systeme
- In-App-Benachrichtigung im Admin-Dashboard
## 3. Screen-Zuordnung und Vorschau
### Interaktive Zielgruppen-Auswahl
Waehrend der Kampagnen-Erstellung kann der Admin entscheiden, welche Screens betroffen sein sollen.
```
Zielgruppe
⦿ Alle Screens
○ Nach Gruppe auswaehlen:
□ wall-all (9 Screens)
□ single-info (2 Screens)
□ vertretungsplan-all (2 Screens)
○ Einzelne Screens:
[ Suchfeld: "info" ]
□ info01 (portrait)
□ info02 (portrait)
☑ info03 (portrait)
□ info04 (portrait)
...
```
### Rendering-Vorschau
Admin sieht, wie die Kampagne auf verschiedenen Zielscreens aussieht:
```
Betroffene Screens: 4 ausgew.
┌─────────────────────────────────────┐
│ info01 (portrait, 1920x1080) │
│ ┌────────────────────────────────┐ │
│ │ │ │
│ │ [Kampagnen-Inhalt: Bild] │ │
│ │ (Portrait-Assets verwendet) │ │
│ │ │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ info05 (landscape, 2560x1440) │
│ ┌────────────────────────────────┐ │
│ │ [Kampagnen-Inhalt: Bild] │ │
│ │ (Landscape-Assets verwendet) │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────┘
[Scrollen um weitere Screens zu sehen]
```
### Live-Uebersicht waehrend Laufzeit
Wenn eine Kampagne aktiv ist, zeigt das Admin-Dashboard:
```
Kampagne: Weihnachten 2025 einfuehrung
Status: AKTIV seit 2025-12-01 09:00
Betroffene Screens: 13
✓ Aktiv angezeigt: 11 (info01-info08, info10, info11, info13)
◯ Wartet auf Sync: 1 (info09)
✗ Offline: 1 (info12)
Zuletzt geprueft: vor 30 Sekunden
[Aktualisieren] [Deaktivieren] [Bearbeiten]
```
## 4. Kampagnen-Verwaltung waehrend Laufzeit
### Aktive Kampagnen — Haupt-Dashboard
**Seite:** Admin → Aktive Kampagnen (oder Campaigns)
```
┌─────────────────────────────────┐
│ Aktive Kampagnen │
├─────────────────────────────────┤
│ │
│ Weihnachten 2025 einfuehrung │ ▼
│ Template: Weihnachtsmotiv 2025 │
│ Aktiv seit: 2025-12-01 09:00 │
│ Aktiv bis: 2025-12-26 23:59 │
│ Betroffene: 13 Screens │
│ Status: ✓ Auf allen Screens ok │
│ │
│ [Vorschau] [Bearbeiten] │
│ [Deaktivieren] │
│ │
├─────────────────────────────────┤
│ │
│ Event-Tag 25.03 │
│ Template: screen_specific_scene │
│ Aktiv seit: 2025-03-25 00:00 │
│ Aktiv bis: 2025-03-25 23:59 │
│ Betroffene: 4 Screens │
│ Status: ◯ 1 Screen offline │
│ │
│ [Vorschau] [Bearbeiten] │
│ [Deaktivieren] │
│ │
└─────────────────────────────────┘
```
### Geplante Kampagnen
**Seite:** Admin → Kampagnen (Alle)
```
┌─────────────────────────────────┐
│ Geplante Kampagnen │
├─────────────────────────────────┤
│ │
│ Ostern-Dekoration 2025 │ ▼
│ Template: full_screen_media │
│ Status: GEPLANT │
│ Startet: 2025-04-14 08:00 │
│ Endet: 2025-04-21 20:00 │
│ Betroffene: 13 Screens │
│ Erinnerung: 1 Tag vorher │
│ │
│ [Vorschau] [Bearbeiten] │
│ [Jetzt aktivieren] [Loeschen] │
│ │
├─────────────────────────────────┤
│ │
│ Sommer-Kampagne │
│ Status: GEPLANT │
│ Startet: 2025-06-01 00:00 │
│ │
│ ... │
│ │
└─────────────────────────────────┘
```
### Abgelaufene Kampagnen
**Seite:** Admin → Kampagnen (Archiv)
```
Zeigt inaktive/abgelaufene Kampagnen fuer Audit-Trail.
[ Kampagne ] Zeitraum Status
Ostern 2025 2025-04-14—04-21 Auto-Deaktiviert
Karneval 2025-02-28—03-05 Manuell deaktiviert
Valentinstag 2025-02-14 Auto-Deaktiviert
```
## 5. Prioritaetsverwaltung
### Prio-Einstellung pro Kampagne
```
Prioritaet gegenueber Tenant-Playlist
┌─────────────────────────────────┐
│ Schieber oder Zahlenfeld │
│ │
│ [|━━━━━━━━━━━| ] 10 │
│ 1 5 10 100 │
│ │
│ Bedeutung: │
│ 1 = normale Kampagne │
│ 10 = hohe Prioritaet (Standard) │
│ 100 = Notfall / absolut wichtig │
│ │
│ Diese Prioritaet wird ueber │
│ alle Tenant-Playlists gestellt │
│ (falls mehrere Kampagnen) │
│ verwendet die mit hoechster │
│ Prioritaet │
└─────────────────────────────────┘
```
### Konflikt-Management (mehrere Kampagnen gleichzeitig)
Falls mehrere Kampagnen fuer denselben Screen aktiv sind:
1. Sortierende nach Prioritaet (hoechste gewinnt)
2. Bei gleicher Prioritaet: nach Start-Zeitstempel (neueste gewinnt)
3. Admin sieht im Status-Dashboard einen Warning: "2 Kampagnen fuer info01 aktiv"
Empfehlung: Admin sollte Zeitraeume von Kampagnen nicht ueberlappen lassen.
## 6. Fehlerbehandlung
### Was, wenn ein Screen offline ist?
```
Kampagne wird aktiviert, aber Screen info03 ist gerade offline:
1. Server weiss, dass info03 Ziel der Kampagne ist
2. Server loggt: "Kampagne XYZ kann nicht auf info03 ausgeliefert werden (offline)"
3. Info03 hat letzte gueltige Kampagne gecacht
4. Sobald info03 wieder online kommt:
- Player synchonisiert
- Server sagt: "Kampagne XYZ ist aktiv"
- Player ladet und rendert
5. Status im Dashboard: "info03 — Offline, wird synchronisiert sobald online"
```
### Rollback bei fehlgeschlagener Aktivierung
Falls eine Kampagne fehlerhaft ist (kaputtes Video, Renderingfehler):
```
1. Screen zeigt Fehler-Overlay
2. Admin ist informiert (Status-API zeigt Fehler)
3. Admin Aktion 1: Template korrigieren
- Fehlerhaftes Asset austauschen
- Kampagne aktualisieren
- Screens neu synchonisieren
4. Admin Aktion 2: Schnelle Deaktivierung
- Kampagne abschalten
- Fallback/Playlist kehrt zurueck
```
## 7. Datenschutz und Audit
### Audit-Trail
Alle Kampagnen-Aenderungen werden protokolliert:
```json
{
"ts": "2025-03-25T14:22:00Z",
"event": "campaign_activated",
"campaign_id": "uuid-...",
"campaign_name": "Ostern-Dekoration",
"triggered_by_user_id": "admin123",
"triggered_by_email": "admin@example.com",
"details": {
"valid_from": "2025-04-14T08:00:00Z",
"valid_until": "2025-04-21T20:00:00Z",
"target_screens_count": 13
}
}
```
Diese Logs sind fuer Compliance und Forensik wichtig.
### Sichtbarkeitsbeschraenkung
Nur Benutzer mit Admin-Rolle koennen:
- Kampagnen erstellen/aendernx
- Templates bearbeiten
- Aktivierung planen
Tenant-User sehen keine Kampagnen-Verwaltung.
## 8. Zusammenfassung
Die Aktivierungsoberflaeche:
- **ist einsteigerfreundlich** — Multi-Step Formulare mit Vorschau
- **unterstuetzt Sofort und Planung** — spontan oder Wochen im Voraus
- **ist sichtbar** — Live-Status und Fehler-Reporting
- **ist automatisiert** — Scheduler kuemmert sich um Auf-/Abschalten
- **ist sicher** — Audit-Trail und Rollback-Moeglichkeiten
- **ist robust** — Offline-Screens werden spaeter synchronisiert

470
docs/MONITORING-KONZEPT.md Normal file
View file

@ -0,0 +1,470 @@
# Info-Board Neu - Logging- und Monitoring-Konzept
## Ziel
Logging und Monitoring geben dem Betriebsteam vollstaendige Transparenz ueber:
- Verhalten und Fehler auf dem Player
- Verhalten und Fehler auf dem Server
- Health-Status aller Screens
- Netzwerk- und Synchronisierungsprobleme
- Kapazitaetsauslastung und Trends
Das Konzept muss robust gegen Speicherplatz-Engpaesse auf dem Raspberry Pi arbeiten und zentralisiert auf dem Server auswertbar sein.
## Logging-Architektur
### Allgemeine Prinzipien
- **strukturiertes JSON-Logging** — nicht Freitextloggen, sondern strukturierte Felder
- **Log-Levels**: `debug`, `info`, `warn`, `error`, `fatal`
- **Zentrale Auswertung** — Player loggen lokal und senden auch an Server
- **Rotation und Bereinigung** — lokale Logs werden rotiert und komprimiert
- **Datenschutz** — keine sensiblen Inhalte (Passwoerter, API-Keys) ins Log
### Komponenten und ihre Logs
## 1. Player-Logs
### Player-Agent
Der Agent protokolliert:
- **Startup/Shutdown**
```json
{
"ts": "2025-03-23T14:22:00Z",
"level": "info",
"component": "agent",
"event": "startup",
"config_file": "/etc/signage/config.yml",
"screen_id": "info01"
}
```
- **Server-Sync**
```json
{
"ts": "2025-03-23T14:22:05Z",
"level": "info",
"component": "agent.sync",
"event": "sync_complete",
"duration_ms": 342,
"items_synced": 15,
"bytes_downloaded": 4521000
}
```
- **MQTT-Ereignisse**
```json
{
"ts": "2025-03-23T14:22:10Z",
"level": "info",
"component": "agent.mqtt",
"event": "playlist_changed",
"source": "mqtt",
"cause": "playlist-changed-event"
}
```
- **Fehler**
```json
{
"ts": "2025-03-23T14:22:15Z",
"level": "error",
"component": "agent.cache",
"event": "download_failed",
"media_id": "abc123",
"url": "https://cdn.example.com/video.mp4",
"error": "connection_timeout",
"retry_count": 2
}
```
- **Watchdog-Ereignisse** (siehe WATCHDOG-KONZEPT.md)
### Player-UI
Die lokale Web-App protokolliert:
- **Item-Wechsel**
```json
{
"ts": "2025-03-23T14:23:00Z",
"level": "info",
"component": "ui",
"event": "item_change",
"previous_item": "img-001",
"current_item": "video-002",
"source": "campaign"
}
```
- **Rendering-Fehler**
```json
{
"ts": "2025-03-23T14:23:05Z",
"level": "warn",
"component": "ui.renderer",
"event": "render_failed",
"item_id": "url-003",
"media_type": "webpage",
"error": "load_timeout",
"timeout_ms": 10000
}
```
- **Overlay-Status-Aenderungen**
```json
{
"ts": "2025-03-23T14:23:10Z",
"level": "info",
"component": "ui.overlay",
"event": "status_change",
"old_status": "online",
"new_status": "offline",
"reason": "broker_connection_lost"
}
```
### Chromium
Der Browser ist schwer zu loggable, aber systemd journal erfasst:
- Startup und Argumente
- Crash-Meldungen
- Fehlerrückmeldungen bei Seitenladefehler
## 2. Server-Logs
### Backend-API
Der Server protokolliert:
- **HTTP-Requests** (strukturiert, nicht kompletter Request-Body)
```json
{
"ts": "2025-03-23T14:22:20Z",
"level": "info",
"component": "server.http",
"method": "POST",
"path": "/api/v1/screens/info01/playlist",
"status": 200,
"duration_ms": 34,
"user_id": "admin123",
"tenant_id": "tenant01"
}
```
- **Datenbank-Operationen** (nur bei Debug-Level)
```json
{
"ts": "2025-03-23T14:22:25Z",
"level": "debug",
"component": "server.db",
"query": "UPDATE playlists SET updated_at = NOW() WHERE screen_id = $1",
"duration_ms": 5,
"rows_affected": 1
}
```
- **Fehler und Exceptions**
```json
{
"ts": "2025-03-23T14:22:30Z",
"level": "error",
"component": "server.api",
"event": "media_download_failed",
"media_id": "abc123",
"reason": "storage_quota_exceeded",
"available_bytes": 1024000,
"required_bytes": 50000000
}
```
- **Admin-Kommandos**
```json
{
"ts": "2025-03-23T14:22:35Z",
"level": "info",
"component": "server.command",
"event": "command_sent",
"command_type": "restart_player",
"target_screen": "info01",
"triggered_by_user": "admin123"
}
```
### Provisionierungs-Worker
```json
{
"ts": "2025-03-23T14:22:40Z",
"level": "info",
"component": "server.provision",
"event": "provision_started",
"screen_id": "new_display_01",
"target_ip": "192.168.1.50",
"ansible_playbook": "site.yml"
}
```
## Log-Format und Ausgabe
### Struktur
Alle Logs folgen diesem Schema:
```json
{
"ts": "2025-03-23T14:22:00Z", // ISO 8601, UTC
"level": "info|warn|error|debug",
"component": "agent|ui|server.api|server.db|server.mqtt",
"event": "descriptive_name",
"screen_id": "info01", // nur auf Player relevant
"tenant_id": "tenant01", // nur auf Server relevant
"user_id": "user123", // nur auf Server bei Auth-Events
"duration_ms": 342, // bei Performance-Events
// Fehler-spezifische Felder
"error": "error_code",
"error_message": "readable error",
// Domain-spezifische Felder
"item_id": "...",
"media_type": "image|video|pdf|webpage",
"source": "campaign|tenant_playlist|fallback",
// Sonstige beliebige Felder
"details": { ... }
}
```
### Ausgabeziele
#### Auf dem Player
1. **stdout/stderr** mit `log/slog` JSON-Formatter
- erfasst von systemd journal
- abrufbar via `journalctl`
2. **Lokale Datei** `/var/log/signage/player.log`
- JSON, eine Zeile pro Event
- Rotation auf 100 MB, 10 Archive
3. **Schnelle Fehler** an Server via HTTP-POST
- `POST /api/v1/screens/{screenSlug}/log-event`
- asynchron, Fehler bei Offline ignoriert
- nur `error` und `fatal` Events
#### Auf dem Server
1. **stdout/stderr** mit strukturiertem Logging
- erfasst von Docker/systemd
- abrufbar via `docker logs` oder `journalctl`
2. **PostgreSQL** (Phase 2+)
- wichtige Fehler und Status-Events in Tabelle `logs`
- Abfrage-UI im Admin-Dashboard
3. **Dateispeicher** (Docker Volume)
- `/var/log/signage/server.log`
- Rotation und Verdichtung durch Container-Orchester
## Log-Level-Strategie
### Debug (development)
- SQL-Queries
- HTTP-Request-Details
- interner State-Uebergaenge
Bei Production: `--log-level warn` oder `--log-level info`
### Info (standard)
- Startup/Shutdown
- erfolgreiche Operationen
- Status-Wechsel
- Synchronisierungsereignisse
### Warn (aufmerksamkeit)
- Timeouts
- Retry-Versuche
- deprecierte APIs
- suboptimale Performance
### Error (problematisch)
- gescheiterte HTTP-Requests
- Datenbankfehler
- fehlende Ressourcen
- Auth-Fehler
### Fatal (kritisch)
- nicht-wiederherstellbare Fehler
- Prozess beendet sich danach
## Monitoring-Metriken
### Player-seitig
Metriken, die der Agent periodisch dem Server meldet:
```json
{
"screen_id": "info01",
"ts": "2025-03-23T14:25:00Z",
"heartbeat": {
"uptime_seconds": 86400,
"last_sync_at": "2025-03-23T14:24:55Z",
"seconds_since_last_sync": 5,
"sync_status": "ok|failed|pending",
"sync_fail_count_24h": 0
},
"resources": {
"cpu_percent": 25,
"memory_percent": 45,
"disk_free_mb": 2048,
"disk_used_percent": 35
},
"network": {
"broker_connected": true,
"server_reachable": true,
"ip_addresses": ["192.168.1.10"],
"signal_strength_dbm": -55
},
"playback": {
"current_item_id": "img-001",
"source": "campaign",
"rendering_status": "ok",
"seconds_on_current_item": 23
},
"errors_last_hour": [
{
"event": "download_failed",
"media_id": "video-999",
"count": 2
}
]
}
```
**Uebertragung:** HTTP `POST /api/v1/screens/{screenSlug}/heartbeat` alle 60 Sekunden
### Server-seitig
Der Server sammelt und ueberwacht:
```json
{
"screen_id": "info01",
"status": "online|offline|degraded|error",
"last_heartbeat_at": "2025-03-23T14:25:00Z",
"seconds_since_last_heartbeat": 0,
"heartbeat_interval_sec": 60,
"offline_since_sec": null,
"screenshot": {
"latest_at": "2025-03-23T14:25:00Z",
"seconds_since_latest": 0
},
"sync": {
"latest_at": "2025-03-23T14:24:55Z",
"latest_duration_ms": 342,
"fail_count_24h": 1,
"last_error": null
},
"content": {
"current_item": "img-001",
"source": "campaign",
"campaign_id": "xmas-2025"
},
"performance": {
"cpu_avg_percent_1h": 22,
"memory_avg_percent_1h": 44,
"disk_free_mb": 2048
}
}
```
Diese Metriken werden in PostgreSQL gespeichert und bilden Basis fuer:
- Status-Dashboard
- Alerts
- Trend-Analysen
- Kapazitaetsplanung
## Log-Rotation auf dem Player
Der Raspberry Pi hat begrenzte Speicherkapazitaet. Log-Rotation muss aggressiv sein:
```yaml
# /etc/logrotate.d/signage
/var/log/signage/player.log
{
size 50M
rotate 5
compress
delaycompress
missingok
notifempty
create 0644 root root
postrotate
systemctl reload signage-agent.service || true
endscript
}
/var/log/signage/watchdog.log
{
size 20M
rotate 3
compress
delaycompress
missingok
notifempty
create 0644 root root
}
```
Resultat:
- `player.log`: max 50 MB * 5 = 250 MB
- `watchdog.log`: max 20 MB * 3 = 60 MB
- Komprimierung von alten Logs auf ~10% der urspruenglichen Groesse
## Alerting-Strategie
### Kriterien fuer Alerts
| Bedingung | Severity | Aktion |
|---|---|---|
| Screen offline > 15 min | High | Email + Dashboard-Alert |
| Screen offline > 2h | Critical | Email + SMS |
| Sync-Fehlerquote > 50% in 1h | Medium | Email |
| Disk Full auf Player | Critical | Email + Stop-Recording |
| CPU > 90% fuer 5 min | Medium | Warnung + Analysis |
| Provisioning fehlgeschlagen | High | Email an Provisioner |
### Alert-Kanal (Phase 2)
1. **Dashboard-Benachrichtigungen** (im Admin-UI sichtbar)
2. **Email** an konfigurierte Admin-Adressen
3. **Webhook** fuer externe Monitoring-Systeme (Zabbix, Grafana)
4. **Server-API** `/api/v1/admin/alerts` fuer Polling
## Zusammenfassung
Das Logging- und Monitoring-Konzept:
- **ist strukturiert** — JSON, nicht Freitexte
- **ist verteilt** — lokal auf Player + zentral auf Server
- **ist speicherbewusst** — Rotation und Kompression
- **gibt Ueberblick** — Heartbeat + Metriken fuer jeden Screen
- **ermoeglicht Diagnose** — detaillierte Logs im Fehlerfall
- **skaliert** — Verfahren gilt fuer beliebig viele Player

610
docs/PROVISION-KONZEPT.md Normal file
View file

@ -0,0 +1,610 @@
# Info-Board Neu - Jobrunner-Konzept fuer Ansible-gestuetzte Erstinstallation
## Ziel
Der Jobrunner fuehrt aus dem Admin-Backend heraus Provisionierungsjobs aus, die ein neues Display technisch in Betrieb nehmen.
Dieses Dokument beschreibt:
- wie ein Admin einen neuen Screen aus dem Web-UI provisioniert
- wie der Server Ansible-Playbooeke orchestriert
- wie der Fortschritt angezeigt wird
- Sicherheits- und Fehlerbehandlung
Grundlagen zur Provisionierungs-Strategie finden sich in `docs/PROVISIONIERUNGSKONZEPT.md`.
## 1. Provisionierungs-Workflow im Admin-UI
### Seite: Admin → Screens → Neu
```
┌──────────────────────────────────────────┐
│ Neuen Screen provisionieren │
├──────────────────────────────────────────┤
│ │
│ Schritt 1 — Grunddaten │
│ │
│ Screen-ID / Slug * │
│ [ info10 ] │
│ (muss eindeutig sein, alphanumerisch) │
│ │
│ Anzeigename * │
│ [ Infowand Bottom-Left ________________ ] │
│ │
│ Beschreibung │
│ [ Neue Infowand Display, pos. 7______ ] │
│ │
│ Device Type * │
│ ⦿ Raspberry Pi 4 │
│ ○ Raspberry Pi 5 │
│ ○ x86 Linux Kiosk │
│ │
│ Aufloesung * │
│ [1920 x 1080 ] Standard fuer RPi │
│ │
│ Orientierung * │
│ ⦿ portrait (hochkant) │
│ ○ landscape (quer) │
│ │
│ Tenant-Zuordnung │
│ [ Dropdown: alle Tenants + "admin" ] │
│ │
│ [Weiter >] [Abbrechen] │
└──────────────────────────────────────────┘
```
### Schritt 2 — Netzwerk- und SSH-Einstellung
```
┌──────────────────────────────────────────┐
│ Schritt 2 — Zugang zur Hardware │
│ │
│ Ziel-IP-Adresse * │
│ [ 192.168.1.50 ] │
│ │
│ SSH-Port │
│ [ 22 ] Standard │
│ │
│ Bootstrap-Benutzer * │
│ ⦿ root │
│ ○ pi │
│ ○ custom: [ ________________ ] │
│ │
│ Bootstrap-Authentifizierung * │
│ ⦿ Passwort (initial, wird durch Key │
│ ersetzt): │
│ [ Passwort ____________ ] │
│ ○ SSH-Key (nur wenn vorvorhanden): │
│ [ Datei auswaehlen ] oder │
│ [ PEM-Key einfuegen ] │
│ │
│ Test-Verbindung │
│ [SSH Test] [PING Test] │
│ │
│ [Weiter >] [Zurueck] [Abbrechen] │
└──────────────────────────────────────────┘
```
### Schritt 3 — Konfiguration und Optionen
```
┌──────────────────────────────────────────┐
│ Schritt 3 — Konfiguration │
│ │
│ Fallback-Verzeichnis (lokal auf Player) │
│ [ /var/lib/signage/fallback ] │
│ │
│ Snapshot-Intervall (Sekunden) │
│ [ 300 ] 0 = deaktiviert │
│ │
│ MQTT-Broker-Adresse (Zielserver) │
│ [ mqtt.example.com ] auto-gefuellt │
│ │
│ Server-API-Adresse │
│ [ https://signage.example.com/api ] │
│ auto-gefuellt │
│ │
│ Gruppen-Zuordnung (optional) │
│ [ Checkboxen: wall-all, wall-row-1 ] │
│ │
│ Tags / Labels (optional) │
│ [ mainfloor, hightrafficarea ] │
│ │
│ [Weiter >] [Zurueck] [Abbrechen] │
└──────────────────────────────────────────┘
```
### Schritt 4 — Review und Start
```
┌──────────────────────────────────────────┐
│ Schritt 4 — Uebersicht & Start │
│ │
│ Zusammenfassung: │
│ │
│ Screen: info10 │
│ Name: Infowand Bottom-Left │
│ Typ: Raspberry Pi 4 │
│ IP: 192.168.1.50 │
│ Aufloesung: 1920 x 1080 │
│ Orientierung: portrait │
│ Tenant: admin │
│ │
│ SSH-Verbindung wird hergestellt... │
│ [✓] SSH-Zugang verifiziert │
│ [✓] Pfadberechtigungen ok │
│ [✓] Speicherplatz ausreichend (15GB) │
│ │
│ Provisioning-Playbook: │
│ [ ] site.yml │
│ ├─ signage_base (Packages, Kernel) │
│ ├─ signage_display (X11, Chromium) │
│ ├─ signage_player (Agent, Config) │
│ └─ signage_provision (Setup-Jobs) │
│ │
│ Warnung: │
│ ! Diesen Prozess kann nicht unterbrochen│
│ werden. Typische Dauer: 10-15 Min. │
│ │
│ [Provisioning starten] [Abbrechen] │
└──────────────────────────────────────────┘
```
## 2. Provisioning-Job: Serverseitige Orchestrierung
### Architektur
```
┌─────────────────────────────────────────┐
│ Admin-UI HTTP Request │
│ POST /api/v1/admin/provision │
└────────────┬────────────────────────────┘
┌─────────────────────────────────────────┐
│ Backend API (Go) │
│ - validiert Eingaben │
│ - erstellt ProvisioningJob in DB │
│ - queued Job in Job-Broker (Redis etc) │
└────────────┬────────────────────────────┘
┌─────────────────────────────────────────┐
│ Jobrunner Worker (Goroutine oder │
│ separater Go-Service) │
│ - laeuft im Server-Container │
│ - zeigt Fortschritt via Websocket │
└────────────┬────────────────────────────┘
┌─────────────────────────────────────────┐
│ Ansible Executor │
│ ansible-playbook site.yml │
│ -i inventory.ini │
│ -e vars.yml │
└────────────┬────────────────────────────┘
┌─────────────────────────────────────────┐
│ Target Device (Raspberry Pi) │
│ SSH: root@192.168.1.50 │
│ - installiert Packages │
│ - startet Services │
│ - synchonisiert Config │
└─────────────────────────────────────────┘
```
### Provisioning-Job-Modell
```sql
CREATE TABLE provisioning_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
screen_id UUID NOT NULL REFERENCES screens(id),
status TEXT NOT NULL CHECK (status IN (
'pending', 'running', 'completed', 'failed'
)),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
-- SSH/Ansible-Details
target_ip TEXT NOT NULL,
target_port INT NOT NULL DEFAULT 22,
target_user TEXT NOT NULL,
-- Verbrauch von Ressourcen
ansible_job_id TEXT, -- Job-ID aus Ansible-Executor
-- Fehlerbehandlung
error_log TEXT, -- bei failure
created_by_user_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
### Provisioning-Log-Modell
```sql
CREATE TABLE provisioning_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES provisioning_jobs(id) ON DELETE CASCADE,
line_number INT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Quelle des Logs
source TEXT NOT NULL CHECK (source IN ('ansible', 'agent', 'system')),
level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')),
-- Nachricht
message TEXT NOT NULL,
UNIQUE(job_id, line_number)
);
```
## 3. Jobrunner-Implementierung
### Job-Verarbeitung (Pseudocode)
```go
type ProvisioningJobRunner struct {
db *sql.DB
ansibleBinPath string
logChannel chan ProvisioningLogMessage
}
func (r *ProvisioningJobRunner) ProcessJob(ctx context.Context, jobID uuid.UUID) error {
// 1. Lade Job aus DB
job := r.db.GetProvisioningJob(jobID)
// 2. Setze Status auf "running"
r.db.UpdateProvisioningJob(job.ID, map[string]interface{}{
"status": "running",
"started_at": time.Now(),
})
// 3. Generiere Ansible-Inventar
inventory := r.generateInventory(job)
// [192.168.1.50]
// ansible_user=root
// ansible_password=***
// screen_id=info10
// ansible_become=yes
// 4. Generiere vars.yml
vars := r.generateVars(job)
// screen_id: info10
// display_name: "Infowand Bottom-Left"
// orientation: portrait
// mqtt_broker: mqtt.example.com
// etc.
// 5. Fuehre Ansible aus
cmd := exec.CommandContext(ctx,
r.ansibleBinPath,
"site.yml",
"-i", inventoryPath,
"-e", varsPath,
"-v", // verbose
)
// 6. Piping: Ansible-Ausgabe → Log-Dateien + Websocket
stdout, _ := cmd.StdoutPipe()
stderr, _ := cmd.StderrPipe()
go r.streamLogs(job.ID, stdout, "ansible")
go r.streamLogs(job.ID, stderr, "ansible")
// 7. Warte auf Completion
err := cmd.Run()
// 8. Aktualisiere Job-Status
if err != nil {
r.db.UpdateProvisioningJob(job.ID, map[string]interface{}{
"status": "failed",
"completed_at": time.Now(),
"error_log": err.Error(),
})
return err
}
r.db.UpdateProvisioningJob(job.ID, map[string]interface{}{
"status": "completed",
"completed_at": time.Now(),
})
return nil
}
func (r *ProvisioningJobRunner) streamLogs(jobID uuid.UUID, reader io.Reader, source string) {
scanner := bufio.NewScanner(reader)
lineNum := 1
for scanner.Scan() {
line := scanner.Text()
// Persistiere in DB
r.db.InsertProvisioningLog(ProvisioningLog{
JobID: jobID,
LineNumber: lineNum,
Source: source,
Level: parseLogLevel(line), // heuristic
Message: line,
})
// Schreibe ins Websocket (siehe Abschnitt "Fortschritt")
r.logChannel <- ProvisioningLogMessage{
JobID: jobID,
Line: line,
}
lineNum++
}
}
```
### Ansible-Ausfuehrung mit Jumphost (optional)
Falls der Server nicht direkt die Zielgeraete erreicht, kann ein Jumphost verwendet werden:
```yaml
# ansible.cfg
[defaults]
inventory = inventory.ini
host_key_checking = False
retries = 3
[privilege_escalation]
become = True
become_method = sudo
```
```ini
# inventory.ini fuer Jumphost-Szenario
[targets]
192.168.1.50 ansible_user=root ansible_password=*** \
ansible_ssh_common_args='-o ProxyCommand="ssh -W %h:%p jumphost@example.com"'
```
## 4. Fortschritt und Live-Updates
### Websocket-Kanal fuer Echtzeit-Logs
**HTTP-Upgrade zu Websocket:**
```
GET /api/v1/admin/provision/{jobID}/logs
Upgrade: websocket
Connection: Upgrade
```
**Server sendet kontinuierlich:**
```json
{
"type": "log_line",
"timestamp": "2025-03-25T14:22:00Z",
"line": "TASK [signage_base : Update package cache] **",
"source": "ansible",
"level": "info"
}
```
```json
{
"type": "progress",
"timestamp": "2025-03-25T14:22:15Z",
"current_task": "signage_base : Update package cache",
"task_number": 3,
"total_tasks": 12,
"percent": 25
}
```
```json
{
"type": "status_change",
"timestamp": "2025-03-25T14:35:00Z",
"status": "completed",
"duration_seconds": 780
}
```
### UI-Anzeige waehrend Provisioning
```
┌──────────────────────────────────────────┐
│ Provisioning laeuft: info10 │
│ Gestartet: vor 5 Min. │
│ Geschaetzte verbleibende Zeit: 8 Min. │
├──────────────────────────────────────────┤
│ │
│ [████████████░░░░░░░░░░░░░░] 33% │
│ │
│ Aktuelle Aufgabe: │
│ ⊙ signage_base : Update package cache │
│ │
│ Letzte Logs: │
│ ├─ [14:22:00] TASK [signage_base ...] │
│ ├─ [14:22:05] ok: [192.168.1.50] │
│ ├─ [14:22:10] TASK [signage_display] │
│ ├─ [14:22:15] Chromium wird installiert│
│ └─ [14:22:20] ... │
│ │
│ [Auto-Refresh] [Pause] [Abbrechen] │
│ (Abbrechen: SSH-Verbindung wird nicht │
│ sofort getrennt, aber Job gestoppt) │
└──────────────────────────────────────────┘
```
## 5. Fehlerbehandlung und Recovery
### Fehlerszenarien
| Fehler | Grund | Recovery |
|---|---|---|
| SSH-Verbindung fehlgeschlagen | IP falsch, Passwort falsch, Firewall | Logs zeigen SSH-Error, Admin kann Credentials korrigieren und neu starten |
| Ansible-Playbook fehlgeschlagen | Paket-Versionskonflikt, Platz voll | Logs zeigen welcher Task fehlgeschlagen, Admin kann manuell SSH-en oder Job wiederholen |
| Timeout nach 30 Min. | Sehr langsame Netzwerk oder Device haengt | Job wird abgebrochen, Admin kann Verbindung checken und neu starten |
| Package-Download fehlgeschlagen | Mirror offline, Netzwerk unterbrochen | Ansible retry automatisch 3x, Logs zeigen wget-Error |
### Retry-Logik
```
Strategie: Exponentieller Backoff fuer Playbook-Fehler
Fehler 1: Sofort wiederholen
Fehler 2: Warte 5s, wiederhole
Fehler 3: Warte 15s, wiederhole
Fehler 4+: Gib auf, zeige Fehler
```
### Admin-Recovery
Falls ein Job fehlgeschlagen ist:
```
┌──────────────────────────────────────────┐
│ Provisioning fehlgeschlagen: info10 │
│ │
│ Fehler: │
│ ssh: Could not resolve hostname │
│ (DNS-Fehler oder Geraet nicht erreichbar)│
│ │
│ Empfehlung: │
│ 1. IP-Adresse pruefen │
│ 2. Geraet von Hand SSH-en und testen │
│ 3. Job neu starten: [Neuer Versuch] │
│ │
│ Komplette Logs herunterladen: │
│ [logs-info10-20250325.txt] │
│ │
│ [Neuer Versuch] [Logs zeigen] [Zurueck]│
└──────────────────────────────────────────┘
```
## 6. Sicherheitsaspekte
### SSH-Key-Verwaltung
**Phase 1 — Bootstrap mit Passwort:**
```
Admin gibt Passwort ein
Server speichert Passwort NICHT
Server uebergibt an Ansible nur waehrend dieser Session
Ansible loggt sich ein, generiert SSH-Key
SSH-Key wird auf dem Geraet als authorized_key eingetragen
Passwort wird auf dem Geraet gelöscht oder deaktiviert
```
**Phase 2 — Dauerhaft mit SSH-Key:**
```
Server speichert SSH-Key in Secrets-Backend (z.B. HashiCorp Vault)
Zukuenftige Ansible-Lauefe verwenden den Key
```
### Ansible-Vault fuer sensitive Daten
```yaml
# roles/signage_player/defaults/main.yml
server_api_key: !vault |
$ANSIBLE_VAULT;1.1;AES256
abcd1234...
```
Die Vault-Passphrase wird:
- nie im Klartext gelagert
- vom Server nur zur Laufzeit an Ansible uebergeben
- in Logs nicht ausgegeben
### Sudo ohne Passwort
Ansible erhoeht die Rechte per `sudo` ohne Passwort-Eingabe:
```sudoers
# /etc/sudoers.d/ansible-signage
ansible ALL=(ALL) NOPASSWD: ALL
```
(Alternativ: mit Passwort, das Ansible am Anfang einmal abfragt)
## 7. Verbindung zum bestehenden System
### Provisioning-Trigger aus Admin-UI
```
Admin-Seite: Screens → "+ Neuer Screen"
Formular sammelt Grunddaten
POST /api/v1/admin/provision
Backend:
1. Screen in `screens` Tabelle eintragen
2. ProvisioningJob in `provisioning_jobs` anlegen
3. Job in Broker queuen
Jobrunner:
1. Holt Job aus Broker
2. Startet Ansible
3. Streamt Logs via Websocket
4. Aktualisiert Job-Status bei Completion
Admin sieht Live-Updates im UI
```
### Nach erfolgreichem Provisioning
```
Job-Status: "completed"
Agent auf dem Display startet
Agent registriert sich beim Server
Server setzt Screen-Status auf "online"
Admin sieht Screen in Tabelle mit Status "online"
Admin kann sofort Kampagnen/Playlists zuweisen
```
## 8. Konfigurierbare Parameter
In `/etc/signage/provision.yml`:
```yaml
jobrunner:
max_concurrent_jobs: 3
ansible_timeout_sec: 1800
playbook_path: "/srv/ansible/site.yml"
inventory_template_path: "/srv/ansible/inventory.ini.tpl"
vars_template_path: "/srv/ansible/vars.yml.tpl"
ssh:
known_hosts_file: "/etc/signage/.ssh/known_hosts"
key_storage: "vault" # oder "filesystem"
ansible:
verbosity: "-vv" # oder "-v", "-vvv"
extra_args: ""
```
## 9. Zusammenfassung
Der Jobrunner:
- **ist web-gesteuert** — Provisioning-UI mit Multi-Step-Wizard
- **ist automatisiert** — Ansible Playbooks, nicht manuelle SSH-Kommandos
- **ist transparent** — Live-Logs und Fortschritt-Anzeige
- **ist sicher** — SSH-Keys, Ansible-Vault, keine Plaintext-Credentials in Logs
- **ist resilient** — Retry-Logik und Error-Recovery
- **ist erweiterbar** — neue Rollen und Tasks koennen hinzugefuegt werden ohne UI-Aenderung

View file

@ -48,21 +48,73 @@ Zweck:
Spalten: Spalten:
```sql ```sql
id uuid primary key id text primary key default gen_random_uuid()::text
tenant_id uuid null references tenants(id) on delete set null tenant_id text not null references tenants(id) on delete cascade
username text not null unique username text not null
email text not null unique password_hash text not null
password_hash text not null role text not null default 'tenant'
role text not null created_at timestamptz not null default now()
active boolean not null default true unique(tenant_id, username)
last_login_at timestamptz null
created_at timestamptz not null
updated_at timestamptz not null
``` ```
Regeln: Regeln:
- `role` in v1: `admin`, `tenant_user` - `role` in v1: `admin`, `screen_user`, `tenant`
- `username` ist nur innerhalb eines Tenants eindeutig (Unique-Constraint auf `(tenant_id, username)`)
- `tenant_id` ist `NOT NULL` — jeder User gehoert genau einem Tenant
- IDs sind `text`, nicht `uuid`, enthalten aber UUID-Werte (via `gen_random_uuid()::text`)
- Felder wie `email`, `active`, `last_login_at` und `updated_at` existieren in v1 nicht
### `user_screen_permissions`
Zweck:
- Zuordnung von Screen-Usern zu Screens (rollenbasierter Zugriff)
Spalten:
```sql
id uuid primary key
user_id text not null references users(id) on delete cascade
screen_id uuid not null references screens(id) on delete cascade
created_at timestamptz not null default now()
unique(user_id, screen_id)
```
Regeln:
- `user_id` muss ein User mit `role = 'screen_user'` sein
- `screen_id` muss existieren; Loeschen des Screens loescht auch die Permission
- Loeschen des Users loescht auch alle seine Permissions
### `sessions`
Zweck:
- Sitzungstokens fuer den Browser-Login
Spalten:
```sql
id text primary key default gen_random_uuid()::text
user_id text not null references users(id) on delete cascade
created_at timestamptz not null default now()
expires_at timestamptz not null default (now() + interval '8 hours')
```
Indizes:
```sql
create index idx_sessions_user_id on sessions(user_id);
create index idx_sessions_expires_at on sessions(expires_at);
```
Regeln:
- Session-TTL beim Anlegen betraegt standardmaessig 8 Stunden (Migration-Default);
`AuthStore.CreateSession` uebergibt die tatsaechliche TTL als Parameter (aktuell 24 Stunden)
- Abgelaufene Sessions werden stuendlich per Hintergrund-Ticker bereinigt (`CleanExpiredSessions`)
- Cookie-Name: `morz_session`; `HttpOnly=true`, `Secure=true` (ausser `MORZ_INFOBOARD_DEV_MODE=true`)
### `screen_groups` ### `screen_groups`
@ -494,6 +546,35 @@ last_failed_sync_at timestamptz null
last_error_message text null last_error_message text null
``` ```
## Auth-Datenbankschema
Die Auth-Tabellen werden durch `server/backend/internal/db/migrations/002_auth.sql` angelegt
und sind vollstaendig unter den Abschnitten `users` und `sessions` oben beschrieben.
Die Screen-Usserverwaltung wird durch `server/backend/internal/db/migrations/003_user_screen_permissions.sql` angelegt
und ist unter dem Abschnitt `user_screen_permissions` oben beschrieben.
Der `AuthStore` (`internal/store/auth.go`) stellt folgende Methoden bereit:
- `GetUserByUsername(ctx, username)` — Nutzer per Username laden (inkl. `TenantSlug` via LEFT JOIN)
- `CreateSession(ctx, userID, ttl)` — neue Session anlegen
- `GetSessionUser(ctx, sessionID)` — User zu gueltigem Session-Token laden
- `DeleteSession(ctx, sessionID)` — Session loeschen (Logout)
- `CleanExpiredSessions(ctx)` — abgelaufene Sessions bereinigen
- `EnsureAdminUser(ctx, tenantSlug, password)` — Admin-User beim Start anlegen wenn nicht vorhanden
- `VerifyPassword(ctx, userID, password)` — Passwort gegen bcrypt-Hash pruefen
- `CreateScreenUser(ctx, tenantID, username, password)` — neuen Screen-User anlegen
- `ListScreenUsers(ctx, tenantID)` — alle Screen-User eines Tenants auflisten
- `DeleteUser(ctx, userID)` — User und alle zugeordneten Permissions loeschen
Der `ScreenStore` (`internal/store/screen.go`) stellt folgende Methoden bereit:
- `GetAccessibleScreens(ctx, userID)` — alle Screens, auf die der User Zugriff hat
- `HasUserScreenAccess(ctx, userID, screenID)` — prueft ob User auf Screen zugreifen darf
- `AddUserToScreen(ctx, userID, screenID)` — User zu Screen hinzufuegen
- `RemoveUserFromScreen(ctx, userID, screenID)` — User von Screen entfernen
- `GetScreenUsers(ctx, screenID)` — alle User, die auf Screen Zugriff haben
## Wichtige Indizes ## Wichtige Indizes
Empfohlen mindestens: Empfohlen mindestens:

View file

@ -247,6 +247,96 @@ Sinnvolle Komponenten in `compose/`:
- `mosquitto` - `mosquitto`
- optional `worker` - optional `worker`
## Authentifizierung
Der Server verwendet einen Session-basierten Login-Flow mit `bcrypt`-Passwort-Hashing.
### Login-Flow
1. `GET /login` rendert das Login-Formular (Bulma-Card zentriert).
2. `POST /login` prueft Username und Passwort:
- `AuthStore.GetUserByUsername` laedt den User inkl. Tenant-Slug.
- `bcrypt.CompareHashAndPassword` prueft das Passwort (Cost-Faktor 12).
- Bei Erfolg legt `AuthStore.CreateSession` eine Session an (TTL 24 Stunden).
- Das Session-Token wird als `morz_session`-Cookie gesetzt (`HttpOnly=true`, `Secure=true`).
- Im `DevMode` (`MORZ_INFOBOARD_DEV_MODE=true`) wird `Secure=false` gesetzt fuer lokalen HTTP-Betrieb.
- Weiterleitung je nach Rolle: `admin``/admin`, `tenant``/tenant/{slug}/dashboard`.
3. `POST /logout` loescht die Session in der DB und entfernt den Cookie.
### Cookie-Lebensdauer
- Standard-TTL: 24 Stunden
- Der Cookie verfaellt automatisch; die DB wird stuendlich durch `CleanExpiredSessions` bereinigt.
### Admin-User-Bootstrap
Beim Server-Start wird `EnsureAdminUser` aufgerufen, wenn `MORZ_INFOBOARD_ADMIN_PASSWORD` gesetzt ist.
Der Admin-User wird dem Tenant mit Slug `MORZ_INFOBOARD_DEFAULT_TENANT` (Standard: `morz`) zugeordnet.
Ist der User bereits vorhanden, passiert nichts. Fehler sind nicht fatal — der Server startet trotzdem.
---
## Middleware-Kette
Alle geschuetzten Routen werden durch Middleware-Funktionen in `internal/httpapi/middleware.go` abgesichert.
```
Eingehende Anfrage
RequireAuth Liest morz_session-Cookie, prueft Session via DB,
speichert *store.User im Request-Context.
→ Fehler: Redirect zu /login?next=<Pfad>
├─► RequireAdmin Prueft user.Role == "admin"
│ → Fehler: 403 Forbidden
└─► RequireTenant Prueft user.TenantSlug == {tenantSlug} aus dem URL-Pfad.
Access Admins duerfen immer durch.
→ Fehler: 403 Forbidden
```
### Route-Gruppen im Router
| Gruppe | Middleware | Beispielrouten |
|----------------|------------------------------------|---------------------------------------------|
| Oeffentlich | keine | `/healthz`, `/login`, `/api/v1/screens/register` |
| Auth-only | RequireAuth | `/manage/{screenSlug}/...` |
| Admin-only | RequireAuth + RequireAdmin | `/admin`, `/admin/screens/...` |
| Tenant-scoped | RequireAuth + RequireTenantAccess | `/tenant/{tenantSlug}/...`, `/api/v1/tenants/{tenantSlug}/...` |
Der Hilfsfunktion `chain(middlewares...)` in `router.go` wrappet Handler von aussen nach innen.
---
## Tenant-Dashboard
Das Tenant-Self-Service-Dashboard ist unter `/tenant/{tenantSlug}/dashboard` erreichbar.
### URL-Schema
| Methode | Pfad | Beschreibung |
|---------|---------------------------------------------|---------------------------|
| GET | `/tenant/{tenantSlug}/dashboard` | Dashboard rendern |
| POST | `/tenant/{tenantSlug}/upload` | Medium hochladen |
| POST | `/tenant/{tenantSlug}/media/{mediaId}/delete` | Medium loeschen |
### Tabs
- **Tab A Meine Monitore:** Zeigt Screen-Karten mit Live-Status. Der Status wird per JavaScript
aus `GET /api/v1/screens/status` geladen und alle 30 Sekunden aktualisiert.
Status-Badge: `is-success` (online), `is-danger` (offline), `is-warning` (unbekannt).
- **Tab B Mediathek:** Upload-Formular (Bild, Video, PDF oder Web-URL) und Dateiliste
mit Loeschen-Button. Nach Upload oder Loeschen Redirect mit `?tab=media&flash=uploaded/deleted`.
### Eigentuemer-Pruefung beim Loeschen
`HandleTenantDeleteMedia` prueft, dass `asset.TenantID == tenant.ID`, bevor es loescht.
Damit ist sichergestellt, dass ein Tenant keine Assets anderer Tenants loeschen kann,
selbst wenn er die `mediaId` erraten wuerde.
---
## Sicherheitsgrundsaetze ## Sicherheitsgrundsaetze
- Root-Bootstrap-Geheimnisse nur kurzlebig oder referenziert speichern - Root-Bootstrap-Geheimnisse nur kurzlebig oder referenziert speichern

494
docs/TEMPLATE-EDITOR.md Normal file
View file

@ -0,0 +1,494 @@
# Info-Board Neu - Template-Editor fuer globale Kampagnen
## Ziel
Der Template-Editor ist ein Bereich des Admin-UI fuer die fachliche Erstellung und Verwaltung globaler Templates und deren operativen Aktivierungen als Kampagnen.
Dieses Dokument definiert:
- Welche Schritte ein Admin unternimmt, um ein Template zu erstellen
- Welche Felder und Optionen der Editor anbietet
- Wie Templates zu Kampagnen aktiviert werden
- Wie die Abbildung im Datenmodell aussieht
Grundlagen zu Template-Typen, Slot-Modell und Message-Wall finden sich in `docs/TEMPLATE-KONZEPT.md`.
## 1. Template-Verwaltung
### Template-Liste
**Seite:** Admin → Templates
**Anzeige:**
Tabelle mit allen Templates:
| Name | Typ | Zielgruppe | Szenen | Erstellt | Status |
|---|---|---|---|---|---|
| Weihnachtsmotiv 2025 | full_screen_media | alle | 1 | 2025-01-15 | draft |
| Schriftzug Infowand | message_wall | wall-all | 9 | 2025-02-01 | active |
| Event-Tag 25.03 | screen_specific_scene | [info01, info02, ...] | 2 | 2025-03-01 | draft |
**Aktionen pro Zeile:**
- "Bearbeiten" — öffnet Template-Editor
- "Kopieren" — dupliziert als neue Draft
- "Löschen" — nur wenn keine aktiven Kampagnen
- "Vorschau" — zeigt Layout (fuer message_wall) oder Asset-Galerien
- "Aktivieren" — schneller Weg zu Kampagne starten
### Template-Editor (Erstellung/Bearbeitung)
#### Phase 1 — Grunddaten
```
┌─────────────────────────────────────────┐
│ Neues Template erstellen │
├─────────────────────────────────────────┤
│ │
│ Name * │
│ [ Weihnachtsmotiv 2025_______________ ]│
│ technischer slug wird automatisch │
│ │
│ Template-Typ * │
│ ⦿ full_screen_media │
│ ○ message_wall │
│ ○ screen_specific_scene │
│ │
│ Beschreibung │
│ [ Weihnachtliche Grafik fuer alle___ ] │
│ [ Screens __________________________ ]│
│ │
│ Zielgruppe / Screens * │
│ ⦿ Alle Screens │
│ ○ Nach Gruppe auswaehlen │
│ [Dropdown: wall-all, single-all, ...] │
│ ○ Einzelne Screens auswaehlen │
│ [Checkbox-Liste mit Filterung] │
│ │
│ [Weiter >] [Abbrechen] │
└─────────────────────────────────────────┘
```
**Validierung:**
- Name ist erforderlich
- Name ist eindeutig
- Template-Typ ist erforderlich
- Zielgruppe ist erforderlich (keine leere Zuweisung)
#### Phase 2 — Szenen/Inhalte
Fuer `full_screen_media`:
```
┌─────────────────────────────────────────┐
│ Szenen und Inhalte │
├─────────────────────────────────────────┤
│ │
│ Szene 1: Vollbild-Grafik │
│ │
│ Medientyp * │
│ ○ Bild │
│ ○ Video │
│ ○ PDF │
│ ⦿ Webseite (HTML) │
│ │
│ Portrait-Asset (Hochformat) │
│ [Upload oder URL] │
│ [ Datei auswaehlen ] [Neue URL] │
│ oder vorher gemanagte Assets: [Liste] │
│ │
│ Landscape-Asset (Querformat) [optional] │
│ [ Datei auswaehlen ] [Neue URL] │
│ │
│ Anzeigedauer (Sekunden) │
│ [60_____] Standard: 10 │
│ │
│ Load-Timeout (Sekunden) │
│ [10_____] Standard: 10 │
│ │
│ gueltig ab │
│ [ 2025-03-25 ] [ 00:00 ] │
│ (leer = sofort gueltig) │
│ │
│ gueltig bis │
│ [ 2025-04-01 ] [ 00:00 ] │
│ (leer = unendlich) │
│ │
│ [+ Weitere Szene hinzufuegen] │
│ │
│ [Zurueck <] [Speichern & Aktivieren] │
│ [Speichern] │
│ [Abbrechen] │
└─────────────────────────────────────────┘
```
Fuer `message_wall`:
```
┌─────────────────────────────────────────┐
│ Message-Wall Layout │
├─────────────────────────────────────────┤
│ │
│ Layout-Template │
│ [Dropdown: 3x3-Grid, 2x2-Grid, ...] │
│ │
│ Anzeigedauer (Sekunden) │
│ [10_____] │
│ │
│ Gesamt-Grafik oder Text eingeben │
│ [Rich-Text-Editor oder Bild-Upload] │
│ │
│ Vorschau: [Zeigt Einteilung in Slots] │
│ │
│ Slot-Zuordnung: [Interaktive Zuordnung] │
│ Slot wall-r1-c1 → Screen info01 │
│ Slot wall-r1-c2 → Screen info02 │
│ ... (9 Slots insgesamt) │
│ │
│ [+ Layout-Typ aendernx] [Speichern] │
│ │
│ [Zurueck <] [Speichern & Aktivieren] │
│ [Speichern] │
│ [Abbrechen] │
└─────────────────────────────────────────┘
```
Fuer `screen_specific_scene`:
```
┌─────────────────────────────────────────┐
│ Monitorindividuelle Szenen │
├─────────────────────────────────────────┤
│ │
│ Szene 1: Infowand │
│ │
│ Zielgruppe │
│ ⦿ Gruppe: [Dropdown: wall-all] │
│ ○ Einzelne Screens: [Checkboxen] │
│ │
│ Asset │
│ [Upload oder URL] │
│ │
│ Dauer, Timeout, gueltig_von/bis │
│ [... wie oben ...] │
│ │
│ [+ Weitere Szene hinzufuegen] │
│ │
│ [Zurueck <] [Speichern & Aktivieren] │
└─────────────────────────────────────────┘
```
## 2. Kampagnen-Verwaltung
Kampagnen sind die operativen Instanzen von Templates.
### Kampagnen-Liste
**Seite:** Admin → Kampagnen
**Anzeige:**
| Name | Template | Aktiv | Zielgruppe | gueltig von | gueltig bis | Betroffene Screens |
|---|---|---|---|---|---|---|
| Weihnachten Dekoration | Weihnachtsmotiv 2025 | ✓ | alle | 2025-12-01 | 2025-12-26 | 13 Screens |
| Schriftzug Januar | Schriftzug Infowand | ✗ | wall-all | 2025-01-06 | 2025-01-31 | 9 Screens |
**Aktionen:**
- "Bearbeiten" — Kampagnen-Eigenschaften aendern
- "Aktivieren/Deaktivieren" — Toggle sofort
- "Vorschau" — zeigt betroffene Screens mit Rendering
- "Duplizieugen" — als neue Kampagne mit anderem Template
- "Loeschen" — wenn inaktiv und abgelaufen
### Neue Kampagne starten
**Workflow Option 1 — Von Template aus:**
Template-Liste → [Template] → "Aktivieren"
```
┌─────────────────────────────────────────┐
│ Kampagne starten: Weihnachtsmotiv 2025 │
├─────────────────────────────────────────┤
│ │
│ Kampagnen-Name │
│ [ Weihnachten 2025 einfuehrung____ ] │
│ │
│ Aktiv ab sofort? │
│ ⦿ Ja │
│ ○ Geplant fuer: [Datum/Zeit auswaehlen]│
│ [ 2025-12-01 ] [ 09:00 ] │
│ │
│ Gueltig von │
│ [ 2025-12-01 ] [ 00:00 ] │
│ │
│ Gueltig bis │
│ [ 2025-12-26 ] [ 23:59 ] │
│ │
│ Prioritaet (gegenueber Playlist) │
│ [1 (hoehere Werte sind wichtiger)] ___ │
│ │
│ Auto-Deaktivierung bei Ablauf? │
│ ⦿ Ja │
│ ○ Nein (Kampagne bleibt inaktiv) │
│ │
│ [Kampagne starten] [Abbrechen] │
└─────────────────────────────────────────┘
```
**Workflow Option 2 — Neue Kampagne ohne Template:**
Admin → Kampagnen → "+ Neue Kampagne"
```
[Template auswaehlen] → [Grunddaten] → [Aktivierung]
```
### Kampagnen-Detailseite
**Anzeige einer laufenden Kampagne:**
```
Kampagne: Weihnachten 2025 einfuehrung
Status: AKTIV seit 2025-12-01 09:00
Template: Weihnachtsmotiv 2025 (full_screen_media)
Zielgruppe: Alle (13 Screens)
Gueltig: 2025-12-01 00:00 bis 2025-12-26 23:59
Prioritaet: 1
Betroffene Screens:
┌──────────────────────────────┐
│ info01 online aktiv │ [Screenshot]
│ info02 online aktiv │ [Screenshot]
│ info03 offline ausstehend │
│ info04 online aktiv │ [Screenshot]
│ ... (10 weitere) ... │
└──────────────────────────────┘
Aktionen:
[Deaktivieren] [Bearbeiten] [Vorschau aendernx]
Aktivierungsverlauf:
2025-12-01 09:00 — Kampagne gestartet von admin@...
2025-12-01 09:05 — 9 Screens haben gerendert
2025-12-01 10:30 — info03 ging offline, Kampagnen-Inhalt wartet auf Rueckkehr
```
## 3. Verknuepfung zur Prioritaetsregel
Die Regel `campaign > tenant_playlist > fallback` ist:
- **hardcoded** im Player
- **administrierbar** ueber die Kampagnen-Aktivierung
- **vorhersagbar** durch klare Doku
### Abbildung im System
```
Fuer jeden Screen:
IF Kampagne fuer diesen Screen aktiv UND gueltig_von <= jetzt <= gueltig_bis
THEN Zeige Kampagnen-Inhalt
ELSE IF Tenant-Playlist hat gueltige Items
THEN Zeige Tenant-Playlist
ELSE
Zeige Fallback
```
Diese Logik wird:
1. **Serverseitig** berechnet bei jedem Sync-Request (HTTP `/api/v1/screens/{screenSlug}/playlist`)
2. **Spielerseitig** nochmals geprueft beim Rendering (fuer Offline-Robustheit)
### Admin-Sichtbarkeit
Die Admin-UI zeigt auf der Seite "Screens" fuer jeden Monitor:
```
info01
├── Kampagne (AKTIV bis 2025-12-26)
│ └── Weihnachten 2025 einfuehrung
├── Fallback (wird nach Kampagnen-Ablauf gezeigt)
└── Tenant Playlist
├── Playlist A (Tenant XYZ)
│ ├── Bild-1 (gueltig bis 2025-04-01)
│ ├── Video-2 (laedt...)
│ └── Webseite-3
└── Fallback-Verzeichnis
```
Diese View zeigt, was der Screen **aktuell gerade zeigt** und warum.
## 4. Datenmodell
### Tabelle `templates`
```sql
CREATE TABLE templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
template_type TEXT NOT NULL CHECK (template_type IN ('message_wall', 'full_screen_media', 'screen_specific_scene')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by_user_id TEXT NOT NULL,
-- Serializierte Konfiguration (JSON)
config JSONB NOT NULL DEFAULT '{}'
-- Beispiele:
-- {
-- "target_mode": "all_screens" | "group" | "specific_screens",
-- "target_group": "wall-all" (wenn target_mode = "group"),
-- "target_screen_ids": ["..."] (wenn target_mode = "specific_screens"),
-- "scenes": [
-- {
-- "media_type": "image|video|pdf|webpage|html",
-- "asset_id": "...",
-- "portrait_asset_id": "..." (optional),
-- "landscape_asset_id": "..." (optional),
-- "duration_sec": 10,
-- "load_timeout_sec": 10,
-- "valid_from": "2025-03-25T00:00:00Z",
-- "valid_until": "2025-04-01T23:59:59Z"
-- }
-- ]
-- }
);
```
### Tabelle `campaigns`
```sql
CREATE TABLE campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
template_id UUID NOT NULL REFERENCES templates(id),
active BOOLEAN NOT NULL DEFAULT false,
priority INT NOT NULL DEFAULT 1,
valid_from TIMESTAMPTZ NOT NULL,
valid_until TIMESTAMPTZ,
auto_deactivate BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by_user_id TEXT NOT NULL,
-- ueberschreiben/erweitern Template-Zielgruppe (optional)
target_mode TEXT CHECK (target_mode IN ('template', 'all_screens', 'group', 'specific_screens')),
target_group TEXT,
target_screen_ids UUID[] DEFAULT '{}'::uuid[]
);
```
### Tabelle `campaign_screen_assignments` (generiert)
Diese Tabelle wird **serverseitig** generiert/gepflegt, wenn eine Kampagne aktiv wird.
Sie expandiert Gruppen in konkrete Screen-IDs:
```sql
CREATE TABLE campaign_screen_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
screen_id UUID NOT NULL REFERENCES screens(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(campaign_id, screen_id)
);
```
**Logik:**
```
IF campaign.target_mode = 'template'
THEN Fuelle campaign_screen_assignments aus template.config.target_screen_ids
ELSE IF campaign.target_mode = 'group'
THEN Fuelle campaign_screen_assignments aus allen Screens in campaign.target_group
ELSE IF campaign.target_mode = 'specific_screens'
THEN Fuelle campaign_screen_assignments aus campaign.target_screen_ids
ELSE
(alle Screens)
```
## 5. Praxis-Beispiele
### Beispiel 1 — Weihnachtsplakatierung (full_screen_media)
**Szenario:**
Admin will ab 01.12.2025 fuer 4 Wochen ein rotes Weihnachtsmotiv auf allen Screens zeigen.
**Schritte:**
1. Admin → Templates → "+ Neues Template"
- Name: `Weihnachtsmotiv 2025`
- Typ: `full_screen_media`
- Zielgruppe: `Alle Screens`
2. Szene hinzufuegen:
- Bild hochladen (passend fuer Portrait und Landscape)
- Dauer: 10 Sekunden
3. Speichern → Editor zeigt Draft mit Vorschau
4. Admin → Templates → [Weihnachtsmotiv 2025] → "Aktivieren"
- Kampagnen-Name: `Weihnachten 2025 globale Dekoration`
- Gueltig von: 2025-12-01
- Gueltig bis: 2025-12-26
- Aktiv ab: sofort
5. Kampagne speichern → Sofort sichtbar auf allen Screens
### Beispiel 2 — Schriftzug ueber die Infowand (message_wall)
**Szenario:**
Admin hat eine neue `message_wall`-Gruppe "wall-all" mit 9 Screens. Er will ein riesiges rotes Schriftzug-Motiv aufteilen und auf allen 9 Screens verteilen.
**Schritte:**
1. Admin → Templates → "+ Neues Template"
- Name: `Rotes Schriftzug auf Infowand`
- Typ: `message_wall`
- Zielgruppe: `Gruppe: wall-all`
2. Layout waehlen: `3x3-Grid` (passt zu 9 Screens)
3. Gesamte Grafik hochladen (oder als Text eingeben)
4. Slot-Zuordnung:
- System zeigt interaktive 3x3-Vorschau
- Admin tuen: "Slot 1 → info01", "Slot 2 → info02", ...
- System generiert automatisch die Crop-Regionen
5. Speichern + Aktivieren
- Jeder Screen zeigt seinen Ausschnitt
### Beispiel 3 — Deaktivierung und Fallback
**Szenario:**
Kampagne laueft seit 2 Wochen. Admin will sie sofort stoppen, damit Screens auf ihre normalen Playlists zurueckfallen.
**Aktion:**
Admin → Kampagnen → [Kampagne] → "Deaktivieren"
**Folge:**
- Server setzt `campaigns.active = false`
- Bei naechstem Sync ladet jeder Player wieder die Tenant-Playlist
- Fallback-Verzeichnis wird nur noch angezeigt, wenn tenantbezogene Playlist leer ist
## 6. Zusammenfassung
Der Template-Editor:
- **ist zwei-stufig** — Template-Verwaltung + Kampagnen-Aktivierung
- **ist intuitiv** — Multi-Step-Formulare mit Vorschauen
- **unterstützt alle Template-Typen** — full_screen, message_wall, screen_specific
- **haelt die Prioritaetsregel transparent** — Admin sieht, welche Kampagne welche Screens uebersteuert
- **ist zukunftssicher** — Datenmodell skaliert mit neuen Template-Typen

View file

@ -119,45 +119,45 @@ Logout implementieren, alle Routen eintragen.
Ziel: Drei Middleware-Funktionen implementieren, Router umbauen sodass geschuetzte Routen Ziel: Drei Middleware-Funktionen implementieren, Router umbauen sodass geschuetzte Routen
hinter den Middlewares liegen, hardcoded `"morz"` an allen vier Stellen entfernen. hinter den Middlewares liegen, hardcoded `"morz"` an allen vier Stellen entfernen.
- [ ] **RequireAuth implementieren** in `server/backend/internal/httpapi/middleware.go` - [x] **RequireAuth implementieren** in `server/backend/internal/httpapi/middleware.go`
(neue Datei) Funktion `RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler`; (neue Datei) Funktion `RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler`;
liest Cookie `morz_session`, ruft `authStore.GetSessionUser` auf, liest Cookie `morz_session`, ruft `authStore.GetSessionUser` auf,
speichert `*store.User` im Context (eigener Key-Typ `contextKey`), speichert `*store.User` im Context (eigener Key-Typ `contextKey`),
redirectet bei Fehler zu `/login?next=<aktueller-Pfad>`. redirectet bei Fehler zu `/login?next=<aktueller-Pfad>`.
- [ ] **RequireAdmin implementieren** in `middleware.go` Funktion - [x] **RequireAdmin implementieren** in `middleware.go` Funktion
`RequireAdmin(next http.Handler) http.Handler`; liest User aus Context, `RequireAdmin(next http.Handler) http.Handler`; liest User aus Context,
prueft `user.Role == "admin"`, antwortet sonst mit 403. prueft `user.Role == "admin"`, antwortet sonst mit 403.
- [ ] **RequireTenantAccess implementieren** in `middleware.go` Funktion - [x] **RequireTenantAccess implementieren** in `middleware.go` Funktion
`RequireTenantAccess(next http.Handler) http.Handler`; liest User und `{tenantSlug}` aus `RequireTenantAccess(next http.Handler) http.Handler`; liest User und `{tenantSlug}` aus
Request-Path, erlaubt Zugriff wenn `user.Role == "admin"` oder `user.TenantSlug == tenantSlug` Request-Path, erlaubt Zugriff wenn `user.Role == "admin"` oder `user.TenantSlug == tenantSlug`
(dazu Feld `TenantSlug string` auf `store.User` erganzen, per JOIN in `GetSessionUser` befullen), (dazu Feld `TenantSlug string` auf `store.User` erganzen, per JOIN in `GetSessionUser` befullen),
antwortet sonst mit 403. antwortet sonst mit 403.
- [ ] **Router umbauen** in `router.go` die bisherige flache Route-Liste in Gruppen - [x] **Router umbauen** in `router.go` die bisherige flache Route-Liste in Gruppen
umstrukturieren: `/admin`-Routen hinter `RequireAuth` + `RequireAdmin` legen, umstrukturieren: `/admin`-Routen hinter `RequireAuth` + `RequireAdmin` legen,
`/manage/{screenSlug}`-Routen und kuenftige `/tenant/{tenantSlug}/...`-Routen hinter `/manage/{screenSlug}`-Routen und kuenftige `/tenant/{tenantSlug}/...`-Routen hinter
`RequireAuth` + `RequireTenantAccess` legen; Hilfsfunktion `chain(...Middleware)` nutzen `RequireAuth` + `RequireTenantAccess` legen; Hilfsfunktion `chain(...Middleware)` nutzen
oder inline wrappen. oder inline wrappen.
- [ ] **Hardcoded "morz" entfernen (Stelle 1)** in - [x] **Hardcoded "morz" entfernen (Stelle 1)** in
`server/backend/internal/httpapi/manage/ui.go` Zeile 93: `server/backend/internal/httpapi/manage/ui.go` Zeile 93:
`tenants.Get(r.Context(), "morz")` ersetzen durch Auslesen des authentifizierten Users aus `tenants.Get(r.Context(), "morz")` ersetzen durch Auslesen des authentifizierten Users aus
Context; `tenant_id` aus `user.TenantID` verwenden. Context; `tenant_id` aus `user.TenantID` verwenden.
- [ ] **Hardcoded "morz" entfernen (Stelle 2)** in `ui.go` Zeile 154: - [x] **Hardcoded "morz" entfernen (Stelle 2)** in `ui.go` Zeile 154:
gleiche Ersetzung fuer `HandleManageUI`. gleiche Ersetzung fuer `HandleManageUI`.
- [ ] **Hardcoded "morz" entfernen (Stelle 3)** in `ui.go` Zeile 197: - [x] **Hardcoded "morz" entfernen (Stelle 3)** in `ui.go` Zeile 197:
gleiche Ersetzung fuer `HandleProvisionUI`; SSH-User `"morz"` (Zeile 191) aus Config gleiche Ersetzung fuer `HandleProvisionUI`; SSH-User `"morz"` (Zeile 191) aus Config
lesen oder als optionalen Query-Parameter ermoeglichen. lesen oder als optionalen Query-Parameter ermoeglichen.
- [ ] **Hardcoded "morz" entfernen (Stelle 4)** in - [x] **Hardcoded "morz" entfernen (Stelle 4)** in
`server/backend/internal/httpapi/manage/register.go` Zeile 43: `server/backend/internal/httpapi/manage/register.go` Zeile 43:
`tenants.Get(r.Context(), "morz")` durch `cfg.DefaultTenantSlug` ersetzen. `tenants.Get(r.Context(), "morz")` durch `cfg.DefaultTenantSlug` ersetzen.
- [ ] **Doku** `docs/SERVER-KONZEPT.md` um Abschnitt "Middleware-Kette" erganzen: - [x] **Doku** `docs/SERVER-KONZEPT.md` um Abschnitt "Middleware-Kette" erganzen:
Schaubild der Route-Gruppen mit den jeweiligen Middlewares. Schaubild der Route-Gruppen mit den jeweiligen Middlewares.
--- ---
@ -167,52 +167,52 @@ hinter den Middlewares liegen, hardcoded `"morz"` an allen vier Stellen entferne
Ziel: Eigenes Package fuer Tenant-Handler, zweistufige Tab-Ansicht Ziel: Eigenes Package fuer Tenant-Handler, zweistufige Tab-Ansicht
(Screens mit Live-Status, Mediathek mit Upload), Navbar, Routing. (Screens mit Live-Status, Mediathek mit Upload), Navbar, Routing.
- [ ] **Package-Verzeichnis anlegen** neues Verzeichnis - [x] **Package-Verzeichnis anlegen** neues Verzeichnis
`server/backend/internal/httpapi/tenant/`; Dateien: `server/backend/internal/httpapi/tenant/`; Dateien:
`tenant.go` (Handler), `templates.go` (Template-Strings); gleiche Struktur wie Package `manage`. `tenant.go` (Handler), `templates.go` (Template-Strings); gleiche Struktur wie Package `manage`.
- [ ] **tenantDashTmpl definieren** in `tenant/templates.go` Bulma-Layout mit: - [x] **tenantDashTmpl definieren** in `tenant/templates.go` Bulma-Layout mit:
Navbar (Logo links, "Abmelden"-Button rechts als POST /logout), Navbar (Logo links, "Abmelden"-Button rechts als POST /logout),
zwei Tabs (`<div class="tabs">`) mit IDs `tab-screens` und `tab-media`, zwei Tabs (`<div class="tabs">`) mit IDs `tab-screens` und `tab-media`,
Tab A "Meine Monitore", Tab B "Mediathek"; JS-Snippet fuer Tab-Switching inline am Ende Tab A "Meine Monitore", Tab B "Mediathek"; JS-Snippet fuer Tab-Switching inline am Ende
des Templates (analog zu bestehenden inline-Scripts in `manage/templates.go`). des Templates (analog zu bestehenden inline-Scripts in `manage/templates.go`).
- [ ] **Tab A Screen-Karten implementieren** in `tenantDashTmpl` Tab A mit Bulma-Cards - [x] **Tab A Screen-Karten implementieren** in `tenantDashTmpl` Tab A mit Bulma-Cards
pro Screen: Titel (Screen.Name), Orientierungsicon, Status-Badge pro Screen: Titel (Screen.Name), Orientierungsicon, Status-Badge
(Online/Offline/Unbekannt) per JS-Fetch aus `/api/v1/screens/status`; (Online/Offline/Unbekannt) per JS-Fetch aus `/api/v1/screens/status`;
JS-Funktion `loadScreenStatuses()` alle 30 Sekunden aufrufen und Badge-Farbe setzen JS-Funktion `loadScreenStatuses()` alle 30 Sekunden aufrufen und Badge-Farbe setzen
(is-success / is-danger / is-warning). (is-success / is-danger / is-warning).
- [ ] **Tab B Mediathek mit Upload implementieren** in `tenantDashTmpl` Tab B: - [x] **Tab B Mediathek mit Upload implementieren** in `tenantDashTmpl` Tab B:
Upload-Formular (multipart, POST `/tenant/{tenantSlug}/upload`), Dateiliste als Bulma-Table Upload-Formular (multipart, POST `/tenant/{tenantSlug}/upload`), Dateiliste als Bulma-Table
(Titel, Typ, Groesse, Datum, Loeschen-Button mit Modal-Confirmation analog zu `manage/templates.go`); (Titel, Typ, Groesse, Datum, Loeschen-Button mit Modal-Confirmation analog zu `manage/templates.go`);
Upload-Fortschrittsbalken (bestehende JS-Logik aus `manageTmpl` wiederverwenden oder extrahieren). Upload-Fortschrittsbalken (bestehende JS-Logik aus `manageTmpl` wiederverwenden oder extrahieren).
- [ ] **HandleTenantDashboard implementieren** in `tenant/tenant.go` Funktion - [x] **HandleTenantDashboard implementieren** in `tenant/tenant.go` Funktion
`HandleTenantDashboard(tenantStore *store.TenantStore, screenStore *store.ScreenStore, `HandleTenantDashboard(tenantStore *store.TenantStore, screenStore *store.ScreenStore,
mediaStore *store.MediaStore, statusStore playerStatusStore) http.HandlerFunc`; mediaStore *store.MediaStore, statusStore playerStatusStore) http.HandlerFunc`;
liest `{tenantSlug}` aus URL, laedt Screens und Media-Assets, rendert `tenantDashTmpl`. liest `{tenantSlug}` aus URL, laedt Screens und Media-Assets, rendert `tenantDashTmpl`.
- [ ] **HandleTenantUpload implementieren** in `tenant/tenant.go` Funktion - [x] **HandleTenantUpload implementieren** in `tenant/tenant.go` Funktion
`HandleTenantUpload(tenantStore *store.TenantStore, mediaStore *store.MediaStore, `HandleTenantUpload(tenantStore *store.TenantStore, mediaStore *store.MediaStore,
uploadDir string) http.HandlerFunc`; identische Upload-Logik wie `manage.HandleUploadMediaUI`, uploadDir string) http.HandlerFunc`; identische Upload-Logik wie `manage.HandleUploadMediaUI`,
aber ohne Screen-Kontext (Media gehoert direkt dem Tenant); aber ohne Screen-Kontext (Media gehoert direkt dem Tenant);
nach Erfolg Redirect zu `/tenant/{tenantSlug}/dashboard?tab=media&flash=uploaded`. nach Erfolg Redirect zu `/tenant/{tenantSlug}/dashboard?tab=media&flash=uploaded`.
- [ ] **Navbar in Admin-UI erganzen** in `manage/templates.go` in `adminTmpl` und - [x] **Navbar in Admin-UI erganzen** in `manage/templates.go` in `adminTmpl` und
`manageTmpl` eine minimale Bulma-Navbar mit "Admin" (aktiv) und "Abmelden"-Button erganzen, `manageTmpl` eine minimale Bulma-Navbar mit "Admin" (aktiv) und "Abmelden"-Button erganzen,
sodass beide UIs optisch konsistent sind. sodass beide UIs optisch konsistent sind.
- [ ] **Responsive pruefen** `tenantDashTmpl` auf `is-mobile`-Breakpoint testen: - [x] **Responsive pruefen** `tenantDashTmpl` auf `is-mobile`-Breakpoint testen:
Screen-Karten sollen in `columns is-multiline` wrappen; Upload-Bereich soll auf schmalen Screen-Karten sollen in `columns is-multiline` wrappen; Upload-Bereich soll auf schmalen
Screens nutzbar bleiben. Screens nutzbar bleiben.
- [ ] **Routen eintragen** in `router.go` innerhalb `registerManageRoutes` hinter - [x] **Routen eintragen** in `router.go` innerhalb `registerManageRoutes` hinter
`RequireAuth` + `RequireTenantAccess`: `RequireAuth` + `RequireTenantAccess`:
`mux.HandleFunc("GET /tenant/{tenantSlug}/dashboard", tenant.HandleTenantDashboard(...))`, `mux.HandleFunc("GET /tenant/{tenantSlug}/dashboard", tenant.HandleTenantDashboard(...))`,
`mux.HandleFunc("POST /tenant/{tenantSlug}/upload", tenant.HandleTenantUpload(...))`. `mux.HandleFunc("POST /tenant/{tenantSlug}/upload", tenant.HandleTenantUpload(...))`.
- [ ] **Doku** `docs/SERVER-KONZEPT.md` neuen Abschnitt "Tenant-Dashboard" mit - [x] **Doku** `docs/SERVER-KONZEPT.md` neuen Abschnitt "Tenant-Dashboard" mit
URL-Schema, Tab-Beschreibung und Status-Polling-Intervall erganzen. URL-Schema, Tab-Beschreibung und Status-Polling-Intervall erganzen.
--- ---
@ -223,28 +223,28 @@ Ziel: Der "Zurueck"-Link in der Manage-UI soll kontextsensitiv sein
aus dem Admin-Bereich kommend zeigt er zur Admin-Uebersicht, aus dem Admin-Bereich kommend zeigt er zur Admin-Uebersicht,
aus dem Tenant-Dashboard kommend zurueck zum Dashboard. aus dem Tenant-Dashboard kommend zurueck zum Dashboard.
- [ ] **TemplateData um BackLink/BackLabel erweitern** in `manage/ui.go` - [x] **TemplateData um BackLink/BackLabel erweitern** in `manage/ui.go`
Struct `manageData` (oder gleichwertiges anonymes Struct) um Felder Struct `manageData` (oder gleichwertiges anonymes Struct) um Felder
`BackLink string` und `BackLabel string` erganzen. `BackLink string` und `BackLabel string` erganzen.
- [ ] **HandleManageUI: BackLink aus Query-Parameter lesen** in `HandleManageUI`: - [x] **HandleManageUI: BackLink aus Query-Parameter lesen** in `HandleManageUI`:
wenn `r.URL.Query().Get("from") == "tenant"`, dann wenn `r.URL.Query().Get("from") == "tenant"`, dann
`BackLink = "/tenant/{tenantSlug}/dashboard"` und `BackLabel = "← Dashboard"`; `BackLink = "/tenant/{tenantSlug}/dashboard"` und `BackLabel = "← Dashboard"`;
sonst `BackLink = "/admin"` und `BackLabel = "← Admin"`. sonst `BackLink = "/admin"` und `BackLabel = "← Admin"`.
- [ ] **manageTmpl: statisches "← Admin" ersetzen** in `manage/templates.go` - [x] **manageTmpl: statisches "← Admin" ersetzen** in `manage/templates.go`
den hardcoded Link `← Admin` durch `{{.BackLabel}}` mit `href="{{.BackLink}}"` ersetzen. den hardcoded Link `← Admin` durch `{{.BackLabel}}` mit `href="{{.BackLink}}"` ersetzen.
- [ ] **Tenant-Dashboard: Links zu Manage-UI mit ?from=tenant** in `tenant/templates.go` - [x] **Tenant-Dashboard: Links zu Manage-UI mit ?from=tenant** in `tenant/templates.go`
jeden "Playlist bearbeiten"-Link als `/manage/{screenSlug}?from=tenant` formulieren, jeden "Playlist bearbeiten"-Link als `/manage/{screenSlug}?from=tenant` formulieren,
damit der Ruecklink korrekt gesetzt wird. damit der Ruecklink korrekt gesetzt wird.
- [ ] **Breadcrumb-Navigation** optional, aber empfohlen: in `manageTmpl` oberhalb des - [x] **Breadcrumb-Navigation** optional, aber empfohlen: in `manageTmpl` oberhalb des
Hauptinhalts eine Bulma-Breadcrumb-Leiste einfuegen: Hauptinhalts eine Bulma-Breadcrumb-Leiste einfuegen:
Admin-Pfad: `Admin > {ScreenName}`, Tenant-Pfad: `Dashboard > {ScreenName}`; Admin-Pfad: `Admin > {ScreenName}`, Tenant-Pfad: `Dashboard > {ScreenName}`;
Daten aus `BackLabel`/`BackLink` und `Screen.Name` zusammensetzen. Daten aus `BackLabel`/`BackLink` und `Screen.Name` zusammensetzen.
- [ ] **Doku** Kommentar in `manage/ui.go` bei `HandleManageUI` dokumentiert - [x] **Doku** Kommentar in `manage/ui.go` bei `HandleManageUI` dokumentiert
den `?from=tenant`-Parameter und das BackLink-Verhalten. den `?from=tenant`-Parameter und das BackLink-Verhalten.
--- ---
@ -254,7 +254,7 @@ aus dem Tenant-Dashboard kommend zurueck zum Dashboard.
Ziel: Session-Cleanup als Hintergrundprozess, Secrets in Docker/Ansible, Ziel: Session-Cleanup als Hintergrundprozess, Secrets in Docker/Ansible,
Code-Review durch Larry, End-to-End-Test, Deployment, Nachziehen der Kerndokumentation. Code-Review durch Larry, End-to-End-Test, Deployment, Nachziehen der Kerndokumentation.
- [ ] **Session-Cleanup-Ticker implementieren** in `app.go` nach Server-Start einen - [x] **Session-Cleanup-Ticker implementieren** in `app.go` nach Server-Start einen
`time.NewTicker(1 * time.Hour)` starten (als Goroutine), der `authStore.CleanExpiredSessions` `time.NewTicker(1 * time.Hour)` starten (als Goroutine), der `authStore.CleanExpiredSessions`
aufruft; Ticker beim Shutdown stoppen (Context-Abbruch oder `defer ticker.Stop()`). aufruft; Ticker beim Shutdown stoppen (Context-Abbruch oder `defer ticker.Stop()`).
@ -281,12 +281,12 @@ Code-Review durch Larry, End-to-End-Test, Deployment, Nachziehen der Kerndokumen
`docker compose pull && docker compose up -d` auf dem Server ausfuehren, `docker compose pull && docker compose up -d` auf dem Server ausfuehren,
Migration 002_auth.sql wird automatisch eingespielt, Logs auf Fehler pruefen. Migration 002_auth.sql wird automatisch eingespielt, Logs auf Fehler pruefen.
- [ ] **TODO.md nachziehen** abgearbeitete Punkte in `TODO.md` abhaken: - [x] **TODO.md nachziehen** abgearbeitete Punkte in `TODO.md` abhaken:
"Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen" (Phase 4), "Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen" (Phase 4),
"Authentifizierungskonzept festlegen" (falls noch offen), "Authentifizierungskonzept festlegen" (falls noch offen),
"Mandantentrennung in den APIs absichern" (falls noch offen). "Mandantentrennung in den APIs absichern" (falls noch offen).
- [ ] **README / DEVELOPMENT nachziehen** `DEVELOPMENT.md` um Abschnitt - [x] **README / DEVELOPMENT nachziehen** `DEVELOPMENT.md` um Abschnitt
"Lokale Entwicklung mit Login" erganzen: Env-Variable `MORZ_INFOBOARD_ADMIN_PASSWORD=dev` "Lokale Entwicklung mit Login" erganzen: Env-Variable `MORZ_INFOBOARD_ADMIN_PASSWORD=dev`
und `MORZ_INFOBOARD_DEV_MODE=true` setzen, um ohne HTTPS-Cookie arbeiten zu koennen. und `MORZ_INFOBOARD_DEV_MODE=true` setzen, um ohne HTTPS-Cookie arbeiten zu koennen.

305
docs/WATCHDOG-KONZEPT.md Normal file
View file

@ -0,0 +1,305 @@
# Info-Board Neu - Watchdog-Konzept
## Ziel
Der Watchdog ueberwacht die kritischen Komponenten des Players und sorgt dafuer, dass der Display-Betrieb bei Abstuerzen oder Verhaengungen automatisch wiederhergestellt wird.
Die Ueberwachung erfolgt auf zwei Ebenen:
1. **Browser-Watchdog** — Ueberwachung von Chromium
2. **Agent-Watchdog** — Ueberwachung des Player-Agents
## Grundprinzipien
- Watchdogs sind extern und unabhaengig von den ueberwachten Prozessen
- Erkennung erfolgt aktiv durch Health-Checks, nicht durch Liveness-Pings
- Restart-Strategien sind progressiv und vermeiden Restart-Schleifen
- Logging ist strukturiert und fuer Admin-Diagnosen aussagekraeftig
## Browser-Watchdog (Chromium-Ueberwachung)
### Aufgaben
Der Browser-Watchdog sorgt dafuer, dass:
- Chromium staendig laeuft und antwortet
- der Renderer nicht in einer Endlosschleife haengt
- Rendering-Fehler nicht zu permanenten Schwarzbildern fuehren
- bei Chromium-Crash oder Verhaengung schnell neugestartet wird
### Health-Check-Verfahren
Der Watchdog fuehrt regelmaeßig folgende Checks durch:
#### 1. Prozess-Check
```
Existiert der Chromium-Prozess noch?
- lsof oder ps-Abfrage auf die PID
- Timeout: sofort bei fehlender PID
```
#### 2. HTTP-Health-Check auf localhost
```
GET http://localhost:8081/health
Timeout: 5 Sekunden
Erwartet: 200 OK und JSON-Antwort {status: "ok"}
```
Die `player-ui` muss einen einfachen `/health`-Endpunkt bereitstellen, der schnell antwortet, auch wenn die Playlist gerade verarbeitet wird.
#### 3. Rendering-Verifizierung (optional, Phase 2)
```
Screenshot-basiert erkennen, ob der Browser:
- Fehlerseite zeigt
- komplett schwarz ist (mehr als 95% schwarze Pixel)
- seit mehreren Minuten denselben Content zeigt, obwohl ein Wechsel erwartet wurde
```
Diese Methode ist fuer v1 optional, wird aber fuer spaetere Verhaengungserkennung eingeplant.
### Ueberwachungs-Intervall
- Health-Check alle **30 Sekunden**
- Bei Fehler: sofort Neustart pruefen (kein Warten auf naechsten Zyklus)
### Restart-Strategie
#### Strategie: Exponentieller Backoff mit Maximum
```
Fehlerfall:
Fehler 1: Sofort neustart (Wait 0s)
Fehler 2: Warte 2s, versuche Restart
Fehler 3: Warte 5s, versuche Restart
Fehler 4: Warte 10s, versuche Restart
Fehler 5+: Warte 30s, versuche Restart
Nach 10 aufeinanderfolgende Fehler ohne erfolgreicher Recovery:
- Alert an Admin (via Server-Status)
- Overlay auf "Error" setzen
- Watchdog-Loop verlangsamen auf 5 Min Intervall
```
#### Erfolg-Kriterium
Wenn der Health-Check 3x hintereinander erfolgreich ist:
- Backoff-Zaehler zuruecksetzen auf 0
- naechstes Fehler wieder mit sofort-Restart starten
### Logging
Jeder Watchdog-Ereignis wird protokolliert:
```json
{
"ts": "2025-03-23T14:22:15Z",
"component": "browser_watchdog",
"event": "restart",
"reason": "health_check_timeout",
"attempt": 2,
"next_retry_in_ms": 5000,
"details": {
"pid_before": 1234,
"pid_after": 1245,
"http_status_before": 0
}
}
```
Logging-Ziele:
- strukturiert auf stdout/stderr (JSON)
- lokal in `/var/log/signage/watchdog.log` mit Rotation
## Agent-Watchdog (systemd-Integration)
### Aufgaben
Der Agent-Watchdog (bzw. systemd-Unit) sorgt dafuer, dass:
- der Player-Agent staendig laeuft
- nach Crash oder gewolltem Stop schnell neugestartet wird
- Restart-Grenzen ein Verhaengungsloop verhindern
### systemd-Konfiguration
```ini
[Service]
Type=simple
ExecStart=/usr/local/bin/player-agent
Restart=always
RestartSec=5
StartLimitInterval=300
StartLimitBurst=10
StandardOutput=journal
StandardError=journal
```
**Bedeutung:**
- `Restart=always` — Neustart bei jedem Exit (unabhaengig vom Exit-Code)
- `RestartSec=5` — Warte 5 Sekunden vor Neustart
- `StartLimitInterval=300` — Zaehle Restarts in einem 300s-Fenster
- `StartLimitBurst=10` — Mehr als 10 Restarts in 300s fuehrt zu systemd-Stop
Wenn `StartLimitBurst` erreicht wird:
- systemd laesst den Service stehen
- Admin wird informiert (Status-API setzt `agent_watchdog_failed`)
- manueller Eingriff oder Admin-Kommando noetig
### Health-Check durch Agent selbst
Der Agent sollte intern:
- Broker-Verbindung regelmaeßig pruefen
- Server-Sync-Status tracken
- bei kritischen Innenfehlern nicht einfach weiterlaeufen
Wenn sich der Agent selbst als unheilbar beschaedigt sieht:
- strukturiert mit Exit-Code `1` beenden (systemd startet neu)
- nicht mit `exit(0)` haengend beenden
## Verhaeltnis zu systemd
### Architektur-Entscheidung
`systemd` uebernimmt die Prozess-Wiederbelebung fuer den Agent.
Der Browser-Watchdog ist ein **separater, von systemd unabhaengiger Prozess**, weil:
- Chromium staendiger Ueberwachung bedarf (Health-Checks im 30s-Rhythmus)
- ein Systemd-Watchdog-Timer zu unverzeihlich waere (nur on/off, nicht granular)
- der Browser-Watchdog auch die Systemd-Unit selbst monitoren kann (Defensive Architektur)
### Optional: systemd WatchdogSec
Fuer den Agent ist es sinnvoll, auch systemd's Watchdog-Timer zu nutzen:
```ini
[Service]
WatchdogSec=30
ExecStart=/usr/local/bin/player-agent
```
Der Agent muesste dann periodisch `systemd-notify --ready` senden.
Das ist **optional fuer v1**, wird aber fuer spaetere Robustheit eingeplant.
## Integration mit Player-Setup
### Verzeichnisstruktur
```
/usr/local/bin/
player-agent — Go-Binary
browser-watchdog — Go-Binary oder Shell-Script
/etc/systemd/system/
signage-agent.service
signage-browser-watchdog.service
/var/lib/signage/
watchdog-state.json — letzter Zustand, Backoff-Counter
/var/log/signage/
watchdog.log — strukturiertes Logging
```
### Startup-Reihenfolge
1. Basis-System bootet, X11 startet
2. `signage-agent.service` startet (systemd)
3. Agent startet, prueft Konfiguration, startet `player-ui` HTTP-Server
4. `signage-browser-watchdog.service` startet (systemd)
5. Watchdog wartet initial 10s, bevor erste Checks starten
6. Agent laesst Chromium starten
7. Watchdog beginnt Health-Checks
Dieses Ordering verhindert, dass der Watchdog versucht, den Browser zu uberwachen, bevor der Agent bereit ist.
### Stopp-Reihenfolge bei Shutdown
1. systemd sendet SIGTERM an Agent und Browser-Watchdog
2. Watchdog: beendet sich, versucht nicht zu restarten
3. Agent: beendet sich, laedt Chromium herunter
4. Systemd wartet auf Completion
## Fehlerklassifizierung und Admin-Reporting
### Fehlerklassen
| Fehlerklasse | Symptom | Watchdog-Aktion | Admin-Alert |
|---|---|---|---|
| Prozess-Crash | PID weg | Sofort neustart | Nach 3x Fehlschlag |
| Health-Check-Timeout | HTTP timeout | Backoff-Restart | Nach 5x Fehlschlag |
| Rendering-Fehler | Browser zeigt Fehlerseite | Neustart | Sofort sichtbar |
| Backoff-Maximum | 10+ Fehler in 5min | Stoppen, Alert | Sofort |
| Agent-Unhealthy | Server-Sync fehlgeschlagen | Systemd-Neustart | Nach 3x Sync-Fehler |
### Admin-Oberflaeche
Status-Page und Admin-Dashboard zeigen:
```json
{
"screen_id": "info01",
"browser_status": {
"pid": 1234,
"health": "ok",
"last_check_at": "2025-03-23T14:25:00Z",
"restart_count_5m": 0,
"last_error": null
},
"agent_status": {
"pid": 567,
"uptime_seconds": 3600,
"sync_status": "ok",
"last_sync_at": "2025-03-23T14:24:55Z",
"systemd_restart_count": 0
},
"watchdog_alert": null
}
```
## Konfigurierbare Parameter
In `/etc/signage/config.yml` oder Umgebungsvariablen:
```yaml
watchdog:
browser:
check_interval_sec: 30
health_check_timeout_sec: 5
restart_backoff_steps: [0, 2, 5, 10, 30] # Sekunden
max_consecutive_errors: 10
error_window_sec: 300
agent:
systemd_unit: "signage-agent.service"
healthcheck_timeout_sec: 10
```
## Testing und Validierung
Testfaelle fuer den Watchdog:
1. Chromium manuell toeten (`kill -9 PID`) — sollte innerhalb 30s neustartet werden
2. Player-Agent starten/stoppen — systemd sollte neustart triggern
3. Player-UI HTTP-Server abschalten — Browser-Watchdog sollte neustarten
4. Schnelle aufeinanderfolgende Crashes — Backoff-Exponentialfunktion pruefen
5. Admin-Kommando `restart_player` — geordneter Neustart, dann Restart-Counter nicht erhoeht
6. Watchdog-Logs auf Struktur und Vollstaendigkeit pruefen
## Zusammenfassung
Der Watchdog-Ansatz ist:
- **Transparent** — klare Logging und Admin-Sichtbarkeit
- **Progressive** — Backoff statt Restart-Schleife
- **Defensiv** — mehrere Erkennungsmethoden (Prozess, HTTP, optional Rendering)
- **Integriert** — arbeitet mit systemd zusammen, nicht gegen es
- **Skalierbar** — Verfahren gilt fuer alle Player unabhaengig von Standort oder Netzwerk

View file

@ -16,6 +16,7 @@ import (
"git.az-it.net/az/morz-infoboard/player/agent/internal/mqttheartbeat" "git.az-it.net/az/morz-infoboard/player/agent/internal/mqttheartbeat"
"git.az-it.net/az/morz-infoboard/player/agent/internal/mqttsubscriber" "git.az-it.net/az/morz-infoboard/player/agent/internal/mqttsubscriber"
"git.az-it.net/az/morz-infoboard/player/agent/internal/playerserver" "git.az-it.net/az/morz-infoboard/player/agent/internal/playerserver"
"git.az-it.net/az/morz-infoboard/player/agent/internal/screenshot"
"git.az-it.net/az/morz-infoboard/player/agent/internal/statusreporter" "git.az-it.net/az/morz-infoboard/player/agent/internal/statusreporter"
) )
@ -222,6 +223,14 @@ func (a *App) Run(ctx context.Context) error {
// Start polling the backend for playlist updates (60 s fallback + MQTT trigger). // Start polling the backend for playlist updates (60 s fallback + MQTT trigger).
go a.pollPlaylist(ctx) go a.pollPlaylist(ctx)
// Phase 6: Periodische Screenshot-Erzeugung, wenn konfiguriert.
if a.Config.ScreenshotEvery > 0 {
ss := screenshot.New(a.Config.ScreenID, a.Config.ServerBaseURL, a.Config.ScreenshotEvery, a.logger)
go ss.Run(ctx)
a.logger.Printf("event=screenshot_enabled screen_id=%s interval_seconds=%d",
a.Config.ScreenID, a.Config.ScreenshotEvery)
}
a.emitHeartbeat() a.emitHeartbeat()
a.mu.Lock() a.mu.Lock()
a.status = StatusRunning a.status = StatusRunning
@ -272,6 +281,10 @@ func (a *App) registerScreen(ctx context.Context) {
return return
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
// K6: Register-Secret mitsenden, wenn konfiguriert.
if a.Config.RegisterSecret != "" {
req.Header.Set("X-Register-Secret", a.Config.RegisterSecret)
}
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err == nil { if err == nil {

View file

@ -23,6 +23,13 @@ type Config struct {
PlayerListenAddr string `json:"player_listen_addr"` PlayerListenAddr string `json:"player_listen_addr"`
// PlayerContentURL is a fallback URL shown when no playlist is available from the server. // PlayerContentURL is a fallback URL shown when no playlist is available from the server.
PlayerContentURL string `json:"player_content_url"` PlayerContentURL string `json:"player_content_url"`
// RegisterSecret ist das Pre-Shared-Secret für POST /api/v1/screens/register (K6).
// Muss mit MORZ_INFOBOARD_REGISTER_SECRET auf dem Server übereinstimmen.
// Wenn leer, wird kein Header gesendet (kompatibel mit Servern ohne Secret).
RegisterSecret string `json:"register_secret"`
// ScreenshotEvery gibt das Intervall in Sekunden für periodische Screenshots an (Phase 6).
// 0 oder negativ = Screenshots deaktiviert.
ScreenshotEvery int `json:"screenshot_every_seconds"`
} }
const defaultConfigPath = "/etc/signage/config.json" const defaultConfigPath = "/etc/signage/config.json"
@ -90,6 +97,12 @@ func overrideFromEnv(cfg *Config) {
cfg.ScreenName = getenv("MORZ_INFOBOARD_SCREEN_NAME", cfg.ScreenName) cfg.ScreenName = getenv("MORZ_INFOBOARD_SCREEN_NAME", cfg.ScreenName)
cfg.ScreenOrientation = getenv("MORZ_INFOBOARD_SCREEN_ORIENTATION", cfg.ScreenOrientation) cfg.ScreenOrientation = getenv("MORZ_INFOBOARD_SCREEN_ORIENTATION", cfg.ScreenOrientation)
cfg.PlayerContentURL = getenv("MORZ_INFOBOARD_PLAYER_CONTENT_URL", cfg.PlayerContentURL) cfg.PlayerContentURL = getenv("MORZ_INFOBOARD_PLAYER_CONTENT_URL", cfg.PlayerContentURL)
cfg.RegisterSecret = getenv("MORZ_INFOBOARD_REGISTER_SECRET", cfg.RegisterSecret)
if value := getenv("MORZ_INFOBOARD_SCREENSHOT_EVERY", ""); value != "" {
var parsed int
_, _ = fmt.Sscanf(value, "%d", &parsed)
cfg.ScreenshotEvery = parsed
}
if value := getenv("MORZ_INFOBOARD_STATUS_REPORT_EVERY", ""); value != "" { if value := getenv("MORZ_INFOBOARD_STATUS_REPORT_EVERY", ""); value != "" {
var parsed int var parsed int
_, _ = fmt.Sscanf(value, "%d", &parsed) _, _ = fmt.Sscanf(value, "%d", &parsed)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -208,6 +208,13 @@ const playerHTML = `<!DOCTYPE html>
opacity: 0; opacity: 0;
transition: opacity 0.5s ease; transition: opacity 0.5s ease;
} }
/* PDF.js Canvas */
#pdf-canvas {
position: fixed; inset: 0;
width: 100%; height: 100%;
display: none; background: #000; z-index: 10;
}
#img-view { #img-view {
object-fit: contain; object-fit: contain;
background: #000; background: #000;
@ -252,22 +259,34 @@ const playerHTML = `<!DOCTYPE html>
<iframe id="frame" allow="autoplay; fullscreen" allowfullscreen></iframe> <iframe id="frame" allow="autoplay; fullscreen" allowfullscreen></iframe>
<img id="img-view" alt=""> <img id="img-view" alt="">
<video id="video-view" autoplay muted playsinline></video> <video id="video-view" autoplay muted playsinline></video>
<canvas id="pdf-canvas"></canvas>
<div id="frame-error"> <div id="frame-error">
<span class="error-title" id="frame-error-title"></span> <span class="error-title" id="frame-error-title"></span>
<span class="error-hint">Seite kann nicht eingebettet werden</span> <span class="error-hint">Seite kann nicht eingebettet werden</span>
</div> </div>
<div id="dot"></div> <div id="dot"></div>
<script src="/assets/pdf.min.js"></script>
<script> <script>
var splash = document.getElementById('splash'); var splash = document.getElementById('splash');
var overlay = document.getElementById('info-overlay'); var overlay = document.getElementById('info-overlay');
var frame = document.getElementById('frame'); var frame = document.getElementById('frame');
var imgView = document.getElementById('img-view'); var imgView = document.getElementById('img-view');
var videoView = document.getElementById('video-view'); var videoView = document.getElementById('video-view');
var pdfCanvas = document.getElementById('pdf-canvas');
var frameError = document.getElementById('frame-error'); var frameError = document.getElementById('frame-error');
var frameErrorTitle = document.getElementById('frame-error-title'); var frameErrorTitle = document.getElementById('frame-error-title');
var dot = document.getElementById('dot'); var dot = document.getElementById('dot');
// PDF.js Worker konfigurieren
if (typeof pdfjsLib !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = '/assets/pdf.worker.min.js';
}
// Aktuell laufende PDF-Render-Session; wird genutzt um veraltete Sessions
// abzubrechen wenn hideAllContent() aufgerufen wird.
var pdfSession = null;
// ── Splash-Orientierung ─────────────────────────────────────────── // ── Splash-Orientierung ───────────────────────────────────────────
function updateSplash() { function updateSplash() {
var portrait = window.innerHeight > window.innerWidth; var portrait = window.innerHeight > window.innerWidth;
@ -349,6 +368,10 @@ const playerHTML = `<!DOCTYPE html>
videoView.pause(); videoView.pause();
videoView.src = ''; videoView.src = '';
// Laufende PDF-Session abbrechen.
pdfSession = null;
pdfCanvas.style.display = 'none';
[frame, imgView, videoView].forEach(function(el) { [frame, imgView, videoView].forEach(function(el) {
if (el.style.display !== 'none') { if (el.style.display !== 'none') {
el.style.opacity = '0'; el.style.opacity = '0';
@ -433,29 +456,12 @@ const playerHTML = `<!DOCTYPE html>
rotateTimer = setTimeout(advanceOnce, ms); rotateTimer = setTimeout(advanceOnce, ms);
videoView.onended = advanceOnce; videoView.onended = advanceOnce;
} else if (type === 'pdf') {
showPdf(item);
} else { } else {
// type === 'web', 'pdf' oder unbekannt → iframe // type === 'web' oder unbekannt → iframe
if (type === 'pdf') { if (frame.src !== item.src) { frame.src = item.src; }
frame.src = (function pdfUrl(src) {
var defaults = {toolbar: '0', navpanes: '0', scrollbar: '0', view: 'Fit', page: '1'};
var hashIdx = src.indexOf('#');
var base = hashIdx >= 0 ? src.substring(0, hashIdx) : src;
var existing = hashIdx >= 0 ? src.substring(hashIdx + 1) : '';
var params = {};
existing.split('&').forEach(function(p) {
var kv = p.split('=');
if (kv[0]) params[kv[0]] = kv[1] || '';
});
for (var k in defaults) {
if (!(k in params)) params[k] = defaults[k];
}
var parts = [];
for (var k in params) parts.push(k + '=' + params[k]);
return base + '#' + parts.join('&');
})(item.src);
} else {
if (frame.src !== item.src) { frame.src = item.src; }
}
frame.style.display = 'block'; frame.style.display = 'block';
requestAnimationFrame(function() { requestAnimationFrame(function() {
requestAnimationFrame(function() { frame.style.opacity = '1'; }); requestAnimationFrame(function() { frame.style.opacity = '1'; });
@ -486,6 +492,83 @@ const playerHTML = `<!DOCTYPE html>
} }
} }
// ── PDF.js Seitendurchblättern ────────────────────────────────────
function showPdf(item) {
if (typeof pdfjsLib === 'undefined') {
// PDF.js nicht verfügbar → Fehler anzeigen
showFrameError(item);
return;
}
// Graceful-Fallback-Timeout: falls PDF nicht innerhalb von 8s lädt → Fehler
var loadTimeout = setTimeout(function() {
if (pdfSession === session) {
showFrameError(item);
}
}, 8000);
// Neue Session starten; alte wird durch pdfSession-Check invalidiert
var session = {};
pdfSession = session;
pdfCanvas.style.display = 'block';
pdfjsLib.getDocument(item.src).promise.then(function(pdf) {
clearTimeout(loadTimeout);
// Session bereits abgebrochen?
if (pdfSession !== session) { return; }
var numPages = pdf.numPages;
var secsPerPage = Math.max(2, Math.floor((item.duration_seconds || 20) / numPages));
var pageNum = 1;
function renderPage(n) {
if (pdfSession !== session) { return; } // Session abgebrochen
pdf.getPage(n).then(function(page) {
if (pdfSession !== session) { return; }
var baseViewport = page.getViewport({ scale: 1.0 });
var scale = window.innerWidth / baseViewport.width;
// Auch Höhe berücksichtigen damit die Seite vollständig sichtbar bleibt
var scaleH = window.innerHeight / baseViewport.height;
if (scaleH < scale) { scale = scaleH; }
var viewport = page.getViewport({ scale: scale });
pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
var ctx = pdfCanvas.getContext('2d');
page.render({ canvasContext: ctx, viewport: viewport }).promise.then(function() {
if (pdfSession !== session) { return; }
// Nach secsPerPage Sekunden zur nächsten Seite
rotateTimer = setTimeout(function() {
if (pdfSession !== session) { return; }
if (n < numPages) {
renderPage(n + 1);
} else {
// Alle Seiten gezeigt → normale Rotation fortsetzen
currentIdx = (currentIdx + 1) % items.length;
showItem(items[currentIdx]);
}
}, secsPerPage * 1000);
}).catch(function() {
if (pdfSession === session) { showFrameError(item); }
});
}).catch(function() {
if (pdfSession === session) { showFrameError(item); }
});
}
renderPage(pageNum);
}).catch(function() {
clearTimeout(loadTimeout);
if (pdfSession === session) { showFrameError(item); }
});
}
function showFrameError(item) { function showFrameError(item) {
hideAllContent(); hideAllContent();
overlay.style.display = 'none'; overlay.style.display = 'none';

View file

@ -0,0 +1,210 @@
// Package screenshot erzeugt periodisch Screenshots des aktuell angezeigten Inhalts
// und sendet sie an den Backend-Server (Phase 6).
//
// Strategie (in dieser Reihenfolge):
// 1. scrot -z -q 60 /tmp/morz-screenshot.jpg — leichtgewichtig, für X11
// 2. import -window root /tmp/morz-screenshot.png — ImageMagick, falls scrot fehlt
// 3. xwd -root -silent | convert xwd:- /tmp/morz-screenshot.jpg — Fallback
//
// Der Screenshot wird per HTTP MULTIPART POST an
// POST /api/v1/player/screenshot gesendet.
package screenshot
import (
"bytes"
"context"
"fmt"
"log"
"mime/multipart"
"net/http"
"os"
"os/exec"
"path/filepath"
"time"
)
const (
screenshotPath = "/tmp/morz-screenshot.jpg"
defaultInterval = 60 * time.Second
uploadTimeout = 15 * time.Second
screenshotQuality = "60" // JPEG quality (0-100)
)
// Screenshotter erzeugt periodisch Screenshots und sendet sie an den Server.
type Screenshotter struct {
screenID string
serverBaseURL string
interval time.Duration
logger *log.Logger
}
// New erzeugt einen neuen Screenshotter.
func New(screenID, serverBaseURL string, intervalSeconds int, logger *log.Logger) *Screenshotter {
interval := defaultInterval
if intervalSeconds > 0 {
interval = time.Duration(intervalSeconds) * time.Second
}
if logger == nil {
logger = log.New(os.Stdout, "screenshot ", log.LstdFlags|log.LUTC)
}
return &Screenshotter{
screenID: screenID,
serverBaseURL: serverBaseURL,
interval: interval,
logger: logger,
}
}
// Run startet die periodische Screenshot-Schleife und blockiert bis ctx abgebrochen wird.
func (s *Screenshotter) Run(ctx context.Context) {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
// Erster Screenshot nach kurzem Delay (damit Chromium hochgefahren ist).
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
}
s.takeAndSend(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.takeAndSend(ctx)
}
}
}
// takeAndSend erzeugt einen Screenshot und sendet ihn an den Server.
func (s *Screenshotter) takeAndSend(ctx context.Context) {
path, err := s.capture()
if err != nil {
s.logger.Printf("event=screenshot_capture_failed screen_id=%s err=%v", s.screenID, err)
return
}
defer os.Remove(path) //nolint:errcheck
if err := s.upload(ctx, path); err != nil {
s.logger.Printf("event=screenshot_upload_failed screen_id=%s err=%v", s.screenID, err)
return
}
s.logger.Printf("event=screenshot_sent screen_id=%s", s.screenID)
}
// capture erzeugt einen Screenshot mit dem ersten verfügbaren Tool.
func (s *Screenshotter) capture() (string, error) {
// Aufräumen falls eine alte Datei existiert.
os.Remove(screenshotPath) //nolint:errcheck
// Versuch 1: scrot (leichtgewichtig, für X11)
if path, err := tryScrot(); err == nil {
return path, nil
}
// Versuch 2: import (ImageMagick)
if path, err := tryImport(); err == nil {
return path, nil
}
// Versuch 3: xwd + convert
if path, err := tryXwd(); err == nil {
return path, nil
}
return "", fmt.Errorf("keine Screenshot-Tool verfügbar (scrot, import, xwd)")
}
func tryScrot() (string, error) {
cmd := exec.Command("scrot", "-z", "-q", screenshotQuality, screenshotPath)
if err := cmd.Run(); err != nil {
return "", err
}
return screenshotPath, nil
}
func tryImport() (string, error) {
// ImageMagick import: -window root macht einen Screenshot des gesamten X-Displays.
pngPath := "/tmp/morz-screenshot-tmp.png"
cmd := exec.Command("import", "-window", "root", pngPath)
if err := cmd.Run(); err != nil {
return "", err
}
// Zu JPEG konvertieren.
cmd = exec.Command("convert", pngPath, "-quality", screenshotQuality, screenshotPath)
defer os.Remove(pngPath) //nolint:errcheck
if err := cmd.Run(); err != nil {
return "", err
}
return screenshotPath, nil
}
func tryXwd() (string, error) {
xwdPath := "/tmp/morz-screenshot-tmp.xwd"
// xwd schreibt in Datei.
xwdCmd := exec.Command("xwd", "-root", "-silent", "-out", xwdPath)
if err := xwdCmd.Run(); err != nil {
return "", err
}
defer os.Remove(xwdPath) //nolint:errcheck
// convert xwd -> jpg.
cmd := exec.Command("convert", "xwd:"+xwdPath, "-quality", screenshotQuality, screenshotPath)
if err := cmd.Run(); err != nil {
return "", err
}
return screenshotPath, nil
}
// upload sendet den Screenshot per MULTIPART POST an den Server.
func (s *Screenshotter) upload(ctx context.Context, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read screenshot: %w", err)
}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("screen_id", s.screenID)
ext := filepath.Ext(path)
mimeType := "image/jpeg"
if ext == ".png" {
mimeType = "image/png"
}
fw, err := writer.CreateFormFile("screenshot", "screenshot"+ext)
if err != nil {
return fmt.Errorf("create form file: %w", err)
}
if _, err := fw.Write(data); err != nil {
return fmt.Errorf("write form file: %w", err)
}
_ = writer.WriteField("mime_type", mimeType)
writer.Close()
uploadCtx, cancel := context.WithTimeout(ctx, uploadTimeout)
defer cancel()
req, err := http.NewRequestWithContext(uploadCtx,
http.MethodPost,
s.serverBaseURL+"/api/v1/player/screenshot",
&body,
)
if err != nil {
return err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("server returned %d", resp.StatusCode)
}
return nil
}

View file

@ -1,26 +1,169 @@
# Backend # Backend
Dieses Verzeichnis enthaelt das erste Geruest fuer das zentrale Backend. Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System.
Ziel fuer die erste Ausbaustufe: ## Aufgaben
- HTTP-API in Go - HTTP-API und serverseitige HTML-UI (Bulma)
- Health-Endpunkt - PostgreSQL-Anbindung mit automatischen Migrationen
- saubere Projektstruktur fuer spaetere API-, Worker- und Datenbankmodule - Session-basierte Authentifizierung und rollenbasierte Zugriffskontrolle
- erste serverseitige Aufloesungslogik fuer `message_wall` - Medienverwaltung und Playlist-Management
- Player-Status-Ingest und Diagnose
- MQTT-Notifizierungen bei Playlist-Aenderungen
Geplante Unterstruktur: ## Unterstruktur
- `cmd/api/` fuer den API-Startpunkt - `cmd/api/` — Startpunkt des Backends
- `internal/app/` fuer App-Initialisierung - `internal/app/` — App-Initialisierung und Lifecycle
- `internal/campaigns/` fuer Kampagnen- und Template-Logik - `internal/config/` — Konfiguration via Umgebungsvariablen
- `internal/httpapi/` fuer HTTP-Routing und Handler - `internal/db/` — PostgreSQL-Anbindung und Migrations-Runner
- `internal/config/` fuer Konfiguration - `internal/store/` — Datenbankzugriff (TenantStore, ScreenStore, MediaStore, PlaylistStore, AuthStore)
- `internal/fileutil/` — Upload-Hilfsfunktionen (SaveUploadedFile mit Tenant-Isolation)
- `internal/httpapi/` — HTTP-Routing, Middleware und Handler
- `internal/httpapi/csrf.go` — Double-Submit-Cookie CSRF-Schutz
- `internal/httpapi/ratelimit.go` — Rate-Limiting fuer /login (Brute-Force-Schutz)
- `internal/httpapi/uploads.go` — Upload-Handler konsolidiert
- `internal/httpapi/manage/` — Admin-UI und Playlist-Management-UI
- `internal/httpapi/manage/csrf_helpers.go` — CSRF-Token Helpers fuer Templates
- `internal/httpapi/tenant/` — Tenant-Self-Service-Dashboard
- `internal/mqttnotifier/` — MQTT-Notifizierungen
- `internal/reqcontext/` — Context-Keys fuer authentifizierten User
Aktuell vorhanden: ## Datenbank-Stores
- `GET /healthz` ### AuthStore (`internal/store/auth.go`)
- `GET /api/v1`
- `GET /api/v1/meta` **Screen-User Management:**
- `POST /api/v1/tools/message-wall/resolve` als erste serverseitige Layout-Aufloesung fuer `message_wall` - `CreateScreenUser(ctx, tenantID, username, passwordHash)` — neuen Screen-User anlegen
- einheitliches API-Fehlerformat im HTTP-Layer - `ListScreenUsers(ctx, tenantID)` — alle Screen-User eines Tenants auflisten
- `DeleteUser(ctx, userID)` — User und alle zugeordneten Permissions loeschen
**Authentifizierung:**
- `GetUserByUsername(ctx, username)` — Nutzer per Username laden
- `CreateSession(ctx, userID, ttl)` — neue Session anlegen
- `GetSessionUser(ctx, sessionID)` — User zu gueltigem Session-Token laden
- `DeleteSession(ctx, sessionID)` — Session loeschen (Logout)
- `CleanExpiredSessions(ctx)` — abgelaufene Sessions bereinigen
- `EnsureAdminUser(ctx, tenantSlug, password)` — Admin-User beim Start anlegen
- `VerifyPassword(ctx, userID, password)` — Passwort gegen bcrypt-Hash pruefen
### ScreenStore (`internal/store/screen.go`)
**Screen-User Zugriffskontrolle:**
- `GetAccessibleScreens(ctx, userID)` — alle Screens, auf die der User Zugriff hat
- `HasUserScreenAccess(ctx, userID, screenID)` — prueft ob User auf Screen zugreifen darf (boolean)
- `AddUserToScreen(ctx, userID, screenID)` — User zu Screen hinzufuegen (Eintrag in `user_screen_permissions`)
- `RemoveUserFromScreen(ctx, userID, screenID)` — User von Screen entfernen
- `GetScreenUsers(ctx, screenID)` — alle User, die auf Screen Zugriff haben
## Aktuelle Endpunkte
### Oeffentlich (keine Auth)
| Methode | Pfad | Beschreibung |
|---------|-------------------------------------|---------------------------------------|
| GET | `/healthz` | Health-Check |
| GET | `/api/v1` | API-Entrypoint |
| GET | `/api/v1/meta` | Metainformationen |
| POST | `/api/v1/player/status` | Status-Ingest vom Player-Agent |
| GET | `/api/v1/screens/status` | Uebersicht aller Screen-Status |
| GET | `/api/v1/screens/{screenId}/status` | Einzelner Screen-Status |
| DELETE | `/api/v1/screens/{screenId}/status` | Screen-Status loeschen |
| GET | `/api/v1/screens/{screenId}/playlist` | Playlist fuer Player (kein Auth) |
| POST | `/api/v1/screens/register` | Agent-Selbstregistrierung |
| POST | `/api/v1/tools/message-wall/resolve`| Message-Wall-Aufloesungsendpunkt |
| GET | `/status` | HTML-Diagnoseseite |
| GET | `/status/{screenId}` | HTML-Detailseite Einzelscreen |
| GET | `/uploads/{filename}` | Hochgeladene Dateien abrufen |
| GET | `/static/bulma.min.css` | Statisches CSS |
| GET | `/static/Sortable.min.js` | Statisches JS |
| GET | `/login` | Login-Formular |
| POST | `/login` | Login verarbeiten |
| POST | `/logout` | Session beenden |
### Nur eingeloggte Benutzer (`RequireAuth`)
| Methode | Pfad | Beschreibung |
|---------|-------------------------------------------|---------------------------------------|
| GET | `/manage/{screenSlug}` | Playlist-Management-UI |
| POST | `/manage/{screenSlug}/upload` | Medium fuer Screen hochladen |
| POST | `/manage/{screenSlug}/items` | Item zur Playlist hinzufuegen |
| POST | `/manage/{screenSlug}/items/{itemId}` | Item aktualisieren |
| POST | `/manage/{screenSlug}/items/{itemId}/delete` | Item loeschen |
| POST | `/manage/{screenSlug}/reorder` | Items reordnen |
| POST | `/manage/{screenSlug}/media/{mediaId}/delete` | Medium loeschen |
| GET | `/api/v1/playlists/{screenId}` | Playlist mit Metadaten abrufen |
| POST | `/api/v1/playlists/{playlistId}/items` | Item zur Playlist hinzufuegen (API) |
| PATCH | `/api/v1/items/{itemId}` | Item aktualisieren (API) |
| DELETE | `/api/v1/items/{itemId}` | Item loeschen (API) |
| PUT | `/api/v1/playlists/{playlistId}/order` | Items reordnen (API) |
| PATCH | `/api/v1/playlists/{playlistId}/duration` | Standard-Dauer setzen (API) |
| DELETE | `/api/v1/media/{id}` | Medium loeschen (API) |
### Nur Admins (`RequireAuth` + `RequireAdmin`)
| Methode | Pfad | Beschreibung |
|---------|-------------------------------------------|---------------------------------------|
| GET | `/admin` | Admin-Uebersicht |
| POST | `/admin/screens/provision` | Provisionierungs-Job starten |
| POST | `/admin/screens` | Neuen Screen anlegen |
| POST | `/admin/screens/{screenId}/delete` | Screen loeschen |
| POST | `/admin/users` | Screen-User anlegen |
| POST | `/admin/users/{userID}/delete` | Screen-User loeschen |
| POST | `/admin/screens/{screenID}/users` | User zu Screen hinzufuegen |
| POST | `/admin/screens/{screenID}/users/{userID}/remove` | User von Screen entfernen |
### Tenant-scoped (`RequireAuth` + `RequireTenantAccess`)
| Methode | Pfad | Beschreibung |
|---------|---------------------------------------------------|---------------------------------|
| GET | `/tenant/{tenantSlug}/dashboard` | Tenant-Self-Service-Dashboard |
| POST | `/tenant/{tenantSlug}/upload` | Medium hochladen |
| POST | `/tenant/{tenantSlug}/media/{mediaId}/delete` | Medium loeschen |
| GET | `/api/v1/tenants/{tenantSlug}/screens` | Screens eines Tenants auflisten |
| POST | `/api/v1/tenants/{tenantSlug}/screens` | Screen anlegen |
| GET | `/api/v1/tenants/{tenantSlug}/media` | Medien eines Tenants auflisten |
| POST | `/api/v1/tenants/{tenantSlug}/media` | Medium hochladen (API) |
## Konfiguration
Alle Werte per Umgebungsvariable:
| Variable | Bedeutung | Standard |
|-----------------------------------|----------------------------------------------------------|---------------|
| `MORZ_INFOBOARD_HTTP_ADDR` | HTTP-Listen-Adresse | `:8080` |
| `DATABASE_URL` | PostgreSQL-Connection-String | — |
| `MORZ_INFOBOARD_UPLOAD_DIR` | Verzeichnis fuer hochgeladene Medien | `/tmp/morz-uploads` |
| `MORZ_INFOBOARD_STATUS_STORE_PATH`| Pfad zur JSON-Persistenz-Datei fuer Status-Store | leer (in-memory) |
| `MORZ_INFOBOARD_ADMIN_PASSWORD` | Passwort des initialen Admin-Users (leer = kein Anlegen) | leer |
| `MORZ_INFOBOARD_DEFAULT_TENANT` | Slug des Tenants, dem der Admin zugeordnet wird | `morz` |
| `MORZ_INFOBOARD_DEV_MODE` | `true` = Session-Cookie ohne Secure-Flag (nur lokal) | `false` |
| `MORZ_INFOBOARD_REGISTER_SECRET` | Pre-Shared-Secret fuer POST /api/v1/screens/register | leer |
| `MORZ_INFOBOARD_MQTT_BROKER` | MQTT-Broker-URL (leer = kein MQTT) | leer |
| `MORZ_INFOBOARD_MQTT_USERNAME` | MQTT-Benutzername | leer |
| `MORZ_INFOBOARD_MQTT_PASSWORD` | MQTT-Passwort | leer |
Detailliertere Beschreibung und lokale Startbeispiele: `DEVELOPMENT.md`.
## Middleware
### `RequireScreenAccess`
Middleware zur rollenbasierten Zugriffskontrolle auf Screen-Ressourcen.
**Verhalten:**
- Admins duerfen auf alle Screens zugreifen
- Screen-User duerfen nur auf Screens zugreifen, fuer die sie in `user_screen_permissions` eingetragen sind
- Tenant-User duerfen auf alle Screens ihres Tenants zugreifen
- Response: `403 Forbidden` wenn keine Berechtigung
**Verwendung:**
- `GET /api/v1/screens/{screenId}/playlist`
- `POST /manage/{screenSlug}/...`
- Alle privaten Screen-Endpunkte
## Migrationen
- `001_core.sql` — initiales Schema (Tenants, Screens, Playlists, Media, etc.)
- `002_auth.sql` — Auth-Tabellen (`users`, `sessions`)
- `003_user_screen_permissions.sql` — Screen-User Management (`user_screen_permissions`)

View file

@ -2,22 +2,31 @@ package main
import ( import (
"log" "log"
"log/slog"
"os" "os"
"git.az-it.net/az/morz-infoboard/server/backend/internal/app" "git.az-it.net/az/morz-infoboard/server/backend/internal/app"
) )
func main() { func main() {
logger := log.New(os.Stdout, "backend ", log.LstdFlags|log.LUTC) // V6: Strukturiertes JSON-Logging als Standard-Logger.
// Alle slog.Info/slog.Error-Aufrufe im Programm nutzen diesen Handler.
slogHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
slog.SetDefault(slog.New(slogHandler))
// Kompatibilitäts-Logger für Komponenten die noch *log.Logger erwarten.
stdLogger := log.New(os.Stdout, "backend ", log.LstdFlags|log.LUTC)
application, err := app.New() application, err := app.New()
if err != nil { if err != nil {
logger.Fatalf("init app: %v", err) stdLogger.Fatalf("init app: %v", err)
} }
logger.Printf("starting backend on %s", application.Config.HTTPAddress) slog.Info("backend starting", "addr", application.Config.HTTPAddress)
if err := application.Run(); err != nil { if err := application.Run(); err != nil {
logger.Fatalf("run backend: %v", err) stdLogger.Fatalf("run backend: %v", err)
} }
} }

View file

@ -6,8 +6,12 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"log" "log"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/signal"
"syscall"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config" "git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/db" "git.az-it.net/az/morz-infoboard/server/backend/internal/db"
@ -17,13 +21,17 @@ import (
) )
type App struct { type App struct {
Config config.Config Config config.Config
server *http.Server server *http.Server
notifier *mqttnotifier.Notifier notifier *mqttnotifier.Notifier
authStore *store.AuthStore
dbPool *db.Pool // V7: für db.Close() im Shutdown
logger *log.Logger
} }
func New() (*App, error) { func New() (*App, error) {
cfg := config.Load() cfg := config.Load()
// Kompatibilitäts-Logger für db.Connect (erwartet *log.Logger).
logger := log.New(os.Stdout, "backend ", log.LstdFlags|log.LUTC) logger := log.New(os.Stdout, "backend ", log.LstdFlags|log.LUTC)
// Ensure upload directory exists. // Ensure upload directory exists.
@ -60,19 +68,20 @@ func New() (*App, error) {
return nil, err return nil, err
} }
adminPassword = hex.EncodeToString(buf) adminPassword = hex.EncodeToString(buf)
logger.Printf("event=admin_password_generated password=%s", adminPassword) // V6: slog statt log.Printf — Passwort nie loggen (K5).
slog.Info("admin password generated", "event", "admin_password_generated", "password", "[gesetzt]")
} }
if err := authStore.EnsureAdminUser(context.Background(), cfg.DefaultTenantSlug, adminPassword); err != nil { if err := authStore.EnsureAdminUser(context.Background(), cfg.DefaultTenantSlug, adminPassword); err != nil {
logger.Printf("event=ensure_admin_user_failed err=%v", err) slog.Error("ensure admin user failed", "event", "ensure_admin_user_failed", "err", err)
// Non-fatal: server starts even if admin setup fails. // Non-fatal: server starts even if admin setup fails.
} }
// MQTT notifier (no-op when broker not configured). // MQTT notifier (no-op when broker not configured).
notifier := mqttnotifier.New(cfg.MQTTBroker, cfg.MQTTUsername, cfg.MQTTPassword) notifier := mqttnotifier.New(cfg.MQTTBroker, cfg.MQTTUsername, cfg.MQTTPassword)
if cfg.MQTTBroker != "" { if cfg.MQTTBroker != "" {
logger.Printf("event=mqtt_notifier_enabled broker=%s", cfg.MQTTBroker) slog.Info("mqtt notifier enabled", "event", "mqtt_notifier_enabled", "broker", cfg.MQTTBroker)
} else { } else {
logger.Printf("event=mqtt_notifier_disabled reason=no_broker_configured") slog.Info("mqtt notifier disabled", "event", "mqtt_notifier_disabled", "reason", "no_broker_configured")
} }
handler := httpapi.NewRouter(httpapi.RouterDeps{ handler := httpapi.NewRouter(httpapi.RouterDeps{
@ -89,14 +98,61 @@ func New() (*App, error) {
}) })
return &App{ return &App{
Config: cfg, Config: cfg,
server: &http.Server{Addr: cfg.HTTPAddress, Handler: handler}, server: &http.Server{Addr: cfg.HTTPAddress, Handler: handler},
notifier: notifier, notifier: notifier,
authStore: authStore,
dbPool: pool, // V7: Referenz für Shutdown
logger: logger,
}, nil }, nil
} }
func (a *App) Run() error { func (a *App) Run() error {
defer a.notifier.Close() defer a.notifier.Close()
// W2+V7: Graceful Shutdown mit Signal-Handling.
// Der Context wird bei SIGTERM/SIGINT abgebrochen, was den Shutdown einleitet.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Session-Cleanup: expired sessions werden stündlich aus der DB entfernt.
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := a.authStore.CleanExpiredSessions(ctx); err != nil {
slog.Error("session cleanup failed", "event", "session_cleanup_failed", "err", err)
} else {
slog.Info("session cleanup ok", "event", "session_cleanup_ok")
}
case <-ctx.Done():
return
}
}
}()
// W2: Signal-Handler für Graceful Shutdown.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
go func() {
sig := <-sigCh
slog.Info("shutdown signal received", "event", "shutdown_signal", "signal", sig.String())
cancel() // Session-Cleanup stoppen.
// HTTP-Server mit Timeout herunterfahren.
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
if err := a.server.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown error", "event", "shutdown_error", "err", err)
}
// V7: DB-Pool schließen.
a.dbPool.Close()
slog.Info("shutdown complete", "event", "shutdown_complete")
}()
err := a.server.ListenAndServe() err := a.server.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {
return nil return nil

View file

@ -15,6 +15,10 @@ type Config struct {
AdminPassword string // MORZ_INFOBOARD_ADMIN_PASSWORD AdminPassword string // MORZ_INFOBOARD_ADMIN_PASSWORD
DefaultTenantSlug string // MORZ_INFOBOARD_DEFAULT_TENANT (default: "morz") DefaultTenantSlug string // MORZ_INFOBOARD_DEFAULT_TENANT (default: "morz")
DevMode bool // MORZ_INFOBOARD_DEV_MODE — when true, session cookie works without HTTPS DevMode bool // MORZ_INFOBOARD_DEV_MODE — when true, session cookie works without HTTPS
// RegisterSecret schützt POST /api/v1/screens/register (K6).
// Wenn gesetzt, muss der Player den Header X-Register-Secret: <secret> senden.
// Wenn leer, ist der Endpoint für alle erreichbar (Rückwärtskompatibilität).
RegisterSecret string // MORZ_INFOBOARD_REGISTER_SECRET
} }
func Load() Config { func Load() Config {
@ -29,6 +33,7 @@ func Load() Config {
AdminPassword: os.Getenv("MORZ_INFOBOARD_ADMIN_PASSWORD"), AdminPassword: os.Getenv("MORZ_INFOBOARD_ADMIN_PASSWORD"),
DefaultTenantSlug: getenv("MORZ_INFOBOARD_DEFAULT_TENANT", "morz"), DefaultTenantSlug: getenv("MORZ_INFOBOARD_DEFAULT_TENANT", "morz"),
DevMode: os.Getenv("MORZ_INFOBOARD_DEV_MODE") == "true", DevMode: os.Getenv("MORZ_INFOBOARD_DEV_MODE") == "true",
RegisterSecret: os.Getenv("MORZ_INFOBOARD_REGISTER_SECRET"),
} }
} }

View file

@ -0,0 +1,22 @@
-- Migration 003: Screen-User-Berechtigungssystem
-- Fügt die Rolle 'screen_user' und die M:N Tabelle user_screen_permissions hinzu.
-- Neue Spalte 'role' in users (DEFAULT 'screen_user' für zukünftige Nutzer).
ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'screen_user';
-- Bestehende Admins auf 'admin' setzen (alle User im Standard-Tenant morz).
UPDATE users SET role = 'admin'
WHERE tenant_id = (SELECT id FROM tenants WHERE slug = 'morz')
AND role IS DISTINCT FROM 'admin';
-- M:N-Tabelle: welche User dürfen welche Screens verwalten.
CREATE TABLE IF NOT EXISTS user_screen_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
screen_id TEXT NOT NULL REFERENCES screens(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, screen_id)
);
CREATE INDEX IF NOT EXISTS idx_user_screen_perms_user ON user_screen_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_screen_perms_screen ON user_screen_permissions(screen_id);

View file

@ -0,0 +1,68 @@
// Package fileutil enthält gemeinsame Datei-Hilfsfunktionen für Upload-Handler (V1, N6).
package fileutil
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
// SaveUploadedFile speichert einen Datei-Stream in uploadDir/{tenantSlug}/ und
// gibt den relativen HTTP-Pfad (/uploads/{tenantSlug}/filename) sowie die
// Anzahl geschriebener Bytes zurück.
//
// V1: Gemeinsame Upload-Logik — ersetzt 3× duplizierte Implementierung.
// N6: Tenant-spezifisches Verzeichnis statt gemeinsamer Ablage.
func SaveUploadedFile(file io.Reader, originalFilename, title, uploadDir, tenantSlug string) (storagePath string, size int64, err error) {
safeSlug := sanitize(tenantSlug)
if safeSlug == "" {
safeSlug = "default"
}
tenantDir := filepath.Join(uploadDir, safeSlug)
if mkErr := os.MkdirAll(tenantDir, 0755); mkErr != nil {
return "", 0, fmt.Errorf("fileutil: mkdir %s: %w", tenantDir, mkErr)
}
ext := filepath.Ext(originalFilename)
safeTitle := sanitize(title)
if safeTitle == "" {
safeTitle = "file"
}
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), safeTitle, ext)
destPath := filepath.Join(tenantDir, filename)
dest, createErr := os.Create(destPath)
if createErr != nil {
return "", 0, fmt.Errorf("fileutil: create %s: %w", destPath, createErr)
}
defer dest.Close()
n, copyErr := io.Copy(dest, file)
if copyErr != nil {
os.Remove(destPath) //nolint:errcheck
return "", 0, fmt.Errorf("fileutil: write %s: %w", destPath, copyErr)
}
return "/uploads/" + safeSlug + "/" + filename, n, nil
}
// sanitize konvertiert einen String in einen sicheren Dateinamen-Bestandteil
// (nur a-z, A-Z, 0-9, -, _; maximal 40 Zeichen).
func sanitize(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
out := b.String()
if len(out) > 40 {
out = out[:40]
}
return out
}

View file

@ -0,0 +1,98 @@
package httpapi
// csrf.go — Double-Submit-Cookie CSRF-Schutz (K1) und neuteredFileSystem (N5).
//
// Jede sichere State-ändernde Anfrage (POST/PUT/PATCH/DELETE) muss:
// 1. Den Cookie „morz_csrf" enthalten.
// 2. Den gleichen Wert als Form-Feld „csrf_token" oder Header „X-CSRF-Token" mitsenden.
//
// Token-Erzeugung: beim Rendern der Login-/Manage-Seiten wird SetCSRFCookie aufgerufen.
// Token-Validierung: CSRFProtect-Middleware prüft, ob Cookie und Payload übereinstimmen.
//
// SameSite=Lax schützt bereits gegen die meisten CSRF-Angriffe aus anderen Domains,
// aber das Double-Submit-Pattern bietet zusätzlichen Schutz für Formulare die per GET
// auf anderen Seiten eingebettet werden könnten.
import (
"crypto/rand"
"encoding/hex"
"net/http"
)
const (
csrfCookieName = "morz_csrf"
csrfFieldName = "csrf_token"
csrfHeaderName = "X-CSRF-Token"
)
// GenerateCSRFToken erzeugt ein 32-Byte zufälliges Hex-Token.
func GenerateCSRFToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// SetCSRFCookie setzt (oder erneuert) den CSRF-Cookie im Response.
// Gibt das Token zurück, damit es in Template-Daten eingebettet werden kann.
func SetCSRFCookie(w http.ResponseWriter, r *http.Request, devMode bool) string {
// Existierendes Token wiederverwenden, wenn vorhanden.
if c, err := r.Cookie(csrfCookieName); err == nil && c.Value != "" {
return c.Value
}
token, err := GenerateCSRFToken()
if err != nil {
// Im Fehlerfall leeres Token — Handler müssen diesen Fall prüfen.
return ""
}
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: false, // JavaScript darf nicht lesen, aber das ist ein Cookie-read, kein DOM-access
Secure: !devMode,
SameSite: http.SameSiteLaxMode,
MaxAge: int((8 * 3600)), // 8h — entspricht sessionTTL
})
return token
}
// CSRFTokenFromRequest liest das CSRF-Token aus Form-Feld oder Header.
func CSRFTokenFromRequest(r *http.Request) string {
// Header hat Vorrang (API-Clients).
if h := r.Header.Get(csrfHeaderName); h != "" {
return h
}
// Form-Feld (HTML-Formulare).
return r.FormValue(csrfFieldName)
}
// CSRFProtect ist Middleware für POST/PUT/PATCH/DELETE-Requests.
// Sie prüft, ob das CSRF-Token im Request mit dem Cookie übereinstimmt.
// GET-/HEAD-/OPTIONS-Requests werden durchgelassen.
func CSRFProtect(devMode bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie(csrfCookieName)
if err != nil || cookie.Value == "" {
http.Error(w, "CSRF-Token fehlt (Cookie)", http.StatusForbidden)
return
}
token := CSRFTokenFromRequest(r)
if token == "" || token != cookie.Value {
http.Error(w, "Ungültiger CSRF-Token", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -8,43 +8,63 @@ import (
"time" "time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config" "git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store" "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
const ( // handleScreenUserRedirect looks up accessible screens for a screen_user and
sessionCookieName = "morz_session" // redirects to the first one. If none exist, it redirects to an error page.
sessionTTL = 8 * time.Hour func handleScreenUserRedirect(w http.ResponseWriter, r *http.Request, screenStore *store.ScreenStore, user *store.User) {
) screens, err := screenStore.GetAccessibleScreens(r.Context(), user.ID)
if err != nil || len(screens) == 0 {
http.Redirect(w, r, "/login?error=no_screens", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/manage/"+screens[0].Slug, http.StatusSeeOther)
}
const sessionTTL = 8 * time.Hour
// sessionCookieName ist ein Alias auf die zentrale Konstante (V5).
const sessionCookieName = reqcontext.SessionCookieName
// loginData is the template data for the login page. // loginData is the template data for the login page.
type loginData struct { type loginData struct {
Error string Error string
Next string Next string
CSRFToken string
} }
// HandleLoginUI renders the login form (GET /login). // HandleLoginUI renders the login form (GET /login).
// If a valid session cookie is already present, the user is redirected to /admin // If a valid session cookie is already present, the user is redirected based on role.
// (or the tenant dashboard once tenants are wired up in Phase 3). func HandleLoginUI(authStore *store.AuthStore, screenStore *store.ScreenStore, cfg config.Config) http.HandlerFunc {
func HandleLoginUI(authStore *store.AuthStore) http.HandlerFunc {
tmpl := template.Must(template.New("login").Parse(loginTmpl)) tmpl := template.Must(template.New("login").Parse(loginTmpl))
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Redirect if already logged in. // Redirect if already logged in.
if cookie, err := r.Cookie(sessionCookieName); err == nil { if cookie, err := r.Cookie(sessionCookieName); err == nil {
if u, err := authStore.GetSessionUser(r.Context(), cookie.Value); err == nil { if u, err := authStore.GetSessionUser(r.Context(), cookie.Value); err == nil {
if u.Role == "admin" { switch u.Role {
http.Redirect(w, r, "/admin", http.StatusSeeOther) case "admin":
} else if u.TenantSlug != "" {
http.Redirect(w, r, "/manage/"+u.TenantSlug, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/admin", http.StatusSeeOther) http.Redirect(w, r, "/admin", http.StatusSeeOther)
case "screen_user":
handleScreenUserRedirect(w, r, screenStore, u)
default:
if u.TenantSlug != "" {
http.Redirect(w, r, "/tenant/"+u.TenantSlug+"/dashboard", http.StatusSeeOther)
} else {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
} }
return return
} }
} }
// K1: CSRF-Token für das Login-Formular setzen/erneuern.
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
next := r.URL.Query().Get("next") next := r.URL.Query().Get("next")
data := loginData{Next: sanitizeNext(next)} data := loginData{Next: sanitizeNext(next), CSRFToken: csrfToken}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = tmpl.Execute(w, data) _ = tmpl.Execute(w, data)
} }
@ -53,16 +73,21 @@ func HandleLoginUI(authStore *store.AuthStore) http.HandlerFunc {
// HandleLoginPost handles form submission (POST /login). // HandleLoginPost handles form submission (POST /login).
// It validates credentials, creates a session, sets the session cookie and // It validates credentials, creates a session, sets the session cookie and
// redirects the user based on their role or the ?next= parameter. // redirects the user based on their role or the ?next= parameter.
func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc { func HandleLoginPost(authStore *store.AuthStore, screenStore *store.ScreenStore, cfg config.Config) http.HandlerFunc {
tmpl := template.Must(template.New("login").Parse(loginTmpl)) tmpl := template.Must(template.New("login").Parse(loginTmpl))
renderError := func(w http.ResponseWriter, next, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_ = tmpl.Execute(w, loginData{Error: msg, Next: next})
}
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// K1: CSRF-Cookie erneuern und Token für Fehler-Re-Rendering bereitstellen.
// Das Token muss auch bei Fehlerantworten im Hidden-Field stehen, damit
// der nächste Submit-Versuch den CSRF-Check besteht.
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
renderError := func(w http.ResponseWriter, next, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_ = tmpl.Execute(w, loginData{Error: msg, Next: next, CSRFToken: csrfToken})
}
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
renderError(w, "", "Ungültige Anfrage.") renderError(w, "", "Ungültige Anfrage.")
return return
@ -79,7 +104,12 @@ func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.Handler
user, err := authStore.GetUserByUsername(r.Context(), username) user, err := authStore.GetUserByUsername(r.Context(), username)
if err != nil { if err != nil {
// Constant-time failure — same message for unknown user and wrong password. // Mitigate user-enumeration timing leak: run a dummy bcrypt
// comparison so that unknown-user and wrong-password responses
// take approximately the same time. The dummy hash is a
// pre-computed bcrypt hash of "dummy" (cost 12).
const dummyHash = "$2a$12$44H3KPmJUDdgNss7JB7Qneg9GWEl2OgxWwSqVpXRaQdki8T3U9ED2"
_ = bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(password))
renderError(w, next, "Benutzername oder Passwort falsch.") renderError(w, next, "Benutzername oder Passwort falsch.")
return return
} }
@ -113,9 +143,11 @@ func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.Handler
switch user.Role { switch user.Role {
case "admin": case "admin":
http.Redirect(w, r, "/admin", http.StatusSeeOther) http.Redirect(w, r, "/admin", http.StatusSeeOther)
case "screen_user":
handleScreenUserRedirect(w, r, screenStore, user)
default: default:
if user.TenantSlug != "" { if user.TenantSlug != "" {
http.Redirect(w, r, "/manage/"+user.TenantSlug, http.StatusSeeOther) http.Redirect(w, r, "/tenant/"+user.TenantSlug+"/dashboard", http.StatusSeeOther)
} else { } else {
http.Redirect(w, r, "/admin", http.StatusSeeOther) http.Redirect(w, r, "/admin", http.StatusSeeOther)
} }
@ -124,19 +156,22 @@ func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.Handler
} }
// HandleLogoutPost deletes the session and clears the cookie (POST /logout). // HandleLogoutPost deletes the session and clears the cookie (POST /logout).
func HandleLogoutPost(authStore *store.AuthStore) http.HandlerFunc { func HandleLogoutPost(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil { if cookie, err := r.Cookie(sessionCookieName); err == nil {
_ = authStore.DeleteSession(r.Context(), cookie.Value) _ = authStore.DeleteSession(r.Context(), cookie.Value)
} }
// Expire the cookie immediately. // Expire the cookie immediately.
// Secure must match the flag used when the cookie was set so that
// browsers on HTTPS connections honour the expiry directive.
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Name: sessionCookieName,
Value: "", Value: "",
Path: "/", Path: "/",
MaxAge: -1, MaxAge: -1,
HttpOnly: true, HttpOnly: true,
Secure: !cfg.DevMode,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })

View file

@ -0,0 +1,44 @@
package manage
// csrf_helpers.go — Hilfsfunktionen für CSRF im manage-Package (K1).
//
// Das manage-Package darf httpapi nicht importieren (würde einen Import-Cycle erzeugen).
// Deshalb sind die minimalen CSRF-Hilfsfunktionen hier dupliziert.
// Die eigentliche CSRF-Middleware lebt in httpapi/csrf.go.
import (
"crypto/rand"
"encoding/hex"
"net/http"
)
const (
csrfCookieName = "morz_csrf"
// CSRFFieldName ist der Name des versteckten Form-Felds mit dem CSRF-Token.
// Wird in Templates als {{.CSRFToken}} eingebettet.
CSRFFieldName = "csrf_token"
)
// setCSRFCookie setzt (oder erneuert) den CSRF-Cookie und gibt das Token zurück.
// Wird von Handlern aufgerufen, die GET-Seiten mit Formularen rendern.
func setCSRFCookie(w http.ResponseWriter, r *http.Request, devMode bool) string {
// Existierendes Token wiederverwenden.
if c, err := r.Cookie(csrfCookieName); err == nil && c.Value != "" {
return c.Value
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return ""
}
token := hex.EncodeToString(buf)
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: false, // muss von JS nicht gelesen werden; Formulare nutzen das versteckte Feld
Secure: !devMode,
SameSite: http.SameSiteLaxMode,
MaxAge: 8 * 3600, // 8h
})
return token
}

View file

@ -2,14 +2,13 @@ package manage
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/fileutil"
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store" "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
) )
@ -46,6 +45,8 @@ func HandleUploadMedia(tenants *store.TenantStore, media *store.MediaStore, uplo
} }
tenantID := tenant.ID tenantID := tenant.ID
// W3: MaxBytesReader begrenzt den gesamten Request-Body auf maxUploadSize.
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil { if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "request too large or not multipart", http.StatusBadRequest) http.Error(w, "request too large or not multipart", http.StatusBadRequest)
return return
@ -90,31 +91,15 @@ func HandleUploadMedia(tenants *store.TenantStore, media *store.MediaStore, uplo
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)) title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
} }
// Generate unique storage path. // V1+N6: Gemeinsame Upload-Funktion, tenant-spezifisches Verzeichnis.
ext := filepath.Ext(header.Filename) storagePath, size, err := fileutil.SaveUploadedFile(file, header.Filename, title, uploadDir, r.PathValue("tenantSlug"))
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext)
destPath := filepath.Join(uploadDir, filename)
dest, err := os.Create(destPath)
if err != nil { if err != nil {
http.Error(w, "storage error", http.StatusInternalServerError) http.Error(w, "storage error", http.StatusInternalServerError)
return return
} }
defer dest.Close()
size, err := io.Copy(dest, file)
if err != nil {
os.Remove(destPath) //nolint:errcheck
http.Error(w, "write error", http.StatusInternalServerError)
return
}
// Storage path relative (served via /uploads/).
storagePath := "/uploads/" + filename
asset, err := media.Create(r.Context(), tenantID, title, assetType, storagePath, "", mimeType, size) asset, err := media.Create(r.Context(), tenantID, title, assetType, storagePath, "", mimeType, size)
if err != nil { if err != nil {
os.Remove(destPath) //nolint:errcheck
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }
@ -138,6 +123,17 @@ func HandleDeleteMedia(media *store.MediaStore, uploadDir string) http.HandlerFu
return return
} }
// K3: Tenant-Check — nur der eigene Tenant oder Admin darf löschen.
u := reqcontext.UserFromContext(r.Context())
if u == nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if u.Role != "admin" && u.TenantID != asset.TenantID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Delete physical file if it's a local upload. // Delete physical file if it's a local upload.
if asset.StoragePath != "" { if asset.StoragePath != "" {
filename := filepath.Base(asset.StoragePath) filename := filepath.Base(asset.StoragePath)

View file

@ -9,9 +9,28 @@ import (
"time" "time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier" "git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store" "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
) )
// requirePlaylistAccess prüft ob der eingeloggte User zur Playlist-Tenant gehört.
// Gibt true zurück wenn Zugriff erlaubt; schreibt 403 und gibt false zurück wenn nicht.
func requirePlaylistAccess(w http.ResponseWriter, r *http.Request, playlist *store.Playlist) bool {
u := reqcontext.UserFromContext(r.Context())
if u == nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return false
}
if u.Role == "admin" {
return true
}
if u.TenantID != playlist.TenantID {
http.Error(w, "Forbidden", http.StatusForbidden)
return false
}
return true
}
// HandleGetPlaylist returns the playlist and its items for a screen. // HandleGetPlaylist returns the playlist and its items for a screen.
func HandleGetPlaylist(screens *store.ScreenStore, playlists *store.PlaylistStore) http.HandlerFunc { func HandleGetPlaylist(screens *store.ScreenStore, playlists *store.PlaylistStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@ -48,6 +67,16 @@ func HandleAddItem(playlists *store.PlaylistStore, media *store.MediaStore, noti
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
playlistID := r.PathValue("playlistId") playlistID := r.PathValue("playlistId")
// K4: Tenant-Check.
playlist, err := playlists.Get(r.Context(), playlistID)
if err != nil {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
if !requirePlaylistAccess(w, r, playlist) {
return
}
var body struct { var body struct {
MediaAssetID string `json:"media_asset_id"` MediaAssetID string `json:"media_asset_id"`
Type string `json:"type"` Type string `json:"type"`
@ -114,6 +143,16 @@ func HandleUpdateItem(playlists *store.PlaylistStore, notifier *mqttnotifier.Not
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("itemId") id := r.PathValue("itemId")
// K4: Tenant-Check via Playlist des Items.
playlist, err := playlists.GetByItemID(r.Context(), id)
if err != nil {
http.Error(w, "item not found", http.StatusNotFound)
return
}
if !requirePlaylistAccess(w, r, playlist) {
return
}
var body struct { var body struct {
Title string `json:"title"` Title string `json:"title"`
DurationSeconds int `json:"duration_seconds"` DurationSeconds int `json:"duration_seconds"`
@ -155,6 +194,16 @@ func HandleDeleteItem(playlists *store.PlaylistStore, notifier *mqttnotifier.Not
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("itemId") id := r.PathValue("itemId")
// K4: Tenant-Check via Playlist des Items.
playlist, err := playlists.GetByItemID(r.Context(), id)
if err != nil {
http.Error(w, "item not found", http.StatusNotFound)
return
}
if !requirePlaylistAccess(w, r, playlist) {
return
}
// Resolve slug before delete (item won't exist after). // Resolve slug before delete (item won't exist after).
slug, _ := playlists.ScreenSlugByItemID(r.Context(), id) slug, _ := playlists.ScreenSlugByItemID(r.Context(), id)
@ -176,6 +225,16 @@ func HandleReorder(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifi
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
playlistID := r.PathValue("playlistId") playlistID := r.PathValue("playlistId")
// K4: Tenant-Check.
playlist, err := playlists.Get(r.Context(), playlistID)
if err != nil {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
if !requirePlaylistAccess(w, r, playlist) {
return
}
var ids []string var ids []string
if err := json.NewDecoder(r.Body).Decode(&ids); err != nil { if err := json.NewDecoder(r.Body).Decode(&ids); err != nil {
http.Error(w, "body must be JSON array of item IDs", http.StatusBadRequest) http.Error(w, "body must be JSON array of item IDs", http.StatusBadRequest)
@ -199,6 +258,17 @@ func HandleReorder(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifi
func HandleUpdatePlaylistDuration(playlists *store.PlaylistStore) http.HandlerFunc { func HandleUpdatePlaylistDuration(playlists *store.PlaylistStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("playlistId") id := r.PathValue("playlistId")
// K4: Tenant-Check.
playlist, err := playlists.Get(r.Context(), id)
if err != nil {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
if !requirePlaylistAccess(w, r, playlist) {
return
}
secs, err := strconv.Atoi(strings.TrimSpace(r.FormValue("default_duration_seconds"))) secs, err := strconv.Atoi(strings.TrimSpace(r.FormValue("default_duration_seconds")))
if err != nil || secs <= 0 { if err != nil || secs <= 0 {
http.Error(w, "invalid duration", http.StatusBadRequest) http.Error(w, "invalid duration", http.StatusBadRequest)
@ -294,7 +364,7 @@ func HandleCreateScreen(tenants *store.TenantStore, screens *store.ScreenStore)
screen, err := screens.Create(r.Context(), tenant.ID, body.Slug, body.Name, body.Orientation) screen, err := screens.Create(r.Context(), tenant.ID, body.Slug, body.Name, body.Orientation)
if err != nil { if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }

View file

@ -15,8 +15,20 @@ import (
// //
// POST /api/v1/screens/register // POST /api/v1/screens/register
// Body: {"slug":"info10","name":"Info10 Bildschirm","orientation":"landscape"} // Body: {"slug":"info10","name":"Info10 Bildschirm","orientation":"landscape"}
//
// K6: Wenn MORZ_INFOBOARD_REGISTER_SECRET gesetzt ist, muss der Aufrufer
// den Header X-Register-Secret: <secret> mitschicken. Ohne gültiges Secret
// antwortet der Endpoint mit 403 Forbidden.
func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore, cfg config.Config) http.HandlerFunc { func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// K6: Secret-Prüfung, wenn konfiguriert.
if cfg.RegisterSecret != "" {
if r.Header.Get("X-Register-Secret") != cfg.RegisterSecret {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
var body struct { var body struct {
Slug string `json:"slug"` Slug string `json:"slug"`
Name string `json:"name"` Name string `json:"name"`
@ -49,7 +61,7 @@ func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore
screen, err := screens.Upsert(r.Context(), tenant.ID, body.Slug, body.Name, body.Orientation) screen, err := screens.Upsert(r.Context(), tenant.ID, body.Slug, body.Name, body.Orientation)
if err != nil { if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }

View file

@ -33,6 +33,7 @@ const loginTmpl = `<!DOCTYPE html>
{{end}} {{end}}
<form method="POST" action="/login"> <form method="POST" action="/login">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{if .Next}} {{if .Next}}
<input type="hidden" name="next" value="{{.Next}}"> <input type="hidden" name="next" value="{{.Next}}">
{{end}} {{end}}
@ -260,7 +261,7 @@ const adminTmpl = `<!DOCTYPE html>
</div> </div>
</nav> </nav>
<!-- Lösch-Bestätigungs-Modal --> <!-- Lösch-Bestätigungs-Modal (Screens) -->
<div id="delete-modal" class="modal"> <div id="delete-modal" class="modal">
<div class="modal-background" onclick="closeDeleteModal()"></div> <div class="modal-background" onclick="closeDeleteModal()"></div>
<div class="modal-card"> <div class="modal-card">
@ -281,6 +282,43 @@ const adminTmpl = `<!DOCTYPE html>
</div> </div>
</div> </div>
<!-- Lösch-Bestätigungs-Modal (User) -->
<div id="delete-user-modal" class="modal">
<div class="modal-background" onclick="closeDeleteUserModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Benutzer löschen?</p>
<button class="delete" aria-label="Schließen" onclick="closeDeleteUserModal()"></button>
</header>
<section class="modal-card-body">
<p>Soll Benutzer <strong id="delete-user-modal-name"></strong> wirklich gelöscht werden?</p>
<p class="has-text-grey is-size-7 mt-2">Alle Screen-Zuordnungen werden ebenfalls entfernt.</p>
</section>
<footer class="modal-card-foot">
<form id="delete-user-modal-form" method="POST">
<button class="button is-danger" type="submit">Wirklich löschen</button>
</form>
<button class="button" onclick="closeDeleteUserModal()">Abbrechen</button>
</footer>
</div>
</div>
<!-- Screen-User-Verwaltungs-Modal -->
<div id="screen-users-modal" class="modal">
<div class="modal-background" onclick="closeScreenUsersModal()"></div>
<div class="modal-card" style="width:600px;max-width:95vw">
<header class="modal-card-head">
<p class="modal-card-title" id="screen-users-modal-title">Benutzer verwalten</p>
<button class="delete" aria-label="Schließen" onclick="closeScreenUsersModal()"></button>
</header>
<section class="modal-card-body" id="screen-users-modal-body">
</section>
<footer class="modal-card-foot">
<button class="button" onclick="closeScreenUsersModal()">Schließen</button>
</footer>
</div>
</div>
<script> <script>
(function() { (function() {
var burger = document.querySelector('.navbar-burger[data-target="adminNavbar"]'); var burger = document.querySelector('.navbar-burger[data-target="adminNavbar"]');
@ -301,8 +339,33 @@ function openDeleteModal(action, name) {
function closeDeleteModal() { function closeDeleteModal() {
document.getElementById('delete-modal').classList.remove('is-active'); document.getElementById('delete-modal').classList.remove('is-active');
} }
function openDeleteUserModal(action, name) {
document.getElementById('delete-user-modal-form').action = action;
document.getElementById('delete-user-modal-name').textContent = name;
document.getElementById('delete-user-modal').classList.add('is-active');
}
function closeDeleteUserModal() {
document.getElementById('delete-user-modal').classList.remove('is-active');
}
function openScreenUsersModal(screenId, screenName, html) {
document.getElementById('screen-users-modal-title').textContent = 'Benutzer: ' + screenName;
document.getElementById('screen-users-modal-body').innerHTML = html;
document.getElementById('screen-users-modal').classList.add('is-active');
// Re-inject CSRF tokens into newly added forms
injectCSRFNow();
}
function closeScreenUsersModal() {
document.getElementById('screen-users-modal').classList.remove('is-active');
}
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteModal(); if (e.key === 'Escape') {
closeDeleteModal();
closeDeleteUserModal();
closeScreenUsersModal();
}
}); });
</script> </script>
<script> <script>
@ -310,14 +373,22 @@ document.addEventListener('keydown', function(e) {
var msg = new URLSearchParams(window.location.search).get('msg'); var msg = new URLSearchParams(window.location.search).get('msg');
if (!msg) return; if (!msg) return;
var texts = { var texts = {
'uploaded': ' Medium erfolgreich hochgeladen.', 'uploaded': ' Medium erfolgreich hochgeladen.',
'deleted': ' Erfolgreich gelöscht.', 'deleted': ' Erfolgreich gelöscht.',
'saved': ' Änderungen gespeichert.', 'saved': ' Änderungen gespeichert.',
'added': ' Erfolgreich hinzugefügt.' 'added': ' Erfolgreich hinzugefügt.',
'user_added': ' Benutzer angelegt.',
'user_deleted': ' Benutzer gelöscht.',
'user_added_to_screen': ' Benutzer zum Screen hinzugefügt.',
'user_removed_from_screen': ' Benutzer vom Screen entfernt.',
'error_empty': ' Benutzername und Passwort erforderlich.',
'error_exists': ' Benutzername bereits vergeben.',
'error_db': ' Datenbankfehler.'
}; };
var isError = msg.startsWith('error_');
var text = texts[msg] || ' Aktion erfolgreich.'; var text = texts[msg] || ' Aktion erfolgreich.';
var n = document.createElement('div'); var n = document.createElement('div');
n.className = 'notification is-success'; n.className = 'notification ' + (isError ? 'is-warning' : 'is-success');
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)'; n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
n.innerHTML = '<button class="delete"></button>' + text; n.innerHTML = '<button class="delete"></button>' + text;
n.querySelector('.delete').addEventListener('click', function() { n.remove(); }); n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
@ -327,16 +398,45 @@ document.addEventListener('keydown', function(e) {
n.style.opacity = '0'; n.style.opacity = '0';
setTimeout(function() { n.remove(); }, 500); setTimeout(function() { n.remove(); }, 500);
}, 3000); }, 3000);
// Clean URL without reloading
var url = new URL(window.location.href); var url = new URL(window.location.href);
url.searchParams.delete('msg'); url.searchParams.delete('msg');
history.replaceState(null, '', url.toString()); history.replaceState(null, '', url.toString());
})(); })();
</script> </script>
<section class="section pt-0"> <section class="section pt-0">
<div class="container"> <div class="container">
<div class="box"> <!-- Tabs -->
<div class="tabs is-boxed mb-0">
<ul>
<li id="tab-screens" class="{{if eq .ActiveTab "screens"}}is-active{{end}}">
<a onclick="switchTab('screens')">Bildschirme</a>
</li>
<li id="tab-users" class="{{if eq .ActiveTab "users"}}is-active{{end}}">
<a onclick="switchTab('users')">Benutzer</a>
</li>
</ul>
</div>
<script>
function switchTab(name) {
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
document.querySelectorAll('.tabs li').forEach(function(li) { li.classList.remove('is-active'); });
document.getElementById('panel-' + name).style.display = '';
document.getElementById('tab-' + name).classList.add('is-active');
var url = new URL(window.location.href);
url.searchParams.set('tab', name);
history.replaceState(null, '', url.toString());
}
document.addEventListener('DOMContentLoaded', function() {
var active = '{{.ActiveTab}}';
switchTab(active || 'screens');
});
</script>
<!-- Panel: Bildschirme -->
<div id="panel-screens" class="tab-panel box" style="border-radius:0 4px 4px 4px">
<h2 class="title is-5">Bildschirme</h2> <h2 class="title is-5">Bildschirme</h2>
{{if .Screens}} {{if .Screens}}
<div style="overflow-x: auto"> <div style="overflow-x: auto">
@ -347,16 +447,27 @@ document.addEventListener('keydown', function(e) {
<th>Slug</th> <th>Slug</th>
<th>Format</th> <th>Format</th>
<th>Status</th> <th>Status</th>
<th>Benutzer</th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range .Screens}} {{range .Screens}}
{{$users := index $.ScreenUserMap .ID}}
<tr> <tr>
<td><strong>{{.Name}}</strong></td> <td><strong>{{.Name}}</strong></td>
<td><code>{{.Slug}}</code></td> <td><code>{{.Slug}}</code></td>
<td>{{orientationLabel .Orientation}}</td> <td>{{orientationLabel .Orientation}}</td>
<td id="status-{{.Slug}}"><span class="has-text-grey"></span></td> <td id="status-{{.Slug}}"><span class="has-text-grey"></span></td>
<td>
{{$screenID := .ID}}
{{$screenName := .Name}}
<button class="button is-small is-light"
type="button"
onclick="openScreenUsersModal('{{$screenID}}', {{$screenName | printf "%q"}}, buildScreenUsersHTML('{{$screenID}}', {{$screenName | printf "%q"}}))">
{{len $users}} Benutzer
</button>
</td>
<td> <td>
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a> <a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
&nbsp; &nbsp;
@ -373,9 +484,9 @@ document.addEventListener('keydown', function(e) {
{{else}} {{else}}
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p> <p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
{{end}} {{end}}
</div>
<div class="box"> <hr>
<h2 class="title is-5">Neuen Bildschirm einrichten</h2> <h2 class="title is-5">Neuen Bildschirm einrichten</h2>
<p class="mb-4 has-text-grey"> <p class="mb-4 has-text-grey">
Fülle die Angaben aus. Der Bildschirm wird im Backend angelegt und du erhältst Fülle die Angaben aus. Der Bildschirm wird im Backend angelegt und du erhältst
@ -434,12 +545,9 @@ document.addEventListener('keydown', function(e) {
</div> </div>
<button class="button is-primary" type="submit">Anlegen &amp; Anleitung generieren </button> <button class="button is-primary" type="submit">Anlegen &amp; Anleitung generieren </button>
</form> </form>
</div>
<div class="box"> <details class="mt-4">
<h2 class="title is-5">Bestehenden Screen manuell anlegen</h2> <summary class="has-text-grey" style="cursor:pointer">Bestehenden Screen manuell anlegen (nur DB-Eintrag, kein Deployment)</summary>
<details>
<summary class="has-text-grey" style="cursor:pointer">Nur DB-Eintrag, kein Deployment (aufklappen)</summary>
<form method="POST" action="/admin/screens" class="mt-4"> <form method="POST" action="/admin/screens" class="mt-4">
<div class="columns is-vcentered"> <div class="columns is-vcentered">
<div class="column is-3"> <div class="column is-3">
@ -481,11 +589,151 @@ document.addEventListener('keydown', function(e) {
</div> </div>
</form> </form>
</details> </details>
</div>
</div><!-- /panel-screens -->
<!-- Panel: Benutzer -->
<div id="panel-users" class="tab-panel box" style="border-radius:0 4px 4px 4px">
<h2 class="title is-5">Screen-Benutzer</h2>
<p class="has-text-grey mb-4">Screen-Benutzer können sich einloggen und nur ihre zugeordneten Bildschirme verwalten.</p>
{{if .ScreenUsers}}
<table class="table is-fullwidth is-hoverable is-striped mb-5">
<thead>
<tr>
<th>Benutzername</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{{range .ScreenUsers}}
<tr>
<td><strong>{{.Username}}</strong></td>
<td>{{.CreatedAt.Format "02.01.2006 15:04"}}</td>
<td>
<button class="button is-small is-danger is-outlined"
type="button"
onclick="openDeleteUserModal('/admin/users/{{.ID}}/delete', '{{.Username}}')">Löschen</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="has-text-grey mb-4">Noch keine Screen-Benutzer angelegt.</p>
{{end}}
<hr>
<h3 class="title is-6">Neuen Benutzer anlegen</h3>
<form method="POST" action="/admin/users">
<div class="columns is-vcentered">
<div class="column is-4">
<div class="field">
<label class="label">Benutzername</label>
<div class="control">
<input class="input" type="text" name="username" placeholder="z.B. alice" required
autocomplete="off">
</div>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">Passwort</label>
<div class="control">
<input class="input" type="password" name="password" placeholder="Passwort" required
autocomplete="new-password">
</div>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-primary" type="submit">Benutzer anlegen</button>
</div>
</div>
</div>
</form>
</div><!-- /panel-users -->
</div> </div>
</section> </section>
<!-- Eingebettete Screen-User-Daten für das Modal (als JSON-Strings) -->
<script>
var _screenUsers = {{.ScreenUsers | screenUsersJSON}};
var _screenUserMap = {{.ScreenUserMap | screenUserMapJSON}};
function buildScreenUsersHTML(screenId, screenName) {
var users = _screenUserMap[screenId] || [];
var allUsers = _screenUsers || [];
// Bereits zugeordnete User-IDs
var assignedIds = {};
users.forEach(function(u) { assignedIds[u.id] = true; });
// Tabelle der zugeordneten User
var html = '';
if (users.length > 0) {
html += '<table class="table is-fullwidth is-narrow mb-4"><thead><tr><th>Benutzer</th><th></th></tr></thead><tbody>';
users.forEach(function(u) {
html += '<tr><td>' + escHtml(u.username) + '</td>';
html += '<td><form method="POST" action="/admin/screens/' + escHtml(screenId) + '/users/' + escHtml(u.id) + '/remove" style="display:inline">';
html += '<button class="button is-small is-danger is-outlined" type="submit">Entfernen</button></form></td></tr>';
});
html += '</tbody></table>';
} else {
html += '<p class="has-text-grey mb-4">Noch keine Benutzer zugeordnet.</p>';
}
// Dropdown mit verfügbaren Usern
var available = allUsers.filter(function(u) { return !assignedIds[u.id]; });
if (available.length > 0) {
html += '<form method="POST" action="/admin/screens/' + escHtml(screenId) + '/users">';
html += '<div class="field has-addons">';
html += '<div class="control is-expanded"><div class="select is-fullwidth"><select name="user_id">';
available.forEach(function(u) {
html += '<option value="' + escHtml(u.id) + '">' + escHtml(u.username) + '</option>';
});
html += '</select></div></div>';
html += '<div class="control"><button class="button is-primary" type="submit">Hinzufügen</button></div>';
html += '</div></form>';
} else if (allUsers.length === 0) {
html += '<p class="has-text-grey is-size-7">Lege zuerst Benutzer im Tab "Benutzer" an.</p>';
} else {
html += '<p class="has-text-grey is-size-7">Alle Benutzer sind bereits zugeordnet.</p>';
}
return html;
}
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function injectCSRFNow() {
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
var token = getCookie('morz_csrf');
if (!token) return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (!f.querySelector('input[name="csrf_token"]')) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
f.appendChild(inp);
}
});
}
</script>
<script> <script>
(function() { (function() {
fetch('/api/v1/screens/status') fetch('/api/v1/screens/status')
@ -503,6 +751,31 @@ document.addEventListener('keydown', function(e) {
.catch(function() {}); .catch(function() {});
})(); })();
</script> </script>
<script>
// K1: CSRF Double-Submit — füge Token aus Cookie in alle POST-Formulare ein.
(function() {
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
function injectCSRF() {
var token = getCookie('morz_csrf');
if (!token) return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (!f.querySelector('input[name="csrf_token"]')) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
f.appendChild(inp);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectCSRF);
} else {
injectCSRF();
}
})();
</script>
</body> </body>
</html>` </html>`
@ -969,6 +1242,31 @@ function startUpload() {
xhr.send(formData); xhr.send(formData);
} }
</script> </script>
<script>
// K1: CSRF Double-Submit — füge Token aus Cookie in alle POST-Formulare ein.
(function() {
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
function injectCSRF() {
var token = getCookie('morz_csrf');
if (!token) return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (!f.querySelector('input[name="csrf_token"]')) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
f.appendChild(inp);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectCSRF);
} else {
injectCSRF();
}
})();
</script>
</body> </body>
</html>` </html>`

View file

@ -1,10 +1,10 @@
package manage package manage
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt"
"html/template" "html/template"
"io" "log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -12,12 +12,91 @@ import (
"strings" "strings"
"time" "time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/fileutil"
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier" "git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext" "git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store" "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
) )
// jsonSafe serializes v to a JSON string safe for inline use in a <script> block.
// It returns template.JS so the template engine does not HTML-escape it again.
func jsonSafe(v any) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("null")
}
return template.JS(b) //nolint:gosec
}
// renderTemplate rendert t mit data in einen Buffer und schreibt das Ergebnis erst
// dann in w, wenn kein Fehler aufgetreten ist. W7: Verhindert halb-gerendertes HTML.
func renderTemplate(w http.ResponseWriter, t *template.Template, data any) {
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
http.Error(w, "Interner Fehler (Template)", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w) //nolint:errcheck
}
// requireScreenAccess prüft, ob der eingeloggte User Zugriff auf den Screen hat.
// Admins dürfen alles. Tenant-User dürfen nur Screens ihres eigenen Tenants bearbeiten.
// Gibt true zurück wenn Zugriff erlaubt ist; schreibt 403 und gibt false zurück wenn nicht.
func requireScreenAccess(w http.ResponseWriter, r *http.Request, screen *store.Screen) bool {
u := reqcontext.UserFromContext(r.Context())
if u == nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return false
}
if u.Role == "admin" {
return true
}
// Tenant-User: Screen muss zum eigenen Tenant gehören.
// Wir vergleichen über TenantSlug→TenantID, aber der Screen hat TenantID.
// Da uns der Tenant-Slug des Users bekannt ist und wir keinen TenantStore
// hier haben, vergleichen wir TenantID des Screens mit dem user.TenantID-Feld.
// store.User hat TenantSlug aber nicht TenantID — deswegen muss der
// aufrufende Handler nach GetBySlug bereits die TenantID des Screens bekannt haben.
// Wir nutzen u.TenantSlug und vertrauen darauf dass der Screen bereits geladen ist.
// Den eigentlichen Vergleich machen wir via TenantID des Screens vs.
// dem TenantID-Feld im User (das über reqcontext gespeichert ist).
if u.TenantID != "" && u.TenantID != screen.TenantID {
http.Error(w, "Forbidden", http.StatusForbidden)
return false
}
return true
}
var tmplFuncs = template.FuncMap{ var tmplFuncs = template.FuncMap{
// screenUsersJSON serializes a []*store.User slice to JSON for inline JS.
"screenUsersJSON": func(users []*store.User) template.JS {
type entry struct {
ID string `json:"id"`
Username string `json:"username"`
}
out := make([]entry, 0, len(users))
for _, u := range users {
out = append(out, entry{ID: u.ID, Username: u.Username})
}
return jsonSafe(out)
},
// screenUserMapJSON serializes map[string][]*store.ScreenUserEntry to JSON.
"screenUserMapJSON": func(m map[string][]*store.ScreenUserEntry) template.JS {
type entry struct {
ID string `json:"id"`
Username string `json:"username"`
}
out := map[string][]entry{}
for screenID, users := range m {
entries := make([]entry, 0, len(users))
for _, u := range users {
entries = append(entries, entry{ID: u.ID, Username: u.Username})
}
out[screenID] = entries
}
return jsonSafe(out)
},
"typeIcon": func(t string) string { "typeIcon": func(t string) string {
switch t { switch t {
case "image": case "image":
@ -52,8 +131,8 @@ var tmplFuncs = template.FuncMap{
}, },
} }
// HandleAdminUI renders the admin overview page. // HandleAdminUI renders the admin overview page (screens + users tabs).
func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc { func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore, auth *store.AuthStore) http.HandlerFunc {
t := template.Must(template.New("admin").Funcs(tmplFuncs).Parse(adminTmpl)) t := template.Must(template.New("admin").Funcs(tmplFuncs).Parse(adminTmpl))
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
allScreens, err := screens.ListAll(r.Context()) allScreens, err := screens.ListAll(r.Context())
@ -66,14 +145,125 @@ func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore) http.
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t.Execute(w, map[string]any{ //nolint:errcheck // Default tenant slug for user management.
"Screens": allScreens, tenantSlug := "morz"
"Tenants": allTenants, if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
screenUsers, err := auth.ListScreenUsers(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// Build per-screen user lists for the modal.
screenUserMap := map[string][]*store.ScreenUserEntry{}
for _, sc := range allScreens {
users, err := screens.GetScreenUsers(r.Context(), sc.ID)
if err != nil {
continue
}
screenUserMap[sc.ID] = users
}
activeTab := r.URL.Query().Get("tab")
if activeTab == "" {
activeTab = "screens"
}
renderTemplate(w, t, map[string]any{
"Screens": allScreens,
"Tenants": allTenants,
"ScreenUsers": screenUsers,
"ScreenUserMap": screenUserMap,
"ActiveTab": activeTab,
}) })
} }
} }
// HandleCreateScreenUser creates a new screen_user for the default tenant.
func HandleCreateScreenUser(auth *store.AuthStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
if username == "" || password == "" {
http.Redirect(w, r, "/admin?tab=users&msg=error_empty", http.StatusSeeOther)
return
}
tenantSlug := "morz"
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
_, err := auth.CreateScreenUser(r.Context(), tenantSlug, username, password)
if err != nil {
slog.Error("create screen user failed", "event", "create_screen_user_failed",
"tenant_slug", tenantSlug, "username", username, "err", err)
http.Redirect(w, r, "/admin?tab=users&msg=error_exists", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin?tab=users&msg=user_added", http.StatusSeeOther)
}
}
// HandleDeleteScreenUser deletes a screen_user by ID.
func HandleDeleteScreenUser(auth *store.AuthStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userID")
if err := auth.DeleteUser(r.Context(), userID); err != nil {
slog.Error("delete screen user failed", "event", "delete_screen_user_failed",
"user_id", userID, "err", err)
http.Error(w, "Fehler beim Löschen", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin?tab=users&msg=user_deleted", http.StatusSeeOther)
}
}
// HandleAddUserToScreen grants a user access to a specific screen.
func HandleAddUserToScreen(screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenID := r.PathValue("screenID")
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
userID := strings.TrimSpace(r.FormValue("user_id"))
if userID == "" {
http.Redirect(w, r, "/admin?msg=error_empty", http.StatusSeeOther)
return
}
if err := screens.AddUserToScreen(r.Context(), userID, screenID); err != nil {
slog.Error("add user to screen failed", "event", "add_user_to_screen_failed",
"screen_id", screenID, "user_id", userID, "err", err)
http.Redirect(w, r, "/admin?msg=error_db", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin?screen="+screenID+"&msg=user_added_to_screen", http.StatusSeeOther)
}
}
// HandleRemoveUserFromScreen removes a user's access to a specific screen.
func HandleRemoveUserFromScreen(screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenID := r.PathValue("screenID")
userID := r.PathValue("userID")
if err := screens.RemoveUserFromScreen(r.Context(), userID, screenID); err != nil {
slog.Error("remove user from screen failed", "event", "remove_user_from_screen_failed",
"screen_id", screenID, "user_id", userID, "err", err)
http.Error(w, "db error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin?screen="+screenID+"&msg=user_removed_from_screen", http.StatusSeeOther)
}
}
// HandleManageUI renders the playlist management UI for a specific screen. // HandleManageUI renders the playlist management UI for a specific screen.
func HandleManageUI( func HandleManageUI(
tenants *store.TenantStore, tenants *store.TenantStore,
@ -91,6 +281,11 @@ func HandleManageUI(
return return
} }
// K2: Tenant-Isolation — nur eigener Tenant oder Admin.
if !requireScreenAccess(w, r, screen) {
return
}
var tenant *store.Tenant var tenant *store.Tenant
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" { if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenant, _ = tenants.Get(r.Context(), u.TenantSlug) tenant, _ = tenants.Get(r.Context(), u.TenantSlug)
@ -139,8 +334,7 @@ func HandleManageUI(
} }
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") renderTemplate(w, t, map[string]any{
t.Execute(w, map[string]any{ //nolint:errcheck
"Screen": screen, "Screen": screen,
"Tenant": tenant, "Tenant": tenant,
"Playlist": playlist, "Playlist": playlist,
@ -183,7 +377,7 @@ func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore
_, err = screens.Create(r.Context(), tenant.ID, slug, name, orientation) _, err = screens.Create(r.Context(), tenant.ID, slug, name, orientation)
if err != nil { if err != nil {
http.Error(w, "Fehler: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Interner Fehler", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/admin?msg=added", http.StatusSeeOther) http.Redirect(w, r, "/admin?msg=added", http.StatusSeeOther)
@ -230,12 +424,11 @@ func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) h
screen, err := screens.Upsert(r.Context(), tenant.ID, slug, name, orientation) screen, err := screens.Upsert(r.Context(), tenant.ID, slug, name, orientation)
if err != nil { if err != nil {
http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Interner Fehler", http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") renderTemplate(w, t, map[string]any{
t.Execute(w, map[string]any{ //nolint:errcheck
"Screen": screen, "Screen": screen,
"IP": ip, "IP": ip,
"SSHUser": sshUser, "SSHUser": sshUser,
@ -267,6 +460,14 @@ func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
return return
} }
// K2: Tenant-Isolation.
if !requireScreenAccess(w, r, screen) {
return
}
// W3: MaxBytesReader begrenzt Uploads auf maxUploadSize.
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil { if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "Upload zu groß oder ungültig", http.StatusBadRequest) http.Error(w, "Upload zu groß oder ungültig", http.StatusBadRequest)
return return
@ -275,6 +476,15 @@ func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
assetType := strings.TrimSpace(r.FormValue("type")) assetType := strings.TrimSpace(r.FormValue("type"))
title := strings.TrimSpace(r.FormValue("title")) title := strings.TrimSpace(r.FormValue("title"))
// Bestimme tenantSlug für N6 (tenant-spezifisches Upload-Verzeichnis).
tenantSlug := ""
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
if tenantSlug == "" {
tenantSlug = "default"
}
switch assetType { switch assetType {
case "web": case "web":
url := strings.TrimSpace(r.FormValue("url")) url := strings.TrimSpace(r.FormValue("url"))
@ -297,17 +507,12 @@ func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)) title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
} }
mimeType := header.Header.Get("Content-Type") mimeType := header.Header.Get("Content-Type")
ext := filepath.Ext(header.Filename) // V1+N6: Gemeinsame Upload-Funktion, tenant-spezifisches Verzeichnis.
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext) storagePath, size, ferr := fileutil.SaveUploadedFile(file, header.Filename, title, uploadDir, tenantSlug)
destPath := filepath.Join(uploadDir, filename)
dest, ferr := os.Create(destPath)
if ferr != nil { if ferr != nil {
http.Error(w, "Speicherfehler", http.StatusInternalServerError) http.Error(w, "Speicherfehler", http.StatusInternalServerError)
return return
} }
defer dest.Close()
size, _ := io.Copy(dest, file)
storagePath := "/uploads/" + filename
_, err = media.Create(r.Context(), screen.TenantID, title, assetType, storagePath, "", mimeType, size) _, err = media.Create(r.Context(), screen.TenantID, title, assetType, storagePath, "", mimeType, size)
default: default:
http.Error(w, "Unbekannter Typ", http.StatusBadRequest) http.Error(w, "Unbekannter Typ", http.StatusBadRequest)
@ -315,7 +520,7 @@ func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
} }
if err != nil { if err != nil {
http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError) http.Error(w, "DB-Fehler", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=uploaded", http.StatusSeeOther) http.Redirect(w, r, "/manage/"+screenSlug+"?msg=uploaded", http.StatusSeeOther)
@ -337,6 +542,11 @@ func HandleAddItemUI(playlists *store.PlaylistStore, media *store.MediaStore, sc
return return
} }
// K2: Tenant-Isolation.
if !requireScreenAccess(w, r, screen) {
return
}
playlist, err := playlists.GetOrCreateForScreen(r.Context(), screen.TenantID, screen.ID, screen.Name) playlist, err := playlists.GetOrCreateForScreen(r.Context(), screen.TenantID, screen.ID, screen.Name)
if err != nil { if err != nil {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
@ -388,10 +598,21 @@ func HandleAddItemUI(playlists *store.PlaylistStore, media *store.MediaStore, sc
} }
// HandleDeleteItemUI removes a playlist item and redirects back. // HandleDeleteItemUI removes a playlist item and redirects back.
func HandleDeleteItemUI(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifier) http.HandlerFunc { func HandleDeleteItemUI(playlists *store.PlaylistStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug") screenSlug := r.PathValue("screenSlug")
itemID := r.PathValue("itemId") itemID := r.PathValue("itemId")
// K2: Tenant-Isolation.
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return
}
if !requireScreenAccess(w, r, screen) {
return
}
if err := playlists.DeleteItem(r.Context(), itemID); err != nil { if err := playlists.DeleteItem(r.Context(), itemID); err != nil {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
@ -410,6 +631,10 @@ func HandleReorderUI(playlists *store.PlaylistStore, screens *store.ScreenStore,
http.Error(w, "screen nicht gefunden", http.StatusNotFound) http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return return
} }
// K2: Tenant-Isolation.
if !requireScreenAccess(w, r, screen) {
return
}
playlist, err := playlists.GetByScreen(r.Context(), screen.ID) playlist, err := playlists.GetByScreen(r.Context(), screen.ID)
if err != nil { if err != nil {
http.Error(w, "playlist nicht gefunden", http.StatusNotFound) http.Error(w, "playlist nicht gefunden", http.StatusNotFound)
@ -430,10 +655,21 @@ func HandleReorderUI(playlists *store.PlaylistStore, screens *store.ScreenStore,
} }
// HandleUpdateItemUI handles form PATCH/POST to update a single item. // HandleUpdateItemUI handles form PATCH/POST to update a single item.
func HandleUpdateItemUI(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifier) http.HandlerFunc { func HandleUpdateItemUI(playlists *store.PlaylistStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug") screenSlug := r.PathValue("screenSlug")
itemID := r.PathValue("itemId") itemID := r.PathValue("itemId")
// K2: Tenant-Isolation.
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return
}
if !requireScreenAccess(w, r, screen) {
return
}
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest) http.Error(w, "bad form", http.StatusBadRequest)
return return
@ -462,6 +698,16 @@ func HandleDeleteMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
screenSlug := r.PathValue("screenSlug") screenSlug := r.PathValue("screenSlug")
mediaID := r.PathValue("mediaId") mediaID := r.PathValue("mediaId")
// K2: Tenant-Isolation.
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return
}
if !requireScreenAccess(w, r, screen) {
return
}
asset, err := media.Get(r.Context(), mediaID) asset, err := media.Get(r.Context(), mediaID)
if err == nil && asset.StoragePath != "" { if err == nil && asset.StoragePath != "" {
os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck

View file

@ -23,7 +23,7 @@ func UserFromContext(ctx context.Context) *store.User {
func RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler { func RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("morz_session") cookie, err := r.Cookie(reqcontext.SessionCookieName)
if err != nil { if err != nil {
redirectToLogin(w, r) redirectToLogin(w, r)
return return
@ -72,7 +72,10 @@ func RequireTenantAccess(next http.Handler) http.Handler {
return return
} }
tenantSlug := r.PathValue("tenantSlug") tenantSlug := r.PathValue("tenantSlug")
if tenantSlug != "" && user.TenantSlug != tenantSlug { // An empty tenantSlug means the route was registered without a
// {tenantSlug} parameter — that is a configuration error. Deny
// access rather than silently granting it to every logged-in user.
if tenantSlug == "" || user.TenantSlug != tenantSlug {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
@ -80,6 +83,48 @@ func RequireTenantAccess(next http.Handler) http.Handler {
}) })
} }
// RequireScreenAccess returns middleware that enforces per-screen access control.
// Admins bypass the check. Screen-Users must have an explicit entry in
// user_screen_permissions for the screen identified by the {screenSlug} path
// value. The screenStore is used to look up the screen and check permissions.
// Must be chained after RequireAuth.
func RequireScreenAccess(screenStore *store.ScreenStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Admins always have access.
if user.Role == "admin" {
next.ServeHTTP(w, r)
return
}
screenSlug := r.PathValue("screenSlug")
if screenSlug == "" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
screen, err := screenStore.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "Screen nicht gefunden", http.StatusNotFound)
return
}
ok, err := screenStore.HasUserScreenAccess(r.Context(), user.ID, screen.ID)
if err != nil || !ok {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// chain applies a list of middleware to a handler, wrapping outermost first. // chain applies a list of middleware to a handler, wrapping outermost first.
// chain(m1, m2, m3)(h) == m1(m2(m3(h))) // chain(m1, m2, m3)(h) == m1(m2(m3(h)))
func chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler { func chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {

View file

@ -0,0 +1,91 @@
package httpapi
// ratelimit.go — Einfaches In-Memory-Rate-Limiting für POST /login (N1).
//
// Implementierung: Sliding-Window-Counter pro IP-Adresse.
// Erlaubt maximal loginMaxAttempts Versuche pro loginWindow.
// Ältere Einträge werden periodisch aus der Map bereinigt.
import (
"net"
"net/http"
"sync"
"time"
)
const (
loginMaxAttempts = 5
loginWindow = 1 * time.Minute
cleanupInterval = 5 * time.Minute
)
type loginAttempt struct {
count int
windowEnd time.Time
}
type loginRateLimiter struct {
mu sync.Mutex
entries map[string]*loginAttempt
}
func newLoginRateLimiter() *loginRateLimiter {
rl := &loginRateLimiter{
entries: make(map[string]*loginAttempt),
}
go rl.cleanup()
return rl
}
// Allow returns true if the IP is within the rate limit, false if it should be blocked.
func (rl *loginRateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
e, ok := rl.entries[ip]
if !ok || now.After(e.windowEnd) {
// Neues Fenster.
rl.entries[ip] = &loginAttempt{count: 1, windowEnd: now.Add(loginWindow)}
return true
}
e.count++
return e.count <= loginMaxAttempts
}
// cleanup bereinigt abgelaufene Einträge periodisch.
func (rl *loginRateLimiter) cleanup() {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
now := time.Now()
for ip, e := range rl.entries {
if now.After(e.windowEnd) {
delete(rl.entries, ip)
}
}
rl.mu.Unlock()
}
}
// LoginRateLimit ist eine globale Instanz des Rate-Limiters (package-level Singleton).
var LoginRateLimit = newLoginRateLimiter()
// RateLimitLogin ist Middleware, die Brute-Force-Angriffe auf den Login-Endpoint verhindert.
// Bei Überschreitung wird 429 Too Many Requests zurückgegeben.
func RateLimitLogin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// IP-Adresse extrahieren (berücksichtigt X-Forwarded-For nicht, um Spoofing zu vermeiden).
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
if !LoginRateLimit.Allow(ip) {
http.Error(w, "Zu viele Anmeldeversuche. Bitte warte eine Minute.", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}

View file

@ -85,32 +85,53 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
notifier = mqttnotifier.New("", "", "") notifier = mqttnotifier.New("", "", "")
} }
// Serve uploaded files. // Serve uploaded files. N5: Directory-Listing deaktiviert via neuteredFileSystem.
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir)))) mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(neuteredFileSystem{http.Dir(uploadDir)})))
// Serve embedded static assets (Bulma CSS, SortableJS) — no external CDN needed. // Serve embedded static assets (Bulma CSS, SortableJS) — no external CDN needed.
mux.HandleFunc("GET /static/bulma.min.css", manage.HandleStaticBulmaCSS()) mux.HandleFunc("GET /static/bulma.min.css", manage.HandleStaticBulmaCSS())
mux.HandleFunc("GET /static/Sortable.min.js", manage.HandleStaticSortableJS()) mux.HandleFunc("GET /static/Sortable.min.js", manage.HandleStaticSortableJS())
// K1: CSRF-Schutz für alle state-ändernden Routen.
csrf := CSRFProtect(d.Config.DevMode)
// K1: Setzt den CSRF-Cookie bei GET-Requests, damit das JS-Inject-Script ihn lesen kann.
setCSRF := func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
SetCSRFCookie(w, r, d.Config.DevMode)
}
h.ServeHTTP(w, r)
})
}
// ── Auth (no auth middleware required) ──────────────────────────────── // ── Auth (no auth middleware required) ────────────────────────────────
mux.HandleFunc("GET /login", manage.HandleLoginUI(d.AuthStore)) // K1: GET /login setzt CSRF-Cookie; POST /login und POST /logout werden per CSRF geprüft.
mux.HandleFunc("POST /login", manage.HandleLoginPost(d.AuthStore, d.Config)) mux.Handle("GET /login", http.HandlerFunc(manage.HandleLoginUI(d.AuthStore, d.ScreenStore, d.Config)))
mux.HandleFunc("POST /logout", manage.HandleLogoutPost(d.AuthStore)) // N1: Rate-Limiting auf /login (max. 5 Versuche/Minute pro IP).
mux.Handle("POST /login", RateLimitLogin(csrf(http.HandlerFunc(manage.HandleLoginPost(d.AuthStore, d.ScreenStore, d.Config)))))
mux.Handle("POST /logout", csrf(http.HandlerFunc(manage.HandleLogoutPost(d.AuthStore, d.Config))))
// Shorthand middleware combinators for this router. // Shorthand middleware combinators for this router.
// Für GET-Routen: setCSRF setzt den Cookie; für POST-Routen: csrf validiert ihn.
authOnly := func(h http.Handler) http.Handler { authOnly := func(h http.Handler) http.Handler {
return chain(h, RequireAuth(d.AuthStore)) return chain(h, RequireAuth(d.AuthStore), setCSRF, csrf)
} }
authAdmin := func(h http.Handler) http.Handler { authAdmin := func(h http.Handler) http.Handler {
return chain(h, RequireAuth(d.AuthStore), RequireAdmin) return chain(h, RequireAuth(d.AuthStore), RequireAdmin, setCSRF, csrf)
} }
authTenant := func(h http.Handler) http.Handler { authTenant := func(h http.Handler) http.Handler {
return chain(h, RequireAuth(d.AuthStore), RequireTenantAccess) return chain(h, RequireAuth(d.AuthStore), RequireTenantAccess, setCSRF, csrf)
}
// authScreen: wie authOnly, aber zusätzlich Screen-Zugriffsprüfung für screen_user.
// Admins und Tenant-User werden von RequireScreenAccess durchgelassen.
authScreen := func(h http.Handler) http.Handler {
return chain(h, RequireAuth(d.AuthStore), RequireScreenAccess(d.ScreenStore), setCSRF, csrf)
} }
// ── Admin UI ────────────────────────────────────────────────────────── // ── Admin UI ──────────────────────────────────────────────────────────
mux.Handle("GET /admin", mux.Handle("GET /admin",
authAdmin(http.HandlerFunc(manage.HandleAdminUI(d.TenantStore, d.ScreenStore)))) authAdmin(http.HandlerFunc(manage.HandleAdminUI(d.TenantStore, d.ScreenStore, d.AuthStore))))
mux.Handle("POST /admin/screens/provision", mux.Handle("POST /admin/screens/provision",
authAdmin(http.HandlerFunc(manage.HandleProvisionUI(d.TenantStore, d.ScreenStore)))) authAdmin(http.HandlerFunc(manage.HandleProvisionUI(d.TenantStore, d.ScreenStore))))
mux.Handle("POST /admin/screens", mux.Handle("POST /admin/screens",
@ -118,21 +139,32 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
mux.Handle("POST /admin/screens/{screenId}/delete", mux.Handle("POST /admin/screens/{screenId}/delete",
authAdmin(http.HandlerFunc(manage.HandleDeleteScreenUI(d.ScreenStore)))) authAdmin(http.HandlerFunc(manage.HandleDeleteScreenUI(d.ScreenStore))))
// ── Screen-User-Verwaltung (nur Admin) ────────────────────────────────
mux.Handle("POST /admin/users",
authAdmin(http.HandlerFunc(manage.HandleCreateScreenUser(d.AuthStore))))
mux.Handle("POST /admin/users/{userID}/delete",
authAdmin(http.HandlerFunc(manage.HandleDeleteScreenUser(d.AuthStore))))
mux.Handle("POST /admin/screens/{screenID}/users",
authAdmin(http.HandlerFunc(manage.HandleAddUserToScreen(d.ScreenStore))))
mux.Handle("POST /admin/screens/{screenID}/users/{userID}/remove",
authAdmin(http.HandlerFunc(manage.HandleRemoveUserFromScreen(d.ScreenStore))))
// ── Playlist management UI ──────────────────────────────────────────── // ── Playlist management UI ────────────────────────────────────────────
// authScreen enforces that screen_user only accesses their permitted screens.
mux.Handle("GET /manage/{screenSlug}", mux.Handle("GET /manage/{screenSlug}",
authOnly(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore)))) authScreen(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore))))
mux.Handle("POST /manage/{screenSlug}/upload", mux.Handle("POST /manage/{screenSlug}/upload",
authOnly(http.HandlerFunc(manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir)))) authScreen(http.HandlerFunc(manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))))
mux.Handle("POST /manage/{screenSlug}/items", mux.Handle("POST /manage/{screenSlug}/items",
authOnly(http.HandlerFunc(manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore, notifier)))) authScreen(http.HandlerFunc(manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/items/{itemId}", mux.Handle("POST /manage/{screenSlug}/items/{itemId}",
authOnly(http.HandlerFunc(manage.HandleUpdateItemUI(d.PlaylistStore, notifier)))) authScreen(http.HandlerFunc(manage.HandleUpdateItemUI(d.PlaylistStore, d.ScreenStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/items/{itemId}/delete", mux.Handle("POST /manage/{screenSlug}/items/{itemId}/delete",
authOnly(http.HandlerFunc(manage.HandleDeleteItemUI(d.PlaylistStore, notifier)))) authScreen(http.HandlerFunc(manage.HandleDeleteItemUI(d.PlaylistStore, d.ScreenStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/reorder", mux.Handle("POST /manage/{screenSlug}/reorder",
authOnly(http.HandlerFunc(manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore, notifier)))) authScreen(http.HandlerFunc(manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/media/{mediaId}/delete", mux.Handle("POST /manage/{screenSlug}/media/{mediaId}/delete",
authOnly(http.HandlerFunc(manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir, notifier)))) authScreen(http.HandlerFunc(manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir, notifier))))
// ── JSON API — screens ──────────────────────────────────────────────── // ── JSON API — screens ────────────────────────────────────────────────
// Self-registration: no auth (player calls this on startup). // Self-registration: no auth (player calls this on startup).

View file

@ -294,6 +294,31 @@ function toggleUploadFields() {
setInterval(pollStatus, 30000); setInterval(pollStatus, 30000);
})(); })();
</script> </script>
<script>
// K1: CSRF Double-Submit — füge Token aus Cookie in alle POST-Formulare ein.
(function() {
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
function injectCSRF() {
var token = getCookie('morz_csrf');
if (!token) return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (!f.querySelector('input[name="csrf_token"]')) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
f.appendChild(inp);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectCSRF);
} else {
injectCSRF();
}
})();
</script>
</body> </body>
</html>` </html>`

View file

@ -2,15 +2,15 @@
package tenant package tenant
import ( import (
"bytes"
"fmt" "fmt"
"html/template" "html/template"
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/fileutil"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store" "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
) )
@ -94,13 +94,19 @@ func HandleTenantDashboard(
} }
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") // W7: Template in Buffer rendern, erst bei Erfolg an Client senden.
t.Execute(w, map[string]any{ //nolint:errcheck var buf bytes.Buffer
if err := t.Execute(&buf, map[string]any{
"Tenant": tenant, "Tenant": tenant,
"Screens": screens, "Screens": screens,
"Assets": assets, "Assets": assets,
"Flash": flash, "Flash": flash,
}) }); err != nil {
http.Error(w, "Interner Fehler (Template)", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w) //nolint:errcheck
} }
} }
@ -120,6 +126,9 @@ func HandleTenantUpload(
return return
} }
// W3: MaxBytesReader begrenzt Uploads auf maxUploadSize bevor ParseMultipartForm.
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil { if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "Upload zu groß oder ungültig", http.StatusBadRequest) http.Error(w, "Upload zu groß oder ungültig", http.StatusBadRequest)
return return
@ -156,24 +165,12 @@ func HandleTenantUpload(
if detected := mimeToAssetType(mimeType); detected != "" { if detected := mimeToAssetType(mimeType); detected != "" {
assetType = detected assetType = detected
} }
ext := filepath.Ext(header.Filename) // V1+N6: tenant-spezifisches Upload-Verzeichnis.
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext) storagePath, size, cerr := fileutil.SaveUploadedFile(file, header.Filename, title, uploadDir, tenantSlug)
destPath := filepath.Join(uploadDir, filename) if cerr != nil {
dest, ferr := os.Create(destPath)
if ferr != nil {
http.Error(w, "Speicherfehler", http.StatusInternalServerError) http.Error(w, "Speicherfehler", http.StatusInternalServerError)
return return
} }
defer dest.Close()
size, cerr := io.Copy(dest, file)
if cerr != nil {
os.Remove(destPath) //nolint:errcheck
http.Error(w, "Schreibfehler", http.StatusInternalServerError)
return
}
storagePath := "/uploads/" + filename
_, err = mediaStore.Create(r.Context(), tenant.ID, title, assetType, storagePath, "", mimeType, size) _, err = mediaStore.Create(r.Context(), tenant.ID, title, assetType, storagePath, "", mimeType, size)
default: default:
@ -182,7 +179,7 @@ func HandleTenantUpload(
} }
if err != nil { if err != nil {
http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Interner Fehler", http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/tenant/"+tenantSlug+"/dashboard?tab=media&flash=uploaded", http.StatusSeeOther) http.Redirect(w, r, "/tenant/"+tenantSlug+"/dashboard?tab=media&flash=uploaded", http.StatusSeeOther)

View file

@ -0,0 +1,32 @@
package httpapi
// uploads.go — Hilfsmittel für sicheres Serving von Uploads (N5, N6).
import (
"net/http"
"os"
)
// neuteredFileSystem wraps an http.FileSystem and disables directory listing (N5).
// When Open() returns a directory, it returns an error as if the file was not found.
type neuteredFileSystem struct {
fs http.FileSystem
}
func (nfs neuteredFileSystem) Open(path string) (http.File, error) {
f, err := nfs.fs.Open(path)
if err != nil {
return nil, err
}
s, err := f.Stat()
if err != nil {
f.Close() //nolint:errcheck
return nil, err
}
if s.IsDir() {
// Return os.ErrNotExist so http.FileServer responds with 404.
f.Close() //nolint:errcheck
return nil, os.ErrNotExist
}
return f, nil
}

View file

@ -10,6 +10,11 @@ import (
"git.az-it.net/az/morz-infoboard/server/backend/internal/store" "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
) )
// SessionCookieName ist der HTTP-Cookie-Name für Sitzungen.
// Er wird in middleware.go (RequireAuth) und manage/auth.go (Login/Logout)
// verwendet und hier zentral definiert, um Duplizierung zu vermeiden.
const SessionCookieName = "morz_session"
type contextKey int type contextKey int
const contextKeyUser contextKey = 0 const contextKeyUser contextKey = 0

View file

@ -112,11 +112,17 @@ func (s *AuthStore) VerifyPassword(ctx context.Context, userID, password string)
// if no user with username 'admin' already exists. The password is hashed with bcrypt. // if no user with username 'admin' already exists. The password is hashed with bcrypt.
// bcrypt cost factor 12 is used (minimum recommended for production). // bcrypt cost factor 12 is used (minimum recommended for production).
func (s *AuthStore) EnsureAdminUser(ctx context.Context, tenantSlug, password string) error { func (s *AuthStore) EnsureAdminUser(ctx context.Context, tenantSlug, password string) error {
// Check whether 'admin' user already exists for this tenant. // Check whether 'admin' user already exists for this specific tenant.
// The check must be scoped to the tenant to avoid false positives when
// another tenant already has an 'admin' user.
var exists bool var exists bool
err := s.pool.QueryRow(ctx, err := s.pool.QueryRow(ctx,
`select exists(select 1 from users where username = $1)`, `select exists(
"admin", select 1 from users u
join tenants t on t.id = u.tenant_id
where u.username = $1 and t.slug = $2
)`,
"admin", tenantSlug,
).Scan(&exists) ).Scan(&exists)
if err != nil { if err != nil {
return fmt.Errorf("auth: check admin user: %w", err) return fmt.Errorf("auth: check admin user: %w", err)
@ -130,39 +136,97 @@ func (s *AuthStore) EnsureAdminUser(ctx context.Context, tenantSlug, password st
return fmt.Errorf("auth: hash password: %w", err) return fmt.Errorf("auth: hash password: %w", err)
} }
var tenantID string
err = s.pool.QueryRow(ctx, `select id from tenants where slug = $1`, tenantSlug).Scan(&tenantID)
if err != nil {
if err == pgx.ErrNoRows {
return fmt.Errorf("auth: tenant not found: %s", tenantSlug)
}
return fmt.Errorf("auth: resolve tenant: %w", err)
}
_, err = s.pool.Exec(ctx, _, err = s.pool.Exec(ctx,
`insert into users(tenant_id, username, password_hash, role) `insert into users(tenant_id, username, password_hash, role)
values( values($1, 'admin', $2, 'admin')`,
(select id from tenants where slug = $1), tenantID, string(hash))
'admin',
$2,
'admin'
)`,
tenantSlug, string(hash))
if err != nil { if err != nil {
return fmt.Errorf("auth: create admin user: %w", err) return fmt.Errorf("auth: create admin user: %w", err)
} }
return nil return nil
} }
// CreateScreenUser creates a new user with role 'screen_user' for the tenant
// identified by tenantSlug. The password is hashed with bcrypt (cost 12).
// Returns pgx.ErrNoRows if the tenant does not exist, or a wrapped error if
// the username is already taken (unique constraint violation).
func (s *AuthStore) CreateScreenUser(ctx context.Context, tenantSlug, username, password string) (*User, error) {
var tenantID string
err := s.pool.QueryRow(ctx, `select id from tenants where slug = $1`, tenantSlug).Scan(&tenantID)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("auth: tenant not found: %s", tenantSlug)
}
return nil, fmt.Errorf("auth: resolve tenant: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return nil, fmt.Errorf("auth: hash password: %w", err)
}
row := s.pool.QueryRow(ctx,
`insert into users(tenant_id, username, password_hash, role)
values($1, $2, $3, 'screen_user')
returning id, tenant_id, $4::text, username, password_hash, role, created_at`,
tenantID, username, string(hash), tenantSlug)
u, err := scanUserWithSlug(row)
if err != nil {
return nil, fmt.Errorf("auth: create screen user: %w", err)
}
return u, nil
}
// ListScreenUsers returns all users with role 'screen_user' for the given tenant.
func (s *AuthStore) ListScreenUsers(ctx context.Context, tenantSlug string) ([]*User, error) {
rows, err := s.pool.Query(ctx,
`select u.id, u.tenant_id, coalesce(t.slug, ''), u.username, u.password_hash, u.role, u.created_at
from users u
left join tenants t on t.id = u.tenant_id
where t.slug = $1 and u.role = 'screen_user'
order by u.username`, tenantSlug)
if err != nil {
return nil, fmt.Errorf("auth: list screen users: %w", err)
}
defer rows.Close()
var out []*User
for rows.Next() {
u, err := scanUserWithSlug(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// DeleteUser removes a user and all their session + screen permission records (CASCADE).
// It refuses to delete users with role 'admin' to prevent lockout.
func (s *AuthStore) DeleteUser(ctx context.Context, userID string) error {
tag, err := s.pool.Exec(ctx,
`delete from users where id = $1 and role != 'admin'`, userID)
if err != nil {
return fmt.Errorf("auth: delete user: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("auth: delete user: not found or is admin")
}
return nil
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// scan helpers // scan helpers
// ------------------------------------------------------------------ // ------------------------------------------------------------------
func scanUser(row interface {
Scan(dest ...any) error
}) (*User, error) {
var u User
err := row.Scan(&u.ID, &u.TenantID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, pgx.ErrNoRows
}
return nil, fmt.Errorf("scan user: %w", err)
}
return &u, nil
}
// scanUserWithSlug scans a row that includes tenant_slug as the third column. // scanUserWithSlug scans a row that includes tenant_slug as the third column.
func scanUserWithSlug(row interface { func scanUserWithSlug(row interface {
Scan(dest ...any) error Scan(dest ...any) error

View file

@ -53,6 +53,13 @@ type Playlist struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// ScreenUserEntry is a lightweight view used when listing users assigned to a screen.
type ScreenUserEntry struct {
ID string `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
}
type PlaylistItem struct { type PlaylistItem struct {
ID string `json:"id"` ID string `json:"id"`
PlaylistID string `json:"playlist_id"` PlaylistID string `json:"playlist_id"`
@ -203,6 +210,90 @@ func (s *ScreenStore) Delete(ctx context.Context, id string) error {
return err return err
} }
// GetAccessibleScreens returns all screens that userID has explicit access to
// via user_screen_permissions.
func (s *ScreenStore) GetAccessibleScreens(ctx context.Context, userID string) ([]*Screen, error) {
rows, err := s.pool.Query(ctx,
`select sc.id, sc.tenant_id, sc.slug, sc.name, sc.orientation, sc.created_at
from screens sc
join user_screen_permissions usp on usp.screen_id = sc.id
where usp.user_id = $1
order by sc.name`, userID)
if err != nil {
return nil, fmt.Errorf("screens: get accessible: %w", err)
}
defer rows.Close()
var out []*Screen
for rows.Next() {
sc, err := scanScreen(rows)
if err != nil {
return nil, err
}
out = append(out, sc)
}
return out, rows.Err()
}
// HasUserScreenAccess returns true when userID has an explicit permission entry
// for screenID in user_screen_permissions.
func (s *ScreenStore) HasUserScreenAccess(ctx context.Context, userID, screenID string) (bool, error) {
var ok bool
err := s.pool.QueryRow(ctx,
`select exists(
select 1 from user_screen_permissions
where user_id = $1 and screen_id = $2
)`, userID, screenID).Scan(&ok)
return ok, err
}
// AddUserToScreen creates a permission entry granting userID access to screenID.
// Silently succeeds if the entry already exists (ON CONFLICT DO NOTHING).
func (s *ScreenStore) AddUserToScreen(ctx context.Context, userID, screenID string) error {
_, err := s.pool.Exec(ctx,
`insert into user_screen_permissions(user_id, screen_id)
values($1, $2)
on conflict (user_id, screen_id) do nothing`,
userID, screenID)
if err != nil {
return fmt.Errorf("screens: add user to screen: %w", err)
}
return nil
}
// RemoveUserFromScreen deletes the permission entry for userID / screenID.
func (s *ScreenStore) RemoveUserFromScreen(ctx context.Context, userID, screenID string) error {
_, err := s.pool.Exec(ctx,
`delete from user_screen_permissions where user_id = $1 and screen_id = $2`,
userID, screenID)
if err != nil {
return fmt.Errorf("screens: remove user from screen: %w", err)
}
return nil
}
// GetScreenUsers returns all users that have explicit access to screenID.
func (s *ScreenStore) GetScreenUsers(ctx context.Context, screenID string) ([]*ScreenUserEntry, error) {
rows, err := s.pool.Query(ctx,
`select u.id, u.username, u.created_at
from users u
join user_screen_permissions usp on usp.user_id = u.id
where usp.screen_id = $1
order by u.username`, screenID)
if err != nil {
return nil, fmt.Errorf("screens: get screen users: %w", err)
}
defer rows.Close()
var out []*ScreenUserEntry
for rows.Next() {
var e ScreenUserEntry
if err := rows.Scan(&e.ID, &e.Username, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("scan screen user entry: %w", err)
}
out = append(out, &e)
}
return out, rows.Err()
}
func scanScreen(row interface { func scanScreen(row interface {
Scan(dest ...any) error Scan(dest ...any) error
}) (*Screen, error) { }) (*Screen, error) {
@ -305,6 +396,18 @@ func (s *PlaylistStore) GetByScreen(ctx context.Context, screenID string) (*Play
return scanPlaylist(row) return scanPlaylist(row)
} }
// GetByItemID returns the playlist that contains the given playlist item.
// Used for tenant-isolation checks (K4).
func (s *PlaylistStore) GetByItemID(ctx context.Context, itemID string) (*Playlist, error) {
row := s.pool.QueryRow(ctx,
`select pl.id, pl.tenant_id, pl.screen_id, pl.name, pl.is_active,
pl.default_duration_seconds, pl.created_at, pl.updated_at
from playlists pl
join playlist_items pi on pi.playlist_id = pl.id
where pi.id = $1`, itemID)
return scanPlaylist(row)
}
func (s *PlaylistStore) UpdateDefaultDuration(ctx context.Context, id string, seconds int) error { func (s *PlaylistStore) UpdateDefaultDuration(ctx context.Context, id string, seconds int) error {
_, err := s.pool.Exec(ctx, _, err := s.pool.Exec(ctx,
`update playlists set default_duration_seconds=$2, updated_at=now() where id=$1`, id, seconds) `update playlists set default_duration_seconds=$2, updated_at=now() where id=$1`, id, seconds)
@ -373,23 +476,20 @@ func (s *PlaylistStore) ListActiveItems(ctx context.Context, playlistID string)
} }
func (s *PlaylistStore) AddItem(ctx context.Context, playlistID, mediaAssetID, itemType, src, title string, durationSeconds int, validFrom, validUntil *time.Time) (*PlaylistItem, error) { func (s *PlaylistStore) AddItem(ctx context.Context, playlistID, mediaAssetID, itemType, src, title string, durationSeconds int, validFrom, validUntil *time.Time) (*PlaylistItem, error) {
// Place at end of list.
var maxIdx int
s.pool.QueryRow(ctx,
`select coalesce(max(order_index)+1, 0) from playlist_items where playlist_id=$1`, playlistID,
).Scan(&maxIdx) //nolint:errcheck
var mediaID *string var mediaID *string
if mediaAssetID != "" { if mediaAssetID != "" {
mediaID = &mediaAssetID mediaID = &mediaAssetID
} }
// W1: Atomare Subquery statt 2 separater Queries — verhindert Race Condition bei order_index.
row := s.pool.QueryRow(ctx, row := s.pool.QueryRow(ctx,
`insert into playlist_items(playlist_id, media_asset_id, order_index, type, src, title, duration_seconds, valid_from, valid_until) `insert into playlist_items(playlist_id, media_asset_id, order_index, type, src, title, duration_seconds, valid_from, valid_until)
values($1,$2,$3,$4,$5,$6,$7,$8,$9) values($1,$2,
(select coalesce(max(order_index)+1, 0) from playlist_items where playlist_id=$1),
$3,$4,$5,$6,$7,$8)
returning id, playlist_id, coalesce(media_asset_id,''), order_index, type, src, returning id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at`, coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at`,
playlistID, mediaID, maxIdx, itemType, src, title, durationSeconds, validFrom, validUntil) playlistID, mediaID, itemType, src, title, durationSeconds, validFrom, validUntil)
return scanPlaylistItem(row) return scanPlaylistItem(row)
} }