Compare commits
No commits in common. "ae5dfbd2105611cfebbebd5e701c05e7d9468fb1" and "2534dbbe05184fbad6ec4d00403961b646bb1570" have entirely different histories.
ae5dfbd210
...
2534dbbe05
21 changed files with 83 additions and 1661 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -21,4 +21,3 @@ ansible/.vault_pass
|
|||
ansible/roles/signage_player/files/morz-agent
|
||||
player/agent/agent-linux-arm64
|
||||
docs/SESSION-MEMORY-*.md
|
||||
player/agent/morz-agent
|
||||
|
|
|
|||
8
TODO.md
8
TODO.md
|
|
@ -58,7 +58,6 @@
|
|||
- [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
|
||||
|
|
@ -164,13 +163,6 @@
|
|||
- [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
|
||||
|
|
|
|||
|
|
@ -1,307 +0,0 @@
|
|||
# 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 |
|
||||
|
|
@ -349,11 +349,8 @@ func (a *App) fetchPlaylist(ctx context.Context) {
|
|||
}
|
||||
|
||||
for i := range pr.Items {
|
||||
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
|
||||
if strings.HasPrefix(pr.Items[i].Src, "/") {
|
||||
pr.Items[i].Src = a.Config.ServerBaseURL + pr.Items[i].Src
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,6 @@ 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
|
||||
}
|
||||
|
||||
|
|
@ -295,10 +294,10 @@ const playerHTML = `<!DOCTYPE html>
|
|||
all.push({ label: 'Playlist', value: dynPlaylistLength + ' Eintr\u00e4ge' });
|
||||
}
|
||||
if (dynConnectivity) {
|
||||
var connLabel = dynConnectivity === 'online' ? 'Erreichbar'
|
||||
var connLabel = dynConnectivity === 'online' ? 'Online'
|
||||
: dynConnectivity === 'degraded' ? 'Eingeschränkt'
|
||||
: 'Nicht erreichbar';
|
||||
all.push({ label: 'Server', value: connLabel });
|
||||
: 'Offline';
|
||||
all.push({ label: 'Netzwerk', value: connLabel });
|
||||
}
|
||||
|
||||
overlay.innerHTML = '';
|
||||
|
|
@ -436,23 +435,7 @@ const playerHTML = `<!DOCTYPE html>
|
|||
} else {
|
||||
// type === 'web', 'pdf' oder unbekannt → iframe
|
||||
if (type === 'pdf') {
|
||||
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);
|
||||
frame.src = item.src + '#toolbar=0&navpanes=0&scrollbar=0&view=Fit&page=1';
|
||||
} else {
|
||||
if (frame.src !== item.src) { frame.src = item.src; }
|
||||
}
|
||||
|
|
@ -620,7 +603,7 @@ const playerHTML = `<!DOCTYPE html>
|
|||
var knownStartupToken = null;
|
||||
|
||||
function pollStartupToken() {
|
||||
fetch('/api/startup-token', {cache: 'no-store'})
|
||||
fetch('/api/startup-token')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d || !d.token) return;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
|
@ -97,6 +98,17 @@ 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 {
|
||||
|
|
@ -115,6 +127,8 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ 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/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
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,19 +15,11 @@ 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=
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package app
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
|
@ -49,23 +47,6 @@ 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)
|
||||
|
|
@ -81,9 +62,7 @@ func New() (*App, error) {
|
|||
ScreenStore: screens,
|
||||
MediaStore: media,
|
||||
PlaylistStore: playlists,
|
||||
AuthStore: authStore,
|
||||
Notifier: notifier,
|
||||
Config: cfg,
|
||||
UploadDir: cfg.UploadDir,
|
||||
Logger: logger,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,24 +11,17 @@ 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 {
|
||||
return Config{
|
||||
HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"),
|
||||
StatusStorePath: os.Getenv("MORZ_INFOBOARD_STATUS_STORE_PATH"),
|
||||
DatabaseURL: getenv("MORZ_INFOBOARD_DATABASE_URL", "postgres://morz_infoboard:morz_infoboard@localhost:5432/morz_infoboard?sslmode=disable"),
|
||||
UploadDir: getenv("MORZ_INFOBOARD_UPLOAD_DIR", "/tmp/morz-uploads"),
|
||||
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",
|
||||
HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"),
|
||||
StatusStorePath: os.Getenv("MORZ_INFOBOARD_STATUS_STORE_PATH"),
|
||||
DatabaseURL: getenv("MORZ_INFOBOARD_DATABASE_URL", "postgres://morz_infoboard:morz_infoboard@localhost:5432/morz_infoboard?sslmode=disable"),
|
||||
UploadDir: getenv("MORZ_INFOBOARD_UPLOAD_DIR", "/tmp/morz-uploads"),
|
||||
MQTTBroker: os.Getenv("MORZ_INFOBOARD_MQTT_BROKER"),
|
||||
MQTTUsername: os.Getenv("MORZ_INFOBOARD_MQTT_USERNAME"),
|
||||
MQTTPassword: os.Getenv("MORZ_INFOBOARD_MQTT_PASSWORD"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
-- 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);
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -5,17 +5,16 @@ 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 so that all
|
||||
// It upserts the screen in the default tenant (morz) 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, cfg config.Config) http.HandlerFunc {
|
||||
func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
|
|
@ -40,8 +39,8 @@ func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore
|
|||
body.Orientation = "landscape"
|
||||
}
|
||||
|
||||
// Register under the configured default tenant.
|
||||
tenant, err := tenants.Get(r.Context(), cfg.DefaultTenantSlug)
|
||||
// v1: single tenant — always register under "morz".
|
||||
tenant, err := tenants.Get(r.Context(), "morz")
|
||||
if err != nil {
|
||||
http.Error(w, "default tenant not found", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,87 +1,5 @@
|
|||
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>
|
||||
|
|
@ -250,13 +168,6 @@ 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>
|
||||
|
||||
|
|
@ -530,7 +441,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="{{.BackLink}}">{{.BackLabel}}</a>
|
||||
<a class="navbar-item" href="/admin">← Admin</a>
|
||||
<span class="navbar-item">
|
||||
<strong>{{.Screen.Name}}</strong>
|
||||
|
||||
|
|
@ -545,13 +456,6 @@ 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ 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"
|
||||
)
|
||||
|
||||
|
|
@ -91,10 +90,7 @@ func HandleManageUI(
|
|||
return
|
||||
}
|
||||
|
||||
var tenant *store.Tenant
|
||||
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
|
||||
tenant, _ = tenants.Get(r.Context(), u.TenantSlug)
|
||||
}
|
||||
tenant, _ := tenants.Get(r.Context(), "morz") // v1: single tenant
|
||||
if tenant == nil {
|
||||
tenant = &store.Tenant{ID: screen.TenantID, Name: "MORZ"}
|
||||
}
|
||||
|
|
@ -125,20 +121,6 @@ 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,
|
||||
|
|
@ -147,8 +129,6 @@ func HandleManageUI(
|
|||
"Items": items,
|
||||
"Assets": assets,
|
||||
"AddedAssets": addedAssets,
|
||||
"BackLink": backLink,
|
||||
"BackLabel": backLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -171,13 +151,9 @@ func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore
|
|||
orientation = "landscape"
|
||||
}
|
||||
|
||||
tenantSlug := "morz"
|
||||
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
|
||||
tenantSlug = u.TenantSlug
|
||||
}
|
||||
tenant, err := tenants.Get(r.Context(), tenantSlug)
|
||||
tenant, err := tenants.Get(r.Context(), "morz")
|
||||
if err != nil {
|
||||
http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError)
|
||||
http.Error(w, "standard-tenant nicht gefunden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -218,13 +194,9 @@ func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) h
|
|||
orientation = "landscape"
|
||||
}
|
||||
|
||||
tenantSlug := "morz"
|
||||
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
|
||||
tenantSlug = u.TenantSlug
|
||||
}
|
||||
tenant, err := tenants.Get(r.Context(), tenantSlug)
|
||||
tenant, err := tenants.Get(r.Context(), "morz")
|
||||
if err != nil {
|
||||
http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError)
|
||||
http.Error(w, "standard-tenant nicht gefunden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -4,9 +4,7 @@ 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"
|
||||
)
|
||||
|
|
@ -18,9 +16,7 @@ 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
|
||||
}
|
||||
|
|
@ -92,87 +88,58 @@ 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.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))))
|
||||
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))
|
||||
|
||||
// ── Playlist management UI ────────────────────────────────────────────
|
||||
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))))
|
||||
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))
|
||||
|
||||
// ── JSON API — screens ────────────────────────────────────────────────
|
||||
// Self-registration: no auth (player calls this on startup).
|
||||
// Self-registration: called by agent on startup (must be before /{tenantSlug}/ routes)
|
||||
mux.HandleFunc("POST /api/v1/screens/register",
|
||||
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))))
|
||||
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))
|
||||
|
||||
// ── JSON API — media ──────────────────────────────────────────────────
|
||||
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))))
|
||||
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))
|
||||
|
||||
// ── 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.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))))
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,299 +0,0 @@
|
|||
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>`
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue