Compare commits

..

7 commits

Author SHA1 Message Date
Jesko Anschütz
ae5dfbd210 Tenant-Feature Phase 5: Kontextsensitiver BackLink in Manage-UI
- manageData: BackLink/BackLabel Felder ergänzt
- HandleManageUI: ?from=tenant → "← Dashboard", sonst "← Admin"
- manageTmpl: hardcoded "← Admin" durch {{.BackLabel}}/{{.BackLink}} ersetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:12:43 +01:00
Jesko Anschütz
fb8d598e9e Tenant-Feature Phase 3c + Phase 4: Register-Fix + Tenant-Dashboard UI
Phase 3c:
- register.go: hardcoded "morz" durch cfg.DefaultTenantSlug ersetzt

Phase 4:
- neues Package httpapi/tenant: HandleTenantDashboard, HandleTenantUpload, HandleTenantDeleteMedia
- tenantDashTmpl: Navbar, zwei Tabs (Monitore/Mediathek), Status-Polling, Upload-Fortschritt
- router.go: /tenant/{tenantSlug}/... Routen hinter RequireAuth+RequireTenantAccess
- manage/templates.go: Abmelden-Button in Admin-UI und Manage-UI Navbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:08:32 +01:00
Jesko Anschütz
27c4562175 Tenant-Feature Phase 3b: Login-Redirect + Tenant-Context in Manage-UI
- reqcontext-Package: shared contextKey für httpapi und manage
- Login-Redirect: Tenant-User → /manage/<slug>, Admin → /admin
- GetUserByUsername: LEFT JOIN tenants für TenantSlug-Befüllung
- manage/ui.go: reqcontext.UserFromContext statt hardcoded "morz"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:00:02 +01:00
Jesko Anschütz
0b21be6469 Tenant-Feature Phase 3: Auth-Middleware verdrahtet
- TenantSlug in User-Struct + GetSessionUser per JOIN befüllt
- middleware.go: RequireAuth, RequireAdmin, RequireTenantAccess
- router.go: alle Routen mit passendem Middleware-Stack gesichert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 17:52:55 +01:00
Jesko Anschütz
7e7a692521 Tenant-Feature Phase 1+2: Auth-Fundament + Login-Flow + UX-Textverbesserung
- DB-Migration 002_auth.sql (users + sessions Tabellen)
- AuthStore mit Session-Management, bcrypt, EnsureAdminUser
- Login/Logout Handler mit Cookie-Session (HttpOnly, SameSite=Lax)
- Login-Template (Bulma-Card, deutsche Labels)
- Config: AdminPassword, DefaultTenantSlug, DevMode
- Fallback-Texte: "Netzwerk offline" → "Server nicht erreichbar"
- TENANT-FEATURE-PLAN.md mit 46 Checkboxen als Steuerungsdatei

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:46:14 +01:00
Jesko Anschütz
cea393c1a0 Auth Phase 1 Review: Fix 3 critical bugs in auth foundation
1. [SQL] Fix username uniqueness constraint
   - Changed from global unique to composite unique(tenant_id, username)
   - Multi-tenant apps need same usernames across tenants (e.g., each tenant can have 'admin')

2. [Go] Fix inconsistent error handling in scanSession
   - Now returns pgx.ErrNoRows when session not found (like scanUser)
   - Allows proper 404 vs 500 error distinction in handlers

3. [Go] Add missing VerifyPassword function
   - Implements bcrypt.CompareHashAndPassword for password verification
   - Enables login flow with proper error handling for missing users
   - Paired with existing GenerateFromPassword for secure password hashing

Security checks:
- SQL injection: All queries parameterized (no string interpolation)
- bcrypt: Cost factor 12 (production-recommended)
- Session tokens: PostgreSQL gen_random_uuid() (cryptographically secure)
- Password hashes: Protected with json:"-" tag (never exposed in responses)
- Error handling: Comprehensive, no silent failures

Build & Vet: All checks pass (go build ./..., go vet ./...)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:37:18 +01:00
Jesko Anschütz
6bc4d3d2f8 Fix: Protokoll-relative URLs, PDF-Fragment-Merge, Startup-Token-Cache, Test-Nil-Deref
- URL-Normalisierung überspringt jetzt //protocol-relative URLs
- PDF-Viewer-Parameter werden mit bestehenden Fragments gemerged statt blind angehängt
- /api/startup-token setzt Cache-Control: no-store (Server + Client)
- Tote Goroutine mit ignoriertem net.Listen-Error aus TestAssetsServed entfernt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:21:26 +01:00
21 changed files with 1661 additions and 83 deletions

1
.gitignore vendored
View file

@ -21,3 +21,4 @@ ansible/.vault_pass
ansible/roles/signage_player/files/morz-agent
player/agent/agent-linux-arm64
docs/SESSION-MEMORY-*.md
player/agent/morz-agent

View file

@ -58,6 +58,7 @@
- [x] API-Backend fachlich schneiden
- [x] Admin-Oberflaeche in Hauptbereiche aufteilen
- [ ] Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen
- [ ] Firmen-/Tenant-Oberfläche → siehe docs/TENANT-FEATURE-PLAN.md
- [x] Storage-Konzept fuer Uploads, Cache-Dateien und Screenshots festlegen
- [x] Authentifizierungskonzept festlegen
- [x] Mandantentrennung im Datenmodell und in den APIs absichern
@ -163,6 +164,13 @@
- [x] vars.yml Download-Button in Provision-UI statt Copy-Paste
- [x] Toggle-Switch statt Ja/Nein-Select fuer Enabled-Feld
## UX-Bug-Fixes
- [x] Fix: Protokoll-relative URLs (//cdn...) werden nicht mehr durch URL-Normalisierung kaputtgeschrieben
- [x] Fix: PDF-Fragment-Parameter werden mit bestehenden Fragments gemerged statt blind angehängt
- [x] Fix: /api/startup-token setzt Cache-Control: no-store Header (Server + Client)
- [x] Fix: TestAssetsServed Nil-Dereferenz durch tote Goroutine behoben
## Querschnittsthemen
- [ ] Datensicherung fuer Datenbank und Medien einplanen

307
docs/TENANT-FEATURE-PLAN.md Normal file
View file

@ -0,0 +1,307 @@
# Tenant-/Firmen-Dashboard Implementierungs-Checkliste
Stand: 2026-03-23
Verantwortliche: Backy (Auth-Backend), Fred (Tenant-UI), Larry (Code-Review)
Diese Datei ist die einzige verbindliche Quelle fuer die Umsetzung des Tenant-Dashboards.
Alle Schritte sind als Checkboxen formuliert und enthalten Dateipfade und Funktionsnamen,
sodass kein weiterer Kontext benoetigt wird.
---
## Phase 1 Auth-Fundament
Ziel: Datenbank-Tabellen fuer User/Sessions anlegen, AuthStore mit allen CRUD-Methoden
implementieren, Config erweitern und Admin-User beim Start anlegen.
- [x] **DB-Migration anlegen** neue Datei
`server/backend/internal/db/migrations/002_auth.sql` erstellen mit zwei Tabellen:
`users` (id UUID PK, tenant_id UUID FK tenants.id, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'tenant', created_at TIMESTAMPTZ)
und `sessions` (id UUID PK DEFAULT gen_random_uuid(), user_id UUID FK users.id ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now(), expires_at TIMESTAMPTZ NOT NULL); Index auf
`sessions(user_id)` und `sessions(expires_at)` anlegen.
- [x] **AuthStore-Datei anlegen** neue Datei
`server/backend/internal/store/auth.go` mit Typ `AuthStore struct{ pool *pgxpool.Pool }`
und Konstruktor `NewAuthStore(pool) *AuthStore`; Paket `store`, gleiche Import-Konventionen
wie `store.go`.
- [x] **AuthStore.GetUserByUsername implementieren** Methode
`(s *AuthStore) GetUserByUsername(ctx, username string) (*User, error)`;
neuer Domain-Typ `User` (id, tenant_id, username, password_hash, role, created_at) in
`store/auth.go` definieren; Query: `SELECT ... FROM users WHERE username=$1`.
- [x] **AuthStore Session-Methoden implementieren**
`CreateSession(ctx, userID string, ttl time.Duration) (*Session, error)`,
`GetSessionUser(ctx, sessionID string) (*User, error)` (JOIN users ON users.id = sessions.user_id
WHERE sessions.id=$1 AND expires_at > now()),
`DeleteSession(ctx, sessionID string) error`,
`CleanExpiredSessions(ctx) error` (DELETE FROM sessions WHERE expires_at <= now()).
- [x] **AuthStore.EnsureAdminUser implementieren** Methode
`(s *AuthStore) EnsureAdminUser(ctx, tenantSlug, password string) error`;
prueft ob `username='admin'` existiert, legt ihn mit `bcrypt.GenerateFromPassword` an falls nicht;
haengt den User an den Tenant mit `tenantSlug` (via Subquery auf `tenants.slug`).
- [x] **Config erweitern** in `server/backend/internal/config/config.go` drei neue Felder
ergaenzen: `AdminPassword string` (Env: `MORZ_INFOBOARD_ADMIN_PASSWORD`, kein Fallback
leer bedeutet: kein EnsureAdminUser-Lauf),
`DefaultTenantSlug string` (Env: `MORZ_INFOBOARD_DEFAULT_TENANT`, Fallback `"morz"`),
`DevMode bool` (Env: `MORZ_INFOBOARD_DEV_MODE`, Fallback `false`; wenn true: Session-Cookie
wird auch ohne HTTPS gesetzt).
- [x] **main.go / app.go: AuthStore verdrahten** in `server/backend/internal/app/app.go`
`store.NewAuthStore(pool.Pool)` instanziieren und an `httpapi.RouterDeps` uebergeben
(neues Feld `AuthStore *store.AuthStore`).
- [x] **EnsureAdminUser beim Start aufrufen** in `app.New()` nach der Store-Initialisierung:
wenn `cfg.AdminPassword != ""`, dann `authStore.EnsureAdminUser(ctx, cfg.DefaultTenantSlug,
cfg.AdminPassword)` aufrufen; Fehler loggen, aber nicht fatal (Server soll starten).
- [x] **Migrations-Runner pruefen** sicherstellen, dass `db.Connect` in
`server/backend/internal/db/db.go` alle Dateien aus `migrations/` in alphabetischer
Reihenfolge ausfuehrt und `002_auth.sql` damit automatisch eingespielt wird; ggf. Logzeile
hinzufuegen.
- [x] **Doku** Abschnitt "Auth-Datenbankschema" in `docs/SCHEMA.md` ergaenzen:
Tabellen `users` und `sessions` mit allen Spalten und Constraints beschreiben.
---
## Phase 2 Login-Flow
Ziel: Login-Seite rendern, POST-Handler mit bcrypt-Pruefung, Session-Cookie setzen,
Logout implementieren, alle Routen eintragen.
- [x] **Login-Template definieren** in `server/backend/internal/httpapi/manage/templates.go`
neue Template-Konstante `loginTmpl` anlegen; Bulma-Card zentriert auf der Seite
(`columns is-centered`, `column is-narrow`), Formular mit Feldern `username` und `password`
(type=password), Submit-Button `Anmelden`, Flash-Message-Bereich fuer Fehlermeldungen
(gleiche Klasse wie bestehende Flash-Messages in `manageTmpl`).
- [x] **HandleLoginUI implementieren** in `server/backend/internal/httpapi/manage/auth.go`
(neue Datei) Funktion `HandleLoginUI() http.HandlerFunc`; GET-Handler, rendert `loginTmpl`;
wenn Session-Cookie bereits gueltig ist, redirect zu `/tenant/{slug}/dashboard`.
- [x] **HandleLoginPost implementieren** in `auth.go` Funktion
`HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc`;
liest `username`/`password` aus Formular, ruft `authStore.GetUserByUsername` auf,
prueft Passwort mit `bcrypt.CompareHashAndPassword`, erstellt Session mit
`authStore.CreateSession(..., 24*time.Hour)`, setzt `HttpOnly`-Cookie `morz_session`
(Secure=true ausser bei DevMode), redirectet je nach Rolle:
`admin` zu `/admin`, `tenant` zu `/tenant/{tenantSlug}/dashboard`.
- [x] **HandleLogoutPost implementieren** in `auth.go` Funktion
`HandleLogoutPost(authStore *store.AuthStore) http.HandlerFunc`;
liest Cookie `morz_session`, ruft `authStore.DeleteSession` auf,
setzt Cookie mit MaxAge=-1 (loeschen), redirectet zu `/login`.
- [x] **Routen eintragen** in `server/backend/internal/httpapi/router.go` in
`registerManageRoutes` hinzufuegen:
`mux.HandleFunc("GET /login", auth.HandleLoginUI())`,
`mux.HandleFunc("POST /login", auth.HandleLoginPost(d.AuthStore, cfg))`,
`mux.HandleFunc("POST /logout", auth.HandleLogoutPost(d.AuthStore))`;
Import `httpapi/manage/auth` ergaenzen (oder Package zusammenfassen Entscheidung vor Umsetzung
treffen).
- [x] **Manuell testen** Server starten, `GET /login` aufrufen, Login mit
Admin-Credentials pruefen, Cookie in Devtools sichtbar, Logout funktioniert,
Fehler bei falschem Passwort zeigt Flash-Message.
- [x] **Doku** `docs/SERVER-KONZEPT.md` Abschnitt "Authentifizierung" um Login-Flow
und Cookie-Lebensdauer erganzen.
---
## Phase 3 Middleware + Tenant-Isolation
Ziel: Drei Middleware-Funktionen implementieren, Router umbauen sodass geschuetzte Routen
hinter den Middlewares liegen, hardcoded `"morz"` an allen vier Stellen entfernen.
- [ ] **RequireAuth implementieren** in `server/backend/internal/httpapi/middleware.go`
(neue Datei) Funktion `RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler`;
liest Cookie `morz_session`, ruft `authStore.GetSessionUser` auf,
speichert `*store.User` im Context (eigener Key-Typ `contextKey`),
redirectet bei Fehler zu `/login?next=<aktueller-Pfad>`.
- [ ] **RequireAdmin implementieren** in `middleware.go` Funktion
`RequireAdmin(next http.Handler) http.Handler`; liest User aus Context,
prueft `user.Role == "admin"`, antwortet sonst mit 403.
- [ ] **RequireTenantAccess implementieren** in `middleware.go` Funktion
`RequireTenantAccess(next http.Handler) http.Handler`; liest User und `{tenantSlug}` aus
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),
antwortet sonst mit 403.
- [ ] **Router umbauen** in `router.go` die bisherige flache Route-Liste in Gruppen
umstrukturieren: `/admin`-Routen hinter `RequireAuth` + `RequireAdmin` legen,
`/manage/{screenSlug}`-Routen und kuenftige `/tenant/{tenantSlug}/...`-Routen hinter
`RequireAuth` + `RequireTenantAccess` legen; Hilfsfunktion `chain(...Middleware)` nutzen
oder inline wrappen.
- [ ] **Hardcoded "morz" entfernen (Stelle 1)** in
`server/backend/internal/httpapi/manage/ui.go` Zeile 93:
`tenants.Get(r.Context(), "morz")` ersetzen durch Auslesen des authentifizierten Users aus
Context; `tenant_id` aus `user.TenantID` verwenden.
- [ ] **Hardcoded "morz" entfernen (Stelle 2)** in `ui.go` Zeile 154:
gleiche Ersetzung fuer `HandleManageUI`.
- [ ] **Hardcoded "morz" entfernen (Stelle 3)** in `ui.go` Zeile 197:
gleiche Ersetzung fuer `HandleProvisionUI`; SSH-User `"morz"` (Zeile 191) aus Config
lesen oder als optionalen Query-Parameter ermoeglichen.
- [ ] **Hardcoded "morz" entfernen (Stelle 4)** in
`server/backend/internal/httpapi/manage/register.go` Zeile 43:
`tenants.Get(r.Context(), "morz")` durch `cfg.DefaultTenantSlug` ersetzen.
- [ ] **Doku** `docs/SERVER-KONZEPT.md` um Abschnitt "Middleware-Kette" erganzen:
Schaubild der Route-Gruppen mit den jeweiligen Middlewares.
---
## Phase 4 Tenant-Dashboard UI
Ziel: Eigenes Package fuer Tenant-Handler, zweistufige Tab-Ansicht
(Screens mit Live-Status, Mediathek mit Upload), Navbar, Routing.
- [ ] **Package-Verzeichnis anlegen** neues Verzeichnis
`server/backend/internal/httpapi/tenant/`; Dateien:
`tenant.go` (Handler), `templates.go` (Template-Strings); gleiche Struktur wie Package `manage`.
- [ ] **tenantDashTmpl definieren** in `tenant/templates.go` Bulma-Layout mit:
Navbar (Logo links, "Abmelden"-Button rechts als POST /logout),
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
des Templates (analog zu bestehenden inline-Scripts in `manage/templates.go`).
- [ ] **Tab A Screen-Karten implementieren** in `tenantDashTmpl` Tab A mit Bulma-Cards
pro Screen: Titel (Screen.Name), Orientierungsicon, Status-Badge
(Online/Offline/Unbekannt) per JS-Fetch aus `/api/v1/screens/status`;
JS-Funktion `loadScreenStatuses()` alle 30 Sekunden aufrufen und Badge-Farbe setzen
(is-success / is-danger / is-warning).
- [ ] **Tab B Mediathek mit Upload implementieren** in `tenantDashTmpl` Tab B:
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`);
Upload-Fortschrittsbalken (bestehende JS-Logik aus `manageTmpl` wiederverwenden oder extrahieren).
- [ ] **HandleTenantDashboard implementieren** in `tenant/tenant.go` Funktion
`HandleTenantDashboard(tenantStore *store.TenantStore, screenStore *store.ScreenStore,
mediaStore *store.MediaStore, statusStore playerStatusStore) http.HandlerFunc`;
liest `{tenantSlug}` aus URL, laedt Screens und Media-Assets, rendert `tenantDashTmpl`.
- [ ] **HandleTenantUpload implementieren** in `tenant/tenant.go` Funktion
`HandleTenantUpload(tenantStore *store.TenantStore, mediaStore *store.MediaStore,
uploadDir string) http.HandlerFunc`; identische Upload-Logik wie `manage.HandleUploadMediaUI`,
aber ohne Screen-Kontext (Media gehoert direkt dem Tenant);
nach Erfolg Redirect zu `/tenant/{tenantSlug}/dashboard?tab=media&flash=uploaded`.
- [ ] **Navbar in Admin-UI erganzen** in `manage/templates.go` in `adminTmpl` und
`manageTmpl` eine minimale Bulma-Navbar mit "Admin" (aktiv) und "Abmelden"-Button erganzen,
sodass beide UIs optisch konsistent sind.
- [ ] **Responsive pruefen** `tenantDashTmpl` auf `is-mobile`-Breakpoint testen:
Screen-Karten sollen in `columns is-multiline` wrappen; Upload-Bereich soll auf schmalen
Screens nutzbar bleiben.
- [ ] **Routen eintragen** in `router.go` innerhalb `registerManageRoutes` hinter
`RequireAuth` + `RequireTenantAccess`:
`mux.HandleFunc("GET /tenant/{tenantSlug}/dashboard", tenant.HandleTenantDashboard(...))`,
`mux.HandleFunc("POST /tenant/{tenantSlug}/upload", tenant.HandleTenantUpload(...))`.
- [ ] **Doku** `docs/SERVER-KONZEPT.md` neuen Abschnitt "Tenant-Dashboard" mit
URL-Schema, Tab-Beschreibung und Status-Polling-Intervall erganzen.
---
## Phase 5 Manage-UI Anpassung
Ziel: Der "Zurueck"-Link in der Manage-UI soll kontextsensitiv sein
aus dem Admin-Bereich kommend zeigt er zur Admin-Uebersicht,
aus dem Tenant-Dashboard kommend zurueck zum Dashboard.
- [ ] **TemplateData um BackLink/BackLabel erweitern** in `manage/ui.go`
Struct `manageData` (oder gleichwertiges anonymes Struct) um Felder
`BackLink string` und `BackLabel string` erganzen.
- [ ] **HandleManageUI: BackLink aus Query-Parameter lesen** in `HandleManageUI`:
wenn `r.URL.Query().Get("from") == "tenant"`, dann
`BackLink = "/tenant/{tenantSlug}/dashboard"` und `BackLabel = "← Dashboard"`;
sonst `BackLink = "/admin"` und `BackLabel = "← Admin"`.
- [ ] **manageTmpl: statisches "← Admin" ersetzen** in `manage/templates.go`
den hardcoded Link `← Admin` durch `{{.BackLabel}}` mit `href="{{.BackLink}}"` ersetzen.
- [ ] **Tenant-Dashboard: Links zu Manage-UI mit ?from=tenant** in `tenant/templates.go`
jeden "Playlist bearbeiten"-Link als `/manage/{screenSlug}?from=tenant` formulieren,
damit der Ruecklink korrekt gesetzt wird.
- [ ] **Breadcrumb-Navigation** optional, aber empfohlen: in `manageTmpl` oberhalb des
Hauptinhalts eine Bulma-Breadcrumb-Leiste einfuegen:
Admin-Pfad: `Admin > {ScreenName}`, Tenant-Pfad: `Dashboard > {ScreenName}`;
Daten aus `BackLabel`/`BackLink` und `Screen.Name` zusammensetzen.
- [ ] **Doku** Kommentar in `manage/ui.go` bei `HandleManageUI` dokumentiert
den `?from=tenant`-Parameter und das BackLink-Verhalten.
---
## Phase 6 Abschluss + Deployment
Ziel: Session-Cleanup als Hintergrundprozess, Secrets in Docker/Ansible,
Code-Review durch Larry, End-to-End-Test, Deployment, Nachziehen der Kerndokumentation.
- [ ] **Session-Cleanup-Ticker implementieren** in `app.go` nach Server-Start einen
`time.NewTicker(1 * time.Hour)` starten (als Goroutine), der `authStore.CleanExpiredSessions`
aufruft; Ticker beim Shutdown stoppen (Context-Abbruch oder `defer ticker.Stop()`).
- [ ] **Docker-Secret fuer AdminPassword** in `compose/` (docker-compose.yml oder
ein neues Override-File) das Env `MORZ_INFOBOARD_ADMIN_PASSWORD` aus einem Docker-Secret
oder `.env`-Datei beziehen; `.env.example` mit leerem Wert als Dokumentation erganzen.
- [ ] **Ansible-Variable erganzen** in `ansible/` (Rollen `signage_server` oder
`signage_base`) Variable `morz_admin_password` als Vault-Variable definieren;
in das entsprechende Docker-Compose-Template eintragen.
- [ ] **Code-Review durch Larry** Pull-Request / Branch auf Gitea erstellen,
Larry als Reviewer eintragen; Review-Fokus: SQL-Injection, Session-Fixation,
bcrypt-Cost-Faktor (mind. 12), Middleware-Reihenfolge, fehlende Fehlerbehandlung.
- [ ] **End-to-End-Test** manuelle Checkliste durchlaufen:
(1) Admin-Login, Admin-UI sichtbar, Logout funktioniert;
(2) Tenant-Login, nur eigene Screens und Medien sichtbar;
(3) Tenant kann nicht auf `/admin` zugreifen (403);
(4) Upload im Tenant-Dashboard, Datei erscheint in Liste;
(5) Playlist-Bearbeitung aus Tenant-Dashboard, Ruecklink zeigt "← Dashboard".
- [ ] **Deployment** neues Image bauen (`make build` oder CI-Pipeline),
`docker compose pull && docker compose up -d` auf dem Server ausfuehren,
Migration 002_auth.sql wird automatisch eingespielt, Logs auf Fehler pruefen.
- [ ] **TODO.md nachziehen** abgearbeitete Punkte in `TODO.md` abhaken:
"Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen" (Phase 4),
"Authentifizierungskonzept festlegen" (falls noch offen),
"Mandantentrennung in den APIs absichern" (falls noch offen).
- [ ] **README / DEVELOPMENT nachziehen** `DEVELOPMENT.md` um Abschnitt
"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.
---
## Offene Entscheidungen
Diese Punkte sind bewusst noch nicht spezifiziert. Sie muessen vor oder waehrend der
Umsetzung entschieden und dann in dieser Datei oder `docs/OFFENE-ARCHITEKTURFRAGEN.md`
festgeschrieben werden.
| Thema | Frage | Wer entscheidet |
|---|---|---|
| Passwort-Reset | Soll es eine "Passwort vergessen"-Funktion geben? Falls ja: E-Mail-Versand oder Admin-seitig? | Jesko |
| CSRF-Schutz | Brauchen die POST-Formulare CSRF-Tokens? Aktuell kein Schutz implementiert. Empfehlung: `gorilla/csrf` oder eigenes Double-Submit-Cookie. | Backy + Larry |
| Rate-Limiting | Soll `/login` gegen Brute-Force abgesichert werden (z.B. 10 Versuche / 15 Min per IP)? | Backy |
| Session-TTL | 24 Stunden ist ein erster Wert. Soll es eine "Eingeloggt bleiben"-Option geben? | Jesko |
| Tenant-User-Verwaltung | Wer legt Tenant-User an? Aktuell nur der Admin via DB oder EnsureAdminUser. Braucht es eine UI fuer neue Firmen-User? | Jesko |

View file

@ -349,8 +349,11 @@ func (a *App) fetchPlaylist(ctx context.Context) {
}
for i := range pr.Items {
if strings.HasPrefix(pr.Items[i].Src, "/") {
pr.Items[i].Src = a.Config.ServerBaseURL + pr.Items[i].Src
src := pr.Items[i].Src
// Nur echte relative Pfade prefixen (einzelnes /), nicht protokoll-relative
// URLs (//cdn.example.com/...) und keine absoluten URLs (http://, https://).
if strings.HasPrefix(src, "/") && !strings.HasPrefix(src, "//") {
pr.Items[i].Src = a.Config.ServerBaseURL + src
}
}

View file

@ -108,6 +108,7 @@ func (s *Server) handleNowPlaying(w http.ResponseWriter, _ *http.Request) {
// wurde und lädt die Seite automatisch neu.
func (s *Server) handleStartupToken(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(map[string]string{"token": s.startupToken}) //nolint:errcheck
}
@ -294,10 +295,10 @@ const playerHTML = `<!DOCTYPE html>
all.push({ label: 'Playlist', value: dynPlaylistLength + ' Eintr\u00e4ge' });
}
if (dynConnectivity) {
var connLabel = dynConnectivity === 'online' ? 'Online'
var connLabel = dynConnectivity === 'online' ? 'Erreichbar'
: dynConnectivity === 'degraded' ? 'Eingeschränkt'
: 'Offline';
all.push({ label: 'Netzwerk', value: connLabel });
: 'Nicht erreichbar';
all.push({ label: 'Server', value: connLabel });
}
overlay.innerHTML = '';
@ -435,7 +436,23 @@ const playerHTML = `<!DOCTYPE html>
} else {
// type === 'web', 'pdf' oder unbekannt → iframe
if (type === 'pdf') {
frame.src = item.src + '#toolbar=0&navpanes=0&scrollbar=0&view=Fit&page=1';
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; }
}
@ -603,7 +620,7 @@ const playerHTML = `<!DOCTYPE html>
var knownStartupToken = null;
function pollStartupToken() {
fetch('/api/startup-token')
fetch('/api/startup-token', {cache: 'no-store'})
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d || !d.token) return;

View file

@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"io/fs"
"net"
"net/http"
"net/http/httptest"
"strings"
@ -98,17 +97,6 @@ func TestHandleSysInfoReturnsItems(t *testing.T) {
}
func TestAssetsServed(t *testing.T) {
s := New("127.0.0.1:0", func() NowPlaying { return NowPlaying{} })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ready := make(chan string, 1)
go func() {
ln, _ := net.Listen("tcp", "127.0.0.1:0")
ready <- ln.Addr().String()
ln.Close()
}()
// Use httptest recorder to test asset handler directly via the embed FS.
sub, err := fs.Sub(assetsFS, "assets")
if err != nil {
@ -127,8 +115,6 @@ func TestAssetsServed(t *testing.T) {
t.Errorf("GET /assets/%s Content-Type = %q, want image/png", name, ct)
}
}
_ = s
_ = ctx
}
func TestServerRunAndStop(t *testing.T) {

View file

@ -9,7 +9,8 @@ require (
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
)

View file

@ -15,11 +15,19 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -2,6 +2,8 @@ package app
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"log"
"net/http"
@ -47,6 +49,23 @@ func New() (*App, error) {
screens := store.NewScreenStore(pool.Pool)
media := store.NewMediaStore(pool.Pool)
playlists := store.NewPlaylistStore(pool.Pool)
authStore := store.NewAuthStore(pool.Pool)
// Ensure admin user exists — generate a random password if none is configured.
adminPassword := cfg.AdminPassword
if adminPassword == "" {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
pool.Close()
return nil, err
}
adminPassword = hex.EncodeToString(buf)
logger.Printf("event=admin_password_generated password=%s", adminPassword)
}
if err := authStore.EnsureAdminUser(context.Background(), cfg.DefaultTenantSlug, adminPassword); err != nil {
logger.Printf("event=ensure_admin_user_failed err=%v", err)
// Non-fatal: server starts even if admin setup fails.
}
// MQTT notifier (no-op when broker not configured).
notifier := mqttnotifier.New(cfg.MQTTBroker, cfg.MQTTUsername, cfg.MQTTPassword)
@ -62,7 +81,9 @@ func New() (*App, error) {
ScreenStore: screens,
MediaStore: media,
PlaylistStore: playlists,
AuthStore: authStore,
Notifier: notifier,
Config: cfg,
UploadDir: cfg.UploadDir,
Logger: logger,
})

View file

@ -11,6 +11,10 @@ type Config struct {
MQTTBroker string
MQTTUsername string
MQTTPassword string
// Auth — optional. When AdminPassword is empty, EnsureAdminUser is skipped.
AdminPassword string // MORZ_INFOBOARD_ADMIN_PASSWORD
DefaultTenantSlug string // MORZ_INFOBOARD_DEFAULT_TENANT (default: "morz")
DevMode bool // MORZ_INFOBOARD_DEV_MODE — when true, session cookie works without HTTPS
}
func Load() Config {
@ -22,6 +26,9 @@ func Load() Config {
MQTTBroker: os.Getenv("MORZ_INFOBOARD_MQTT_BROKER"),
MQTTUsername: os.Getenv("MORZ_INFOBOARD_MQTT_USERNAME"),
MQTTPassword: os.Getenv("MORZ_INFOBOARD_MQTT_PASSWORD"),
AdminPassword: os.Getenv("MORZ_INFOBOARD_ADMIN_PASSWORD"),
DefaultTenantSlug: getenv("MORZ_INFOBOARD_DEFAULT_TENANT", "morz"),
DevMode: os.Getenv("MORZ_INFOBOARD_DEV_MODE") == "true",
}
}

View file

@ -0,0 +1,22 @@
-- 002_auth.sql
-- Auth-Schema: Users und Sessions fuer Tenant-Login
create table if not exists users (
id text primary key default gen_random_uuid()::text,
tenant_id text not null references tenants(id) on delete cascade,
username text not null,
password_hash text not null,
role text not null default 'tenant',
created_at timestamptz not null default now(),
unique(tenant_id, username)
);
create table if not exists sessions (
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')
);
create index if not exists idx_sessions_user_id on sessions(user_id);
create index if not exists idx_sessions_expires_at on sessions(expires_at);

View file

@ -0,0 +1,167 @@
package manage
import (
"html/template"
"net/http"
"net/url"
"strings"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookieName = "morz_session"
sessionTTL = 8 * time.Hour
)
// loginData is the template data for the login page.
type loginData struct {
Error string
Next string
}
// HandleLoginUI renders the login form (GET /login).
// If a valid session cookie is already present, the user is redirected to /admin
// (or the tenant dashboard once tenants are wired up in Phase 3).
func HandleLoginUI(authStore *store.AuthStore) http.HandlerFunc {
tmpl := template.Must(template.New("login").Parse(loginTmpl))
return func(w http.ResponseWriter, r *http.Request) {
// Redirect if already logged in.
if cookie, err := r.Cookie(sessionCookieName); err == nil {
if u, err := authStore.GetSessionUser(r.Context(), cookie.Value); err == nil {
if u.Role == "admin" {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
} else if u.TenantSlug != "" {
http.Redirect(w, r, "/manage/"+u.TenantSlug, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
return
}
}
next := r.URL.Query().Get("next")
data := loginData{Next: sanitizeNext(next)}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = tmpl.Execute(w, data)
}
}
// HandleLoginPost handles form submission (POST /login).
// It validates credentials, creates a session, sets the session cookie and
// redirects the user based on their role or the ?next= parameter.
func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc {
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) {
if err := r.ParseForm(); err != nil {
renderError(w, "", "Ungültige Anfrage.")
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
next := sanitizeNext(r.FormValue("next"))
if username == "" || password == "" {
renderError(w, next, "Bitte Benutzername und Passwort eingeben.")
return
}
user, err := authStore.GetUserByUsername(r.Context(), username)
if err != nil {
// Constant-time failure — same message for unknown user and wrong password.
renderError(w, next, "Benutzername oder Passwort falsch.")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
renderError(w, next, "Benutzername oder Passwort falsch.")
return
}
session, err := authStore.CreateSession(r.Context(), user.ID, sessionTTL)
if err != nil {
renderError(w, next, "Interner Fehler beim Erstellen der Sitzung.")
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: session.ID,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: !cfg.DevMode,
SameSite: http.SameSiteLaxMode,
})
// Redirect: honour ?next= for relative paths, otherwise role-based default.
if next != "" {
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
switch user.Role {
case "admin":
http.Redirect(w, r, "/admin", http.StatusSeeOther)
default:
if user.TenantSlug != "" {
http.Redirect(w, r, "/manage/"+user.TenantSlug, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
}
}
}
// HandleLogoutPost deletes the session and clears the cookie (POST /logout).
func HandleLogoutPost(authStore *store.AuthStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil {
_ = authStore.DeleteSession(r.Context(), cookie.Value)
}
// Expire the cookie immediately.
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
}
// sanitizeNext ensures the redirect target is a safe relative path.
// Only paths starting with "/" and not containing "//" or a scheme are allowed.
func sanitizeNext(next string) string {
if next == "" {
return ""
}
// Reject absolute URLs (contain scheme or authority).
if strings.Contains(next, "://") || strings.Contains(next, "//") {
return ""
}
// Must start with a slash.
if !strings.HasPrefix(next, "/") {
return ""
}
// Validate via url.Parse — rejects anything with a host component.
u, err := url.ParseRequestURI(next)
if err != nil || u.Host != "" || u.Scheme != "" {
return ""
}
return next
}

View file

@ -5,16 +5,17 @@ import (
"net/http"
"strings"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
// HandleRegisterScreen is called by the player agent on startup.
// It upserts the screen in the default tenant (morz) so that all
// It upserts the screen in the default tenant so that all
// deployed screens appear automatically in the admin UI.
//
// POST /api/v1/screens/register
// Body: {"slug":"info10","name":"Info10 Bildschirm","orientation":"landscape"}
func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
Slug string `json:"slug"`
@ -39,8 +40,8 @@ func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore
body.Orientation = "landscape"
}
// v1: single tenant — always register under "morz".
tenant, err := tenants.Get(r.Context(), "morz")
// Register under the configured default tenant.
tenant, err := tenants.Get(r.Context(), cfg.DefaultTenantSlug)
if err != nil {
http.Error(w, "default tenant not found", http.StatusInternalServerError)
return

View file

@ -1,5 +1,87 @@
package manage
const loginTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anmelden morz infoboard</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
body { background: #f5f5f5; min-height: 100vh; display: flex; flex-direction: column; }
.login-wrapper { flex: 1; display: flex; align-items: center; justify-content: center; padding: 2rem; }
</style>
</head>
<body>
<nav class="navbar is-dark">
<div class="navbar-brand">
<span class="navbar-item"><strong>morz infoboard</strong></span>
</div>
</nav>
<div class="login-wrapper">
<div class="columns is-centered" style="width:100%">
<div class="column is-narrow" style="min-width:340px;max-width:420px">
<div class="box">
<h1 class="title is-4 has-text-centered mb-5">Anmelden</h1>
{{if .Error}}
<div class="notification is-danger is-light">
<button class="delete" onclick="this.parentElement.remove()"></button>
{{.Error}}
</div>
{{end}}
<form method="POST" action="/login">
{{if .Next}}
<input type="hidden" name="next" value="{{.Next}}">
{{end}}
<div class="field">
<label class="label" for="username">Benutzername</label>
<div class="control has-icons-left">
<input class="input" type="text" id="username" name="username"
autocomplete="username" autofocus required>
<span class="icon is-small is-left">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</span>
</div>
</div>
<div class="field">
<label class="label" for="password">Passwort</label>
<div class="control has-icons-left">
<input class="input" type="password" id="password" name="password"
autocomplete="current-password" required>
<span class="icon is-small is-left">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</span>
</div>
</div>
<div class="field mt-5">
<div class="control">
<button class="button is-dark is-fullwidth" type="submit">Anmelden</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</body>
</html>`
const provisionTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
@ -168,6 +250,13 @@ const adminTmpl = `<!DOCTYPE html>
<div class="navbar-start">
<a class="navbar-item" href="/status">Diagnose</a>
</div>
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<button class="button is-light is-small" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
@ -441,7 +530,7 @@ const manageTmpl = `<!DOCTYPE html>
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/admin"> Admin</a>
<a class="navbar-item" href="{{.BackLink}}">{{.BackLabel}}</a>
<span class="navbar-item">
<strong>{{.Screen.Name}}</strong>
&nbsp;
@ -456,6 +545,13 @@ const manageTmpl = `<!DOCTYPE html>
<div id="manageNavbar" class="navbar-menu">
<div class="navbar-start">
</div>
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<button class="button is-light is-small" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>

View file

@ -13,6 +13,7 @@ import (
"time"
"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"
)
@ -90,7 +91,10 @@ func HandleManageUI(
return
}
tenant, _ := tenants.Get(r.Context(), "morz") // v1: single tenant
var tenant *store.Tenant
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenant, _ = tenants.Get(r.Context(), u.TenantSlug)
}
if tenant == nil {
tenant = &store.Tenant{ID: screen.TenantID, Name: "MORZ"}
}
@ -121,6 +125,20 @@ func HandleManageUI(
}
}
// Determine back-navigation based on ?from= query parameter.
backLink := "/admin"
backLabel := "← Admin"
if r.URL.Query().Get("from") == "tenant" {
tenantSlug := ""
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
if tenantSlug != "" {
backLink = "/tenant/" + tenantSlug + "/dashboard"
backLabel = "← Dashboard"
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t.Execute(w, map[string]any{ //nolint:errcheck
"Screen": screen,
@ -129,6 +147,8 @@ func HandleManageUI(
"Items": items,
"Assets": assets,
"AddedAssets": addedAssets,
"BackLink": backLink,
"BackLabel": backLabel,
})
}
}
@ -151,9 +171,13 @@ func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore
orientation = "landscape"
}
tenant, err := tenants.Get(r.Context(), "morz")
tenantSlug := "morz"
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
tenant, err := tenants.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "standard-tenant nicht gefunden", http.StatusInternalServerError)
http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError)
return
}
@ -194,9 +218,13 @@ func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) h
orientation = "landscape"
}
tenant, err := tenants.Get(r.Context(), "morz")
tenantSlug := "morz"
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
tenant, err := tenants.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "standard-tenant nicht gefunden", http.StatusInternalServerError)
http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError)
return
}

View file

@ -0,0 +1,96 @@
package httpapi
import (
"context"
"net/http"
"net/url"
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
// UserFromContext returns the authenticated *store.User stored in ctx,
// or nil if none is present.
// It delegates to reqcontext so that sub-packages (e.g. manage) can share
// the same context key without creating an import cycle.
func UserFromContext(ctx context.Context) *store.User {
return reqcontext.UserFromContext(ctx)
}
// RequireAuth returns middleware that validates the morz_session cookie.
// On success it stores the *store.User in the request context and calls next.
// On failure it redirects to /login?next=<current-path>.
func RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("morz_session")
if err != nil {
redirectToLogin(w, r)
return
}
user, err := authStore.GetSessionUser(r.Context(), cookie.Value)
if err != nil {
redirectToLogin(w, r)
return
}
ctx := reqcontext.WithUser(r.Context(), user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireAdmin is middleware that allows only users with role "admin".
// It must be chained after RequireAuth (so a user is present in context).
// On failure it responds with 403 Forbidden.
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil || user.Role != "admin" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// RequireTenantAccess is middleware that allows access only when the
// authenticated user belongs to the tenant identified by the {tenantSlug}
// path value, or when the user has role "admin" (admins can access everything).
// It must be chained after RequireAuth.
func RequireTenantAccess(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 bypass tenant isolation.
if user.Role == "admin" {
next.ServeHTTP(w, r)
return
}
tenantSlug := r.PathValue("tenantSlug")
if tenantSlug != "" && user.TenantSlug != tenantSlug {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// chain applies a list of middleware to a handler, wrapping outermost first.
// chain(m1, m2, m3)(h) == m1(m2(m3(h)))
func chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
// redirectToLogin issues a 303 redirect to /login with the current path as ?next=.
func redirectToLogin(w http.ResponseWriter, r *http.Request) {
target := "/login?next=" + url.QueryEscape(r.URL.RequestURI())
http.Redirect(w, r, target, http.StatusSeeOther)
}

View file

@ -4,7 +4,9 @@ import (
"log"
"net/http"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage"
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/tenant"
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
@ -16,7 +18,9 @@ type RouterDeps struct {
ScreenStore *store.ScreenStore
MediaStore *store.MediaStore
PlaylistStore *store.PlaylistStore
AuthStore *store.AuthStore
Notifier *mqttnotifier.Notifier
Config config.Config
UploadDir string
Logger *log.Logger
}
@ -88,58 +92,87 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
mux.HandleFunc("GET /static/bulma.min.css", manage.HandleStaticBulmaCSS())
mux.HandleFunc("GET /static/Sortable.min.js", manage.HandleStaticSortableJS())
// ── Auth (no auth middleware required) ────────────────────────────────
mux.HandleFunc("GET /login", manage.HandleLoginUI(d.AuthStore))
mux.HandleFunc("POST /login", manage.HandleLoginPost(d.AuthStore, d.Config))
mux.HandleFunc("POST /logout", manage.HandleLogoutPost(d.AuthStore))
// Shorthand middleware combinators for this router.
authOnly := func(h http.Handler) http.Handler {
return chain(h, RequireAuth(d.AuthStore))
}
authAdmin := func(h http.Handler) http.Handler {
return chain(h, RequireAuth(d.AuthStore), RequireAdmin)
}
authTenant := func(h http.Handler) http.Handler {
return chain(h, RequireAuth(d.AuthStore), RequireTenantAccess)
}
// ── Admin UI ──────────────────────────────────────────────────────────
mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /admin/screens/provision", manage.HandleProvisionUI(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /admin/screens", manage.HandleCreateScreenUI(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /admin/screens/{screenId}/delete", manage.HandleDeleteScreenUI(d.ScreenStore))
mux.Handle("GET /admin",
authAdmin(http.HandlerFunc(manage.HandleAdminUI(d.TenantStore, d.ScreenStore))))
mux.Handle("POST /admin/screens/provision",
authAdmin(http.HandlerFunc(manage.HandleProvisionUI(d.TenantStore, d.ScreenStore))))
mux.Handle("POST /admin/screens",
authAdmin(http.HandlerFunc(manage.HandleCreateScreenUI(d.TenantStore, d.ScreenStore))))
mux.Handle("POST /admin/screens/{screenId}/delete",
authAdmin(http.HandlerFunc(manage.HandleDeleteScreenUI(d.ScreenStore))))
// ── Playlist management UI ────────────────────────────────────────────
mux.HandleFunc("GET /manage/{screenSlug}",
manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore))
mux.HandleFunc("POST /manage/{screenSlug}/upload",
manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
mux.HandleFunc("POST /manage/{screenSlug}/items",
manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore, notifier))
mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}",
manage.HandleUpdateItemUI(d.PlaylistStore, notifier))
mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}/delete",
manage.HandleDeleteItemUI(d.PlaylistStore, notifier))
mux.HandleFunc("POST /manage/{screenSlug}/reorder",
manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore, notifier))
mux.HandleFunc("POST /manage/{screenSlug}/media/{mediaId}/delete",
manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir, notifier))
mux.Handle("GET /manage/{screenSlug}",
authOnly(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore))))
mux.Handle("POST /manage/{screenSlug}/upload",
authOnly(http.HandlerFunc(manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))))
mux.Handle("POST /manage/{screenSlug}/items",
authOnly(http.HandlerFunc(manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/items/{itemId}",
authOnly(http.HandlerFunc(manage.HandleUpdateItemUI(d.PlaylistStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/items/{itemId}/delete",
authOnly(http.HandlerFunc(manage.HandleDeleteItemUI(d.PlaylistStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/reorder",
authOnly(http.HandlerFunc(manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/media/{mediaId}/delete",
authOnly(http.HandlerFunc(manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir, notifier))))
// ── JSON API — screens ────────────────────────────────────────────────
// Self-registration: called by agent on startup (must be before /{tenantSlug}/ routes)
// Self-registration: no auth (player calls this on startup).
mux.HandleFunc("POST /api/v1/screens/register",
manage.HandleRegisterScreen(d.TenantStore, d.ScreenStore))
mux.HandleFunc("GET /api/v1/tenants/{tenantSlug}/screens",
manage.HandleListScreens(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /api/v1/tenants/{tenantSlug}/screens",
manage.HandleCreateScreen(d.TenantStore, d.ScreenStore))
manage.HandleRegisterScreen(d.TenantStore, d.ScreenStore, d.Config))
mux.Handle("GET /api/v1/tenants/{tenantSlug}/screens",
authTenant(http.HandlerFunc(manage.HandleListScreens(d.TenantStore, d.ScreenStore))))
mux.Handle("POST /api/v1/tenants/{tenantSlug}/screens",
authTenant(http.HandlerFunc(manage.HandleCreateScreen(d.TenantStore, d.ScreenStore))))
// ── JSON API — media ──────────────────────────────────────────────────
mux.HandleFunc("GET /api/v1/tenants/{tenantSlug}/media",
manage.HandleListMedia(d.TenantStore, d.MediaStore))
mux.HandleFunc("POST /api/v1/tenants/{tenantSlug}/media",
manage.HandleUploadMedia(d.TenantStore, d.MediaStore, uploadDir))
mux.HandleFunc("DELETE /api/v1/media/{id}",
manage.HandleDeleteMedia(d.MediaStore, uploadDir))
mux.Handle("GET /api/v1/tenants/{tenantSlug}/media",
authTenant(http.HandlerFunc(manage.HandleListMedia(d.TenantStore, d.MediaStore))))
mux.Handle("POST /api/v1/tenants/{tenantSlug}/media",
authTenant(http.HandlerFunc(manage.HandleUploadMedia(d.TenantStore, d.MediaStore, uploadDir))))
mux.Handle("DELETE /api/v1/media/{id}",
authOnly(http.HandlerFunc(manage.HandleDeleteMedia(d.MediaStore, uploadDir))))
// ── JSON API — playlists ──────────────────────────────────────────────
// Player fetches its playlist — no auth required.
mux.HandleFunc("GET /api/v1/screens/{screenId}/playlist",
manage.HandlePlayerPlaylist(d.ScreenStore, d.PlaylistStore))
mux.HandleFunc("GET /api/v1/playlists/{screenId}",
manage.HandleGetPlaylist(d.ScreenStore, d.PlaylistStore))
mux.HandleFunc("POST /api/v1/playlists/{playlistId}/items",
manage.HandleAddItem(d.PlaylistStore, d.MediaStore, notifier))
mux.HandleFunc("PATCH /api/v1/items/{itemId}",
manage.HandleUpdateItem(d.PlaylistStore, notifier))
mux.HandleFunc("DELETE /api/v1/items/{itemId}",
manage.HandleDeleteItem(d.PlaylistStore, notifier))
mux.HandleFunc("PUT /api/v1/playlists/{playlistId}/order",
manage.HandleReorder(d.PlaylistStore, notifier))
mux.HandleFunc("PATCH /api/v1/playlists/{playlistId}/duration",
manage.HandleUpdatePlaylistDuration(d.PlaylistStore))
mux.Handle("GET /api/v1/playlists/{screenId}",
authOnly(http.HandlerFunc(manage.HandleGetPlaylist(d.ScreenStore, d.PlaylistStore))))
mux.Handle("POST /api/v1/playlists/{playlistId}/items",
authOnly(http.HandlerFunc(manage.HandleAddItem(d.PlaylistStore, d.MediaStore, notifier))))
mux.Handle("PATCH /api/v1/items/{itemId}",
authOnly(http.HandlerFunc(manage.HandleUpdateItem(d.PlaylistStore, notifier))))
mux.Handle("DELETE /api/v1/items/{itemId}",
authOnly(http.HandlerFunc(manage.HandleDeleteItem(d.PlaylistStore, notifier))))
mux.Handle("PUT /api/v1/playlists/{playlistId}/order",
authOnly(http.HandlerFunc(manage.HandleReorder(d.PlaylistStore, notifier))))
mux.Handle("PATCH /api/v1/playlists/{playlistId}/duration",
authOnly(http.HandlerFunc(manage.HandleUpdatePlaylistDuration(d.PlaylistStore))))
// ── Tenant self-service dashboard ─────────────────────────────────────
mux.Handle("GET /tenant/{tenantSlug}/dashboard",
authTenant(http.HandlerFunc(tenant.HandleTenantDashboard(d.TenantStore, d.ScreenStore, d.MediaStore))))
mux.Handle("POST /tenant/{tenantSlug}/upload",
authTenant(http.HandlerFunc(tenant.HandleTenantUpload(d.TenantStore, d.MediaStore, uploadDir))))
mux.Handle("POST /tenant/{tenantSlug}/media/{mediaId}/delete",
authTenant(http.HandlerFunc(tenant.HandleTenantDeleteMedia(d.TenantStore, d.MediaStore, uploadDir))))
}

View file

@ -0,0 +1,299 @@
package tenant
const tenantDashTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mein Dashboard morz infoboard</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
body { background: #f5f5f5; min-height: 100vh; }
.navbar { margin-bottom: 0; }
.tab-content { display: none; }
.tab-content.is-active { display: block; }
.progress-bar-wrap { display: none; margin-top: .5rem; }
</style>
</head>
<body>
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<span class="navbar-item"><strong>📺 Infoboard</strong></span>
</div>
<div class="navbar-menu">
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<button class="button is-light is-small" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
<section class="section">
<div class="container">
<h1 class="title is-4 mb-4">{{.Tenant.Name}}</h1>
{{if .Flash}}
<div class="notification is-success is-light mb-4">
<button class="delete" onclick="this.parentElement.remove()"></button>
{{.Flash}}
</div>
{{end}}
<div class="tabs is-boxed mb-0" id="dash-tabs">
<ul>
<li class="is-active" data-tab="monitors">
<a><span>Meine Monitore</span></a>
</li>
<li data-tab="media">
<a><span>Mediathek</span></a>
</li>
</ul>
</div>
<!-- Tab A: Meine Monitore -->
<div id="tab-monitors" class="tab-content is-active">
<div class="box" style="border-top-left-radius:0">
{{if .Screens}}
<div class="columns is-multiline">
{{range .Screens}}
<div class="column is-4">
<div class="card">
<div class="card-content">
<p class="title is-5">
{{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}}
{{.Name}}
</p>
<p class="subtitle is-6 has-text-grey">
{{if eq .Orientation "portrait"}}Hochformat{{else}}Querformat{{end}}
</p>
<div id="status-{{.Slug}}" class="mb-3">
<span class="tag is-warning">Unbekannt</span>
</div>
</div>
<footer class="card-footer">
<a class="card-footer-item"
href="/manage/{{.Slug}}?from=tenant">
Playlist bearbeiten
</a>
</footer>
</div>
</div>
{{end}}
</div>
{{else}}
<p class="has-text-grey">Noch keine Monitore zugewiesen.</p>
{{end}}
</div>
</div>
<!-- Tab B: Mediathek -->
<div id="tab-media" class="tab-content">
<div class="box" style="border-top-left-radius:0">
<h2 class="title is-5">Medium hochladen</h2>
<form id="upload-form" method="POST"
action="/tenant/{{.Tenant.Slug}}/upload"
enctype="multipart/form-data">
<div class="field">
<label class="label">Typ</label>
<div class="control">
<div class="select">
<select name="type" id="upload-type" onchange="toggleUploadFields()">
<option value="image">Bild</option>
<option value="video">Video</option>
<option value="pdf">PDF</option>
<option value="web">Website (URL)</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Titel (optional)</label>
<div class="control">
<input class="input" type="text" name="title" placeholder="Wird aus Dateinamen abgeleitet">
</div>
</div>
<div id="file-field" class="field">
<label class="label">Datei</label>
<div class="control">
<input class="input" type="file" name="file" id="upload-file"
accept="image/*,video/*,application/pdf">
</div>
<div class="progress-bar-wrap" id="upload-progress-wrap">
<progress class="progress is-info" id="upload-progress" value="0" max="100"></progress>
</div>
</div>
<div id="url-field" class="field" style="display:none">
<label class="label">URL</label>
<div class="control">
<input class="input" type="url" name="url" placeholder="https://...">
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit" id="upload-btn">Hochladen</button>
</div>
</div>
</form>
<hr>
<h2 class="title is-5">Vorhandene Medien</h2>
{{if .Assets}}
<div style="overflow-x:auto">
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th>Typ</th>
<th>Titel</th>
<th>Größe</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Assets}}
<tr>
<td>{{typeIcon .Type}}</td>
<td>{{.Title}}</td>
<td class="has-text-grey is-size-7">
{{if .SizeBytes}}{{humanSize .SizeBytes}}{{else}}{{end}}
</td>
<td>
<form method="POST"
action="/tenant/{{$.Tenant.Slug}}/media/{{.ID}}/delete"
onsubmit="return confirm('Wirklich löschen?')">
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="has-text-grey">Noch keine Medien hochgeladen.</p>
{{end}}
</div>
</div><!-- /tab-media -->
</div>
</section>
<script>
// ── Tab-Switching ────────────────────────────────────────────────────────────
(function() {
var tabs = document.querySelectorAll('#dash-tabs li[data-tab]');
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) { t.classList.remove('is-active'); });
tab.classList.add('is-active');
document.querySelectorAll('.tab-content').forEach(function(c) {
c.classList.remove('is-active');
});
document.getElementById('tab-' + tab.dataset.tab).classList.add('is-active');
// Sync URL ohne Reload
var url = new URL(window.location.href);
url.searchParams.set('tab', tab.dataset.tab);
history.replaceState(null, '', url.toString());
});
});
// Beim Laden ggf. Tab aus URL herstellen
var tabParam = new URLSearchParams(window.location.search).get('tab');
if (tabParam) {
var target = document.querySelector('#dash-tabs li[data-tab="' + tabParam + '"]');
if (target) target.click();
}
})();
// ── Upload-Formular Typ-Felder ────────────────────────────────────────────────
function toggleUploadFields() {
var t = document.getElementById('upload-type').value;
document.getElementById('file-field').style.display = (t === 'web') ? 'none' : '';
document.getElementById('url-field').style.display = (t === 'web') ? '' : 'none';
}
// ── Upload-Fortschrittsbalken ─────────────────────────────────────────────────
(function() {
var form = document.getElementById('upload-form');
if (!form) return;
form.addEventListener('submit', function(e) {
var t = document.getElementById('upload-type').value;
if (t === 'web') return; // kein XHR für URL-Typ
var file = document.getElementById('upload-file').files[0];
if (!file) return;
e.preventDefault();
var wrap = document.getElementById('upload-progress-wrap');
var bar = document.getElementById('upload-progress');
var btn = document.getElementById('upload-btn');
wrap.style.display = 'block';
btn.disabled = true;
btn.textContent = 'Lädt hoch';
var fd = new FormData(form);
var xhr = new XMLHttpRequest();
xhr.open('POST', form.action);
xhr.upload.addEventListener('progress', function(ev) {
if (ev.lengthComputable) {
bar.value = Math.round(ev.loaded / ev.total * 100);
}
});
xhr.addEventListener('load', function() {
if (xhr.status >= 200 && xhr.status < 400) {
window.location.href = window.location.pathname + '?tab=media&flash=uploaded';
} else {
alert('Upload fehlgeschlagen: ' + xhr.responseText);
btn.disabled = false;
btn.textContent = 'Hochladen';
wrap.style.display = 'none';
}
});
xhr.addEventListener('error', function() {
alert('Netzwerkfehler beim Upload.');
btn.disabled = false;
btn.textContent = 'Hochladen';
wrap.style.display = 'none';
});
xhr.send(fd);
});
})();
// ── Status-Polling alle 30 s ──────────────────────────────────────────────────
(function() {
function pollStatus() {
fetch('/api/v1/screens/status')
.then(function(r) { return r.json(); })
.then(function(list) {
if (!Array.isArray(list)) return;
list.forEach(function(s) {
var el = document.getElementById('status-' + s.screen_id);
if (!el) return;
var ok = s.status === 'ok' || s.status === 'online';
var cls = ok ? 'is-success' : 'is-danger';
var text = ok ? 'Online' : 'Offline';
el.innerHTML = '<span class="tag ' + cls + '">' + text + '</span>';
});
})
.catch(function() { /* ignore */ });
}
pollStatus();
setInterval(pollStatus, 30000);
})();
</script>
</body>
</html>`

View file

@ -0,0 +1,258 @@
// Package tenant implements the tenant self-service dashboard UI.
package tenant
import (
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
var tmplFuncs = template.FuncMap{
"typeIcon": func(t string) string {
switch t {
case "image":
return "🖼"
case "video":
return "🎬"
case "pdf":
return "📄"
case "web":
return "🌐"
default:
return "📁"
}
},
"humanSize": func(b int64) string {
switch {
case b >= 1<<20:
return fmt.Sprintf("%.1f MB", float64(b)/float64(1<<20))
case b >= 1<<10:
return fmt.Sprintf("%.0f KB", float64(b)/float64(1<<10))
default:
return fmt.Sprintf("%d B", b)
}
},
}
const maxUploadSize = 512 << 20 // 512 MB
// HandleTenantDashboard renders the tenant self-service dashboard.
func HandleTenantDashboard(
tenantStore *store.TenantStore,
screenStore *store.ScreenStore,
mediaStore *store.MediaStore,
) http.HandlerFunc {
t := template.Must(template.New("tenant-dash").Funcs(tmplFuncs).Parse(tenantDashTmpl))
return func(w http.ResponseWriter, r *http.Request) {
tenantSlug := r.PathValue("tenantSlug")
tenant, err := tenantStore.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "Tenant nicht gefunden: "+tenantSlug, http.StatusNotFound)
return
}
screens, err := screenStore.List(r.Context(), tenant.ID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
assets, err := mediaStore.List(r.Context(), tenant.ID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// Flash message: prefer ?flash= (from tenant upload redirect),
// also accept legacy ?msg= used by manage handlers.
flash := ""
if f := r.URL.Query().Get("flash"); f != "" {
switch f {
case "uploaded":
flash = "Medium erfolgreich hochgeladen."
case "deleted":
flash = "Medium erfolgreich gelöscht."
default:
flash = f
}
} else if m := r.URL.Query().Get("msg"); m != "" {
switch m {
case "uploaded":
flash = "Medium erfolgreich hochgeladen."
case "deleted":
flash = "Medium erfolgreich gelöscht."
default:
flash = m
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t.Execute(w, map[string]any{ //nolint:errcheck
"Tenant": tenant,
"Screens": screens,
"Assets": assets,
"Flash": flash,
})
}
}
// HandleTenantUpload handles multipart file uploads and web-URL registrations
// from the tenant dashboard, then redirects back.
func HandleTenantUpload(
tenantStore *store.TenantStore,
mediaStore *store.MediaStore,
uploadDir string,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tenantSlug := r.PathValue("tenantSlug")
tenant, err := tenantStore.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "Tenant nicht gefunden: "+tenantSlug, http.StatusNotFound)
return
}
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "Upload zu groß oder ungültig", http.StatusBadRequest)
return
}
assetType := strings.TrimSpace(r.FormValue("type"))
title := strings.TrimSpace(r.FormValue("title"))
switch assetType {
case "web":
url := strings.TrimSpace(r.FormValue("url"))
if url == "" {
http.Error(w, "URL erforderlich", http.StatusBadRequest)
return
}
if title == "" {
title = url
}
_, err = mediaStore.Create(r.Context(), tenant.ID, title, "web", "", url, "", 0)
case "image", "video", "pdf":
file, header, ferr := r.FormFile("file")
if ferr != nil {
http.Error(w, "Datei erforderlich", http.StatusBadRequest)
return
}
defer file.Close()
if title == "" {
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
}
mimeType := header.Header.Get("Content-Type")
// Derive asset type from MIME if more specific.
if detected := mimeToAssetType(mimeType); detected != "" {
assetType = detected
}
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext)
destPath := filepath.Join(uploadDir, filename)
dest, ferr := os.Create(destPath)
if ferr != nil {
http.Error(w, "Speicherfehler", http.StatusInternalServerError)
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)
default:
http.Error(w, "Unbekannter Typ", http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/tenant/"+tenantSlug+"/dashboard?tab=media&flash=uploaded", http.StatusSeeOther)
}
}
// HandleTenantDeleteMedia deletes a media asset owned by the tenant.
func HandleTenantDeleteMedia(
tenantStore *store.TenantStore,
mediaStore *store.MediaStore,
uploadDir string,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tenantSlug := r.PathValue("tenantSlug")
mediaID := r.PathValue("mediaId")
// Verify tenant exists.
tenant, err := tenantStore.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "Tenant nicht gefunden", http.StatusNotFound)
return
}
asset, err := mediaStore.Get(r.Context(), mediaID)
if err != nil {
http.Error(w, "Medium nicht gefunden", http.StatusNotFound)
return
}
// Ownership check.
if asset.TenantID != tenant.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if asset.StoragePath != "" {
os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck
}
mediaStore.Delete(r.Context(), mediaID) //nolint:errcheck
http.Redirect(w, r, "/tenant/"+tenantSlug+"/dashboard?tab=media&flash=deleted", http.StatusSeeOther)
}
}
// mimeToAssetType derives the asset type from a MIME type string.
func mimeToAssetType(mime string) string {
mime = strings.ToLower(strings.TrimSpace(mime))
switch {
case strings.HasPrefix(mime, "image/"):
return "image"
case strings.HasPrefix(mime, "video/"):
return "video"
case mime == "application/pdf":
return "pdf"
default:
return ""
}
}
// sanitize converts a string to a safe filename component.
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,26 @@
// Package reqcontext provides a shared context key and helpers for storing
// the authenticated user in a request context. It lives in its own package so
// that both httpapi (middleware) and httpapi/manage (handlers) can import it
// without creating an import cycle.
package reqcontext
import (
"context"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
type contextKey int
const contextKeyUser contextKey = 0
// WithUser returns a new context that carries u.
func WithUser(ctx context.Context, u *store.User) context.Context {
return context.WithValue(ctx, contextKeyUser, u)
}
// UserFromContext returns the *store.User stored in ctx, or nil if none.
func UserFromContext(ctx context.Context) *store.User {
u, _ := ctx.Value(contextKeyUser).(*store.User)
return u
}

View file

@ -0,0 +1,193 @@
package store
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
)
// ------------------------------------------------------------------
// Domain types
// ------------------------------------------------------------------
// User represents an authenticated user belonging to a tenant.
type User struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
Username string `json:"username"`
PasswordHash string `json:"-"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
// Session represents an authenticated session token.
type Session struct {
ID string `json:"id"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// ------------------------------------------------------------------
// AuthStore
// ------------------------------------------------------------------
// AuthStore handles user authentication and session management.
type AuthStore struct{ pool *pgxpool.Pool }
// NewAuthStore creates a new AuthStore backed by pool.
func NewAuthStore(pool *pgxpool.Pool) *AuthStore { return &AuthStore{pool} }
// GetUserByUsername returns the user with the given username or pgx.ErrNoRows.
// TenantSlug is populated via LEFT JOIN on tenants.
func (s *AuthStore) GetUserByUsername(ctx context.Context, username string) (*User, error) {
row := s.pool.QueryRow(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 u.username = $1`, username)
return scanUserWithSlug(row)
}
// CreateSession inserts a new session for userID with the given TTL and returns the session.
func (s *AuthStore) CreateSession(ctx context.Context, userID string, ttl time.Duration) (*Session, error) {
expiresAt := time.Now().Add(ttl)
row := s.pool.QueryRow(ctx,
`insert into sessions(user_id, expires_at)
values($1, $2)
returning id, user_id, created_at, expires_at`,
userID, expiresAt)
return scanSession(row)
}
// GetSessionUser returns the user associated with sessionID if the session is still valid.
// Returns pgx.ErrNoRows when the session does not exist or has expired.
// TenantSlug is populated via JOIN on tenants.
func (s *AuthStore) GetSessionUser(ctx context.Context, sessionID string) (*User, error) {
row := s.pool.QueryRow(ctx,
`select u.id, u.tenant_id, coalesce(t.slug, ''), u.username, u.password_hash, u.role, u.created_at
from sessions se
join users u on u.id = se.user_id
left join tenants t on t.id = u.tenant_id
where se.id = $1
and se.expires_at > now()`, sessionID)
return scanUserWithSlug(row)
}
// DeleteSession removes the session with the given ID.
func (s *AuthStore) DeleteSession(ctx context.Context, sessionID string) error {
_, err := s.pool.Exec(ctx, `delete from sessions where id = $1`, sessionID)
return err
}
// CleanExpiredSessions removes all sessions whose expires_at is in the past.
func (s *AuthStore) CleanExpiredSessions(ctx context.Context) error {
_, err := s.pool.Exec(ctx, `delete from sessions where expires_at <= now()`)
return err
}
// VerifyPassword checks if the provided password matches the hashed password for a user.
func (s *AuthStore) VerifyPassword(ctx context.Context, userID, password string) (bool, error) {
var passwordHash string
err := s.pool.QueryRow(ctx,
`select password_hash from users where id = $1`, userID).
Scan(&passwordHash)
if err != nil {
if err == pgx.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("auth: get password hash: %w", err)
}
err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
return err == nil, nil
}
// EnsureAdminUser creates an 'admin' user for the tenant identified by tenantSlug
// if no user with username 'admin' already exists. The password is hashed with bcrypt.
// bcrypt cost factor 12 is used (minimum recommended for production).
func (s *AuthStore) EnsureAdminUser(ctx context.Context, tenantSlug, password string) error {
// Check whether 'admin' user already exists for this tenant.
var exists bool
err := s.pool.QueryRow(ctx,
`select exists(select 1 from users where username = $1)`,
"admin",
).Scan(&exists)
if err != nil {
return fmt.Errorf("auth: check admin user: %w", err)
}
if exists {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return fmt.Errorf("auth: hash password: %w", err)
}
_, err = s.pool.Exec(ctx,
`insert into users(tenant_id, username, password_hash, role)
values(
(select id from tenants where slug = $1),
'admin',
$2,
'admin'
)`,
tenantSlug, string(hash))
if err != nil {
return fmt.Errorf("auth: create admin user: %w", err)
}
return nil
}
// ------------------------------------------------------------------
// 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.
func scanUserWithSlug(row interface {
Scan(dest ...any) error
}) (*User, error) {
var u User
err := row.Scan(&u.ID, &u.TenantID, &u.TenantSlug, &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
}
func scanSession(row interface {
Scan(dest ...any) error
}) (*Session, error) {
var se Session
err := row.Scan(&se.ID, &se.UserID, &se.CreatedAt, &se.ExpiresAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, pgx.ErrNoRows
}
return nil, fmt.Errorf("scan session: %w", err)
}
return &se, nil
}