fix(csrf): CSRF-Token für User-Logout in Manage- und Tenant-Dashboard

- HandleManageUI übergibt CSRFToken korrekt ans Template (leeres Hidden-Field
  blockierte JS-Inject-Snippet)
- HandleTenantDashboard setzt CSRF-Cookie und befüllt CSRFToken in Template-Daten
- tenant/csrf_helpers.go: setCSRFCookie im tenant-Package (Import-Cycle-Isolation)
- Logout-Formular in tenantDashTmpl hat jetzt statisches CSRF-Hidden-Field
- Doku: POST /logout und POST /login mit CSRF-Anforderungen dokumentiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-24 14:26:52 +01:00
parent 097cd58c0c
commit 47f65da228
9 changed files with 672 additions and 105 deletions

View file

@ -6,6 +6,7 @@ Die Backend-API unterteilt sich in mehrere Bereiche:
- **Health & Meta**: System-Status und API-Informationen
- **Player Status**: Status-Ingest und Diagnose vom Player
- **Screenshot API**: On-Demand- und periodische Screenshots vom Player
- **Screen Management**: CRUD und Registrierung von Screens
- **Playlists**: Abruf und Verwaltung von Wiedergabelisten
- **Media**: Upload und Verwaltung von Medien-Assets
@ -519,6 +520,19 @@ Spezialendpoint zur Auflösung von Nachrichten-Wand-Anfragen (noch in Entwicklun
Alle Auth-Routen erfordern keine vorherige Authentifizierung.
### GET /
Root-Redirect auf `/login`.
- Anfragen auf exakt `/` werden per `303 See Other` zu `/login` weitergeleitet.
- Anfragen auf unbekannte Pfade (z. B. `/irgendwas`) geben `404 Not Found` zurück (404-Guard).
**Status:**
- `303 See Other` — Weiterleitung zu `/login`
- `404 Not Found` — Pfad existiert nicht
---
### GET /login
Zeigt das Login-Formular.
@ -536,9 +550,13 @@ Verarbeitet die Login-Eingabe.
**Request (Form-Encoded):**
```
username=admin&password=geheim
username=admin&password=geheim&csrf_token=<token>
```
Das CSRF-Token muss als verstecktes Formularfeld `csrf_token` mitgesendet werden.
Der Token wird beim `GET /login` als Cookie `morz_csrf` gesetzt und in den Template-Daten
als `{{.CSRFToken}}` bereitgestellt.
**Verhalten:**
- Passwort wird per `bcrypt.CompareHashAndPassword` geprueft
- Bei Erfolg wird ein `morz_session`-Cookie gesetzt (HttpOnly, Secure, 24h TTL)
@ -555,6 +573,15 @@ username=admin&password=geheim
Meldet den aktuellen Benutzer ab.
**Request (Form-Encoded):**
```
csrf_token=<token>
```
Das CSRF-Token muss als verstecktes Formularfeld `csrf_token` mitgesendet werden.
Der aktuelle Token-Wert wird beim GET-Aufruf der aufrufenden Seite als `{{.CSRFToken}}`
in die Template-Daten eingebettet und als Cookie `morz_csrf` gesetzt.
**Verhalten:**
- Session wird in der DB geloescht (`DeleteSession`)
- Cookie wird mit `MaxAge=-1` geloescht
@ -562,6 +589,7 @@ Meldet den aktuellen Benutzer ab.
**Status:**
- `303 See Other`
- `403 Forbidden` — CSRF-Token fehlt oder ungueltig
---
@ -787,6 +815,21 @@ Rückleitung zur Admin-Seite oder zum Screen-Detail.
## Playlist Management UI (Web-Formulare)
### GET /manage
Übersichtsseite für eingeloggte Benutzer.
**Auth:** `RequireAuth`.
**Verhalten:**
- Admins und Tenant-User werden direkt zu ihrer Standard-Ansicht weitergeleitet.
- Screen-User mit genau einem zugeordneten Screen werden direkt zu `GET /manage/{screenSlug}` weitergeleitet.
- Screen-User mit mehreren zugeordneten Screens erhalten eine Übersichtsseite mit Links zu den einzelnen Screens.
**Response:** HTML-Seite oder Redirect (303 See Other).
---
### GET /manage/{screenSlug}
Verwaltungs-UI für die Playlist eines Screens.
@ -1032,19 +1075,58 @@ Typische HTTP-Status:
---
## In Vorbereitung (Phase 6 / künftig)
## Screenshot API
Die folgenden Endpoints sind derzeit vorbereitet, aber noch nicht vollständig implementiert:
### POST /api/v1/player/screenshot
- `POST /api/v1/player/screenshot` — Upload von Player-Screenshots an den Backend-Server
- Wird vom Agent unter `player/agent/internal/screenshot/screenshot.go` mit dem Intervall `MORZ_INFOBOARD_SCREENSHOT_EVERY` aufgerufen
- Multipart-Request mit `screen_id`, `screenshot` (Datei), `mime_type`
- Benötigt Backend-Handler für Persistierung und/oder Verarbeitung
Vom Player-Agent aufgerufener Endpoint zum Hochladen eines Screenshots.
**Auth:** Keine.
**Request:** `multipart/form-data`, max. 3 MB.
| Feld | Typ | Pflicht | Beschreibung |
|-------------|--------|---------|------------------------------------------------------|
| `screen_id` | string | ja | Interne Screen-ID (entspricht dem Slug des Players) |
| `screenshot`| Datei | ja | Screenshot-Datei (JPEG oder PNG) |
Der MIME-Typ wird aus dem `Content-Type`-Header des Datei-Parts übernommen. Fehlt er, wird `image/png` angenommen.
Der Screenshot wird im In-Memory-`ScreenshotStore` gespeichert (nicht persistiert, kein Filesystem-Zugriff).
**Response:**
- `200 OK` — Screenshot gespeichert (kein Body)
- `400 Bad Request``screen_id` fehlt, `screenshot`-Feld fehlt, oder Multipart-Parsing fehlgeschlagen
- `500 Internal Server Error` — Lesefehler
---
### GET /api/v1/screens/{screenId}/screenshot
Ruft den zuletzt hochgeladenen Screenshot eines Screens ab.
**Auth:** `RequireAuth` (eingeloggter Benutzer).
**Path-Parameter:**
- `screenId` — Screen-ID (wie beim Upload übergeben)
**Response:**
- `200 OK` — Raw-Image-Daten mit korrektem `Content-Type` (z. B. `image/jpeg`), `Cache-Control: no-store`
- `404 Not Found` — kein Screenshot für diese Screen-ID vorhanden
---
## Änderungshistorie
- **2026-03-24 (Update):** Screenshot-Endpoints implementiert und dokumentiert (Doris / Doku-Review)
- `POST /api/v1/player/screenshot` — war als "In Vorbereitung" markiert, ist jetzt vollständig implementiert; Abschnitt komplett neu verfasst
- `GET /api/v1/screens/{screenId}/screenshot` — neuer Endpoint, `authOnly`, liefert Raw-Image aus In-Memory-Store
- `GET /manage` — neue Übersichtsseite für `screen_user` mit mehreren Screens, `authOnly`
- **2026-03-24 (Update):** CSRF-Pflichtfelder in POST /login und POST /logout dokumentiert (Doris / Doku-Review)
- `POST /login` und `POST /logout` erfordern `csrf_token` als Hidden-Field (Double-Submit-Cookie-Pattern)
- Hinweis auf `morz_csrf`-Cookie und `{{.CSRFToken}}`-Template-Variable ergaenzt
- **2026-03-24 (Update):** Root-Redirect dokumentiert (Doris / Doku-Review)
- `GET /` — Redirect 303 auf `/login`, 404-Guard für unbekannte Pfade
- **2026-03-23 (Update):** Screen-User Management Endpoints (Doris / Doku-Review)
- `POST /admin/users` — Screen-User anlegen
- `POST /admin/users/{userID}/delete` — Screen-User löschen

View file

@ -23,10 +23,13 @@ Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System.
- `internal/httpapi/csrf.go` — Double-Submit-Cookie CSRF-Schutz
- `internal/httpapi/ratelimit.go` — Rate-Limiting fuer /login (Brute-Force-Schutz)
- `internal/httpapi/uploads.go` — Upload-Handler konsolidiert
- `internal/httpapi/screenshot.go` — Handler fuer Player-Screenshot-Upload und Screenshot-Abruf
- `internal/httpapi/screenshot_store.go` — In-Memory-Store fuer Screenshots (`ScreenshotStore`, thread-safe via `sync.RWMutex`)
- `internal/httpapi/manage/` — Admin-UI und Playlist-Management-UI
- `internal/httpapi/manage/csrf_helpers.go` — CSRF-Token Helpers fuer Templates
- `internal/httpapi/manage/csrf_helpers.go` — CSRF-Token Helpers fuer Templates (manage-Package)
- `internal/httpapi/tenant/` — Tenant-Self-Service-Dashboard
- `internal/mqttnotifier/` — MQTT-Notifizierungen
- `internal/httpapi/tenant/csrf_helpers.go` — CSRF-Token Helpers fuer Templates (tenant-Package, Import-Cycle-Isolation)
- `internal/mqttnotifier/` — MQTT-Notifizierungen (`NotifyChanged`, `RequestScreenshot`)
- `internal/reqcontext/` — Context-Keys fuer authentifizierten User
## Datenbank-Stores
@ -66,6 +69,7 @@ Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System.
| GET | `/api/v1` | API-Entrypoint |
| GET | `/api/v1/meta` | Metainformationen |
| POST | `/api/v1/player/status` | Status-Ingest vom Player-Agent |
| POST | `/api/v1/player/screenshot` | Screenshot-Upload vom Player-Agent |
| GET | `/api/v1/screens/status` | Uebersicht aller Screen-Status |
| GET | `/api/v1/screens/{screenId}/status` | Einzelner Screen-Status |
| DELETE | `/api/v1/screens/{screenId}/status` | Screen-Status loeschen |
@ -85,6 +89,7 @@ Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System.
| Methode | Pfad | Beschreibung |
|---------|-------------------------------------------|---------------------------------------|
| GET | `/manage` | Screen-Uebersicht fuer screen_user |
| GET | `/manage/{screenSlug}` | Playlist-Management-UI |
| POST | `/manage/{screenSlug}/upload` | Medium fuer Screen hochladen |
| POST | `/manage/{screenSlug}/items` | Item zur Playlist hinzufuegen |
@ -99,6 +104,7 @@ Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System.
| PUT | `/api/v1/playlists/{playlistId}/order` | Items reordnen (API) |
| PATCH | `/api/v1/playlists/{playlistId}/duration` | Standard-Dauer setzen (API) |
| DELETE | `/api/v1/media/{id}` | Medium loeschen (API) |
| GET | `/api/v1/screens/{screenId}/screenshot` | Screenshot eines Screens abrufen |
### Nur Admins (`RequireAuth` + `RequireAdmin`)

View file

@ -21,7 +21,11 @@ func handleScreenUserRedirect(w http.ResponseWriter, r *http.Request, screenStor
http.Redirect(w, r, "/login?error=no_screens", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/manage/"+screens[0].Slug, http.StatusSeeOther)
if len(screens) == 1 {
http.Redirect(w, r, "/manage/"+screens[0].Slug, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/manage", http.StatusSeeOther)
}
const sessionTTL = 8 * time.Hour
@ -64,7 +68,19 @@ func HandleLoginUI(authStore *store.AuthStore, screenStore *store.ScreenStore, c
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
next := r.URL.Query().Get("next")
data := loginData{Next: sanitizeNext(next), CSRFToken: csrfToken}
// K1: ?error= Parameter auswerten und in lesbare Fehlermeldung übersetzen.
var errorMsg string
switch r.URL.Query().Get("error") {
case "no_screens":
errorMsg = "Ihr Konto hat noch keinen Bildschirm zugewiesen. Bitte wenden Sie sich an den Administrator."
case "":
// kein Fehler
default:
errorMsg = "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
}
data := loginData{Error: errorMsg, Next: sanitizeNext(next), CSRFToken: csrfToken}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = tmpl.Execute(w, data)
}

View file

@ -1,10 +1,11 @@
package manage
const loginTmpl = `<!DOCTYPE html>
<html lang="de">
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>Anmelden morz infoboard</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
@ -26,7 +27,7 @@ const loginTmpl = `<!DOCTYPE html>
<h1 class="title is-4 has-text-centered mb-5">Anmelden</h1>
{{if .Error}}
<div class="notification is-danger is-light">
<div class="notification is-danger is-light" role="alert">
<button class="delete" onclick="this.parentElement.remove()"></button>
{{.Error}}
</div>
@ -56,7 +57,7 @@ const loginTmpl = `<!DOCTYPE html>
<div class="field">
<label class="label" for="password">Passwort</label>
<div class="control has-icons-left">
<div class="control has-icons-left has-icons-right">
<input class="input" type="password" id="password" name="password"
autocomplete="current-password" required>
<span class="icon is-small is-left">
@ -67,6 +68,13 @@ const loginTmpl = `<!DOCTYPE html>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</span>
<span class="icon is-small is-right" style="pointer-events:all;cursor:pointer" onclick="togglePw('password',this)" title="Passwort anzeigen/verbergen" aria-label="Passwort anzeigen/verbergen">
<svg id="eye-login" 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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</span>
</div>
</div>
@ -80,21 +88,29 @@ const loginTmpl = `<!DOCTYPE html>
</div>
</div>
</div>
<script>
function togglePw(fieldId, iconWrap) {
var inp = document.getElementById(fieldId);
if (!inp) return;
inp.type = (inp.type === 'password') ? 'text' : 'password';
}
</script>
</body>
</html>`
const provisionTmpl = `<!DOCTYPE html>
<html lang="de">
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>Einrichten {{.Screen.Name}}</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
body { background: #f5f5f5; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 4px;
overflow-x: auto; font-size: 0.9em; line-height: 1.5; }
.step-number { background: #3273dc; color: #fff; border-radius: 50%;
.step-number { background: var(--bulma-primary, hsl(229, 53%, 53%)); color: #fff; border-radius: 50%;
width: 2rem; height: 2rem; display: inline-flex;
align-items: center; justify-content: center;
font-weight: bold; margin-right: 0.5rem; flex-shrink: 0; }
@ -111,7 +127,18 @@ const provisionTmpl = `<!DOCTYPE html>
</div>
</nav>
<section class="section">
<section class="section pb-0 pt-3">
<div class="container" style="max-width:860px">
<nav class="breadcrumb" aria-label="breadcrumb">
<ul>
<li><a href="/admin">Admin</a></li>
<li class="is-active"><a href="#" aria-current="page">Neuer Bildschirm</a></li>
</ul>
</nav>
</div>
</section>
<section class="section pt-2">
<div class="container" style="max-width:860px">
<div class="notification is-success is-light">
@ -192,7 +219,10 @@ ansible-playbook -i ansible/inventory.yml ansible/site.yml --limit {{.Screen.Slu
<div class="step-body">
<p class="title is-6">Fertig Playlist befüllen</p>
<p>Nach erfolgreichem Ansible-Lauf meldet sich der Bildschirm automatisch im Backend an und lädt seine Playlist. Jetzt kannst du Inhalte zuweisen:</p>
<a class="button is-primary mt-3" href="/manage/{{.Screen.Slug}}">Playlist für «{{.Screen.Name}}» verwalten </a>
<div class="buttons mt-3">
<a class="button is-primary" href="/manage/{{.Screen.Slug}}">Playlist für «{{.Screen.Name}}» verwalten </a>
<a class="button" href="/admin"> Zurück zu Admin</a>
</div>
</div>
</div>
</div>
@ -226,15 +256,17 @@ function downloadFile(content, filename) {
</html>`
const adminTmpl = `<!DOCTYPE html>
<html lang="de">
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>MORZ Infoboard Admin</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
body { background: #f5f5f5; }
.navbar { margin-bottom: 1.5rem; }
.tab-panel { display: none; }
.tab-panel.is-active { display: block; }
</style>
</head>
<body>
@ -254,7 +286,8 @@ const adminTmpl = `<!DOCTYPE html>
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<button class="button is-light is-small" type="submit">Abmelden</button>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button is-white is-outlined is-small" type="submit">Abmelden</button>
</form>
</div>
</div>
@ -271,7 +304,7 @@ const adminTmpl = `<!DOCTYPE html>
</header>
<section class="modal-card-body">
<p>Soll <strong id="delete-modal-name"></strong> wirklich gelöscht werden?</p>
<p class="has-text-grey is-size-7 mt-2">Alle Playlist-Einträge werden ebenfalls gelöscht.</p>
<p class="help has-text-grey mt-2">Alle Playlist-Einträge werden ebenfalls gelöscht.</p>
</section>
<footer class="modal-card-foot">
<form id="delete-modal-form" method="POST">
@ -292,7 +325,7 @@ const adminTmpl = `<!DOCTYPE html>
</header>
<section class="modal-card-body">
<p>Soll Benutzer <strong id="delete-user-modal-name"></strong> wirklich gelöscht werden?</p>
<p class="has-text-grey is-size-7 mt-2">Alle Screen-Zuordnungen werden ebenfalls entfernt.</p>
<p class="help has-text-grey mt-2">Alle Screen-Zuordnungen werden ebenfalls entfernt.</p>
</section>
<footer class="modal-card-foot">
<form id="delete-user-modal-form" method="POST">
@ -389,6 +422,7 @@ document.addEventListener('keydown', function(e) {
var text = texts[msg] || ' Aktion erfolgreich.';
var n = document.createElement('div');
n.className = 'notification ' + (isError ? 'is-warning' : 'is-success');
n.setAttribute('role', 'alert');
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
n.innerHTML = '<button class="delete"></button>' + text;
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
@ -404,26 +438,43 @@ document.addEventListener('keydown', function(e) {
})();
</script>
<section class="section pt-0">
<section class="section pb-0 pt-3">
<div class="container">
<nav class="breadcrumb" aria-label="breadcrumb">
<ul>
<li class="is-active"><a href="#" aria-current="page">Admin</a></li>
</ul>
</nav>
</div>
</section>
<section class="section pt-2">
<div class="container">
<!-- Tabs -->
<div class="tabs is-boxed mb-0">
<ul>
<li id="tab-screens" class="{{if eq .ActiveTab "screens"}}is-active{{end}}">
<a onclick="switchTab('screens')">Bildschirme</a>
<a><button type="button" role="tab" aria-selected="{{if eq .ActiveTab "screens"}}true{{else}}false{{end}}" onclick="switchTab('screens')" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;color:inherit">Bildschirme</button></a>
</li>
<li id="tab-users" class="{{if eq .ActiveTab "users"}}is-active{{end}}">
<a onclick="switchTab('users')">Benutzer</a>
<a><button type="button" role="tab" aria-selected="{{if eq .ActiveTab "users"}}true{{else}}false{{end}}" onclick="switchTab('users')" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;color:inherit">Benutzer</button></a>
</li>
</ul>
</div>
<script>
function switchTab(name) {
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
document.querySelectorAll('.tabs li').forEach(function(li) { li.classList.remove('is-active'); });
document.getElementById('panel-' + name).style.display = '';
document.getElementById('tab-' + name).classList.add('is-active');
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
document.querySelectorAll('.tabs li').forEach(function(li) {
li.classList.remove('is-active');
var btn = li.querySelector('[role="tab"]');
if (btn) btn.setAttribute('aria-selected', 'false');
});
document.getElementById('panel-' + name).classList.add('is-active');
var tabLi = document.getElementById('tab-' + name);
tabLi.classList.add('is-active');
var activeBtn = tabLi.querySelector('[role="tab"]');
if (activeBtn) activeBtn.setAttribute('aria-selected', 'true');
var url = new URL(window.location.href);
url.searchParams.set('tab', name);
history.replaceState(null, '', url.toString());
@ -458,12 +509,14 @@ document.addEventListener('keydown', function(e) {
<td><strong>{{.Name}}</strong></td>
<td><code>{{.Slug}}</code></td>
<td>{{orientationLabel .Orientation}}</td>
<td id="status-{{.Slug}}"><span class="has-text-grey"></span></td>
<td id="status-{{.Slug}}"><span class="has-text-grey" aria-label="Status unbekannt"></span></td>
<td>
{{$screenID := .ID}}
{{$screenName := .Name}}
<button class="button is-small is-light"
type="button"
data-screen-id="{{$screenID}}"
data-screen-name="{{$screenName}}"
onclick="openScreenUsersModal('{{$screenID}}', {{$screenName | printf "%q"}}, buildScreenUsersHTML('{{$screenID}}', {{$screenName | printf "%q"}}))">
{{len $users}} Benutzer
</button>
@ -482,7 +535,7 @@ document.addEventListener('keydown', function(e) {
</table>
</div>
{{else}}
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
<div class="notification is-light">Noch keine Bildschirme angelegt. Füge unten den ersten hinzu.</div>
{{end}}
<hr>
@ -547,7 +600,8 @@ document.addEventListener('keydown', function(e) {
</form>
<details class="mt-4">
<summary class="has-text-grey" style="cursor:pointer">Bestehenden Screen manuell anlegen (nur DB-Eintrag, kein Deployment)</summary>
<summary style="cursor:pointer;font-weight:600;color:#4a4a4a">Bildschirm nur anlegen (ohne Deployment)</summary>
<p class="help has-text-grey mt-1 mb-0">Legt nur einen Datenbank-Eintrag an kein Ansible, kein Agent-Setup. Für Bildschirme, die bereits provisioniert sind oder manuell konfiguriert werden.</p>
<form method="POST" action="/admin/screens" class="mt-4">
<div class="columns is-vcentered">
<div class="column is-3">
@ -622,7 +676,7 @@ document.addEventListener('keydown', function(e) {
</tbody>
</table>
{{else}}
<p class="has-text-grey mb-4">Noch keine Screen-Benutzer angelegt.</p>
<div class="notification is-light mb-4">Noch keine Screen-Benutzer angelegt. Lege unten den ersten an.</div>
{{end}}
<hr>
@ -641,10 +695,18 @@ document.addEventListener('keydown', function(e) {
<div class="column is-4">
<div class="field">
<label class="label">Passwort</label>
<div class="control">
<input class="input" type="password" name="password" placeholder="Passwort" required
autocomplete="new-password">
<div class="control has-icons-right">
<input class="input" type="password" id="admin-new-password" name="password" placeholder="Passwort (mind. 8 Zeichen)" required
autocomplete="new-password" minlength="8">
<span class="icon is-small is-right" style="pointer-events:all;cursor:pointer" onclick="togglePwAdmin()" title="Passwort anzeigen/verbergen" aria-label="Passwort anzeigen/verbergen">
<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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</span>
</div>
<p class="help">Mindestens 8 Zeichen</p>
</div>
</div>
<div class="column is-4">
@ -701,9 +763,9 @@ function buildScreenUsersHTML(screenId, screenName) {
html += '<div class="control"><button class="button is-primary" type="submit">Hinzufügen</button></div>';
html += '</div></form>';
} else if (allUsers.length === 0) {
html += '<p class="has-text-grey is-size-7">Lege zuerst Benutzer im Tab "Benutzer" an.</p>';
html += '<p class="help has-text-grey">Lege zuerst Benutzer im Tab "Benutzer" an.</p>';
} else {
html += '<p class="has-text-grey is-size-7">Alle Benutzer sind bereits zugeordnet.</p>';
html += '<p class="help has-text-grey">Alle Benutzer sind bereits zugeordnet.</p>';
}
return html;
@ -740,11 +802,18 @@ function injectCSRFNow() {
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) {
if (!data || !data.screens) return;
var dots = { 'online': '🟢', 'degraded': '🟡', 'offline': '🔴' };
var dots = {
'online': { emoji: '🟢', label: 'Online' },
'degraded': { emoji: '🟡', label: 'Eingeschränkt' },
'offline': { emoji: '🔴', label: 'Offline' }
};
data.screens.forEach(function(s) {
var cell = document.getElementById('status-' + s.screen_id);
if (cell) {
cell.innerHTML = (dots[s.derived_state] || '⚪') + ' <small>' + s.derived_state + '</small>';
var info = dots[s.derived_state] || { emoji: '⚪', label: 'Unbekannt' };
cell.innerHTML = '<span aria-hidden="true">' + info.emoji + '</span>'
+ '<span class="is-sr-only">' + info.label + '</span>'
+ ' <small>' + s.derived_state + '</small>';
}
});
})
@ -776,14 +845,43 @@ function injectCSRFNow() {
}
})();
</script>
<script>
function togglePwAdmin() {
var inp = document.getElementById('admin-new-password');
if (inp) inp.type = (inp.type === 'password') ? 'text' : 'password';
}
</script>
<script>
// M2: Auto-open Screen-User-Modal wenn ?screen= in URL vorhanden
document.addEventListener('DOMContentLoaded', function() {
var autoScreen = new URLSearchParams(window.location.search).get('screen');
if (!autoScreen) return;
// Suche den passenden Screen in den eingebetteten Daten
var allScreenData = document.querySelectorAll('[data-screen-id]');
// Fallback: direkt openScreenUsersModal aufrufen falls screenId bekannt
if (typeof openScreenUsersModal === 'function' && typeof buildScreenUsersHTML === 'function') {
// Finde Screenname aus dem Button
var btn = document.querySelector('[data-screen-id="' + autoScreen + '"]');
if (btn) {
var screenName = btn.getAttribute('data-screen-name') || autoScreen;
openScreenUsersModal(autoScreen, screenName, buildScreenUsersHTML(autoScreen, screenName));
}
}
// URL-Parameter entfernen ohne Reload
var url = new URL(window.location.href);
url.searchParams.delete('screen');
history.replaceState(null, '', url.toString());
});
</script>
</body>
</html>`
const manageTmpl = `<!DOCTYPE html>
<html lang="de">
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>Playlist {{.Screen.Name}}</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<script src="/static/Sortable.min.js"></script>
@ -792,9 +890,9 @@ const manageTmpl = `<!DOCTYPE html>
.drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; }
.drag-handle:hover { color: #333; }
.item-disabled td { opacity: 0.5; }
.edit-row td { background: #fffbf0; padding: 0.75rem 1rem; }
.edit-row td { background: var(--bulma-warning-light, hsl(48, 100%, 96%)); padding: 0.75rem 1rem; }
.tag-type { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; }
.sortable-ghost { background: #e8f4fd !important; }
.sortable-ghost { background: var(--bulma-info-light, hsl(207, 61%, 94%)) !important; }
.tab-panel { display: none; }
.tab-panel.is-active { display: block; }
</style>
@ -803,7 +901,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>
{{if .IsAdmin}}<a class="navbar-item" href="{{.BackLink}}">{{.BackLabel}}</a>{{end}}
<span class="navbar-item">
<strong>{{.Screen.Name}}</strong>
&nbsp;
@ -817,11 +915,24 @@ const manageTmpl = `<!DOCTYPE html>
</div>
<div id="manageNavbar" class="navbar-menu">
<div class="navbar-start">
{{if gt (len .AccessibleScreens) 1}}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">Bildschirm wechseln</a>
<div class="navbar-dropdown">
{{range .AccessibleScreens}}
<a class="navbar-item{{if eq .Slug $.Screen.Slug}} is-active{{end}}" href="/manage/{{.Slug}}">
{{.Name}}
</a>
{{end}}
</div>
</div>
{{end}}
</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>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button is-white is-outlined is-small" type="submit">Abmelden</button>
</form>
</div>
</div>
@ -861,6 +972,7 @@ const manageTmpl = `<!DOCTYPE html>
var text = texts[msg] || ' Aktion erfolgreich.';
var n = document.createElement('div');
n.className = 'notification is-success';
n.setAttribute('role', 'alert');
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
n.innerHTML = '<button class="delete"></button>' + text;
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
@ -889,8 +1001,27 @@ document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeManageDeleteModal();
});
</script>
<section class="section pt-4">
<section class="section pb-0 pt-3">
<div class="container">
<nav class="breadcrumb" aria-label="breadcrumb">
<ul>
{{if .IsAdmin}}<li><a href="/admin">Admin</a></li>{{end}}
<li class="is-active"><a href="#" aria-current="page">{{.Screen.Name}}</a></li>
</ul>
</nav>
</div>
</section>
<section class="section pt-2">
<div class="container">
<!-- Screenshot -->
<div class="box" style="padding:0;overflow:hidden;margin-bottom:1.5rem">
<img class="screen-thumb"
data-src="/api/v1/screens/{{.Screen.Slug}}/screenshot"
style="width:100%;max-height:220px;object-fit:cover;background:#222;display:block"
alt="Screenshot {{.Screen.Name}}">
</div>
<!-- Playlist -->
<div class="box">
@ -911,13 +1042,20 @@ document.addEventListener('keydown', function(e) {
<tbody id="sortable-items">
{{range .Items}}
<tr id="item-{{.ID}}" class="{{if not .Enabled}}item-disabled{{end}}">
<td class="drag-handle" role="button" aria-label="Reihenfolge ändern" tabindex="0" title="Ziehen zum Sortieren"></td>
<td style="white-space:nowrap">
<span class="drag-handle" role="button" aria-label="Reihenfolge per Drag ändern" tabindex="0" title="Ziehen zum Sortieren"></span>
<button type="button" class="button is-small is-white px-1" style="min-width:1.6rem" title="Nach oben" aria-label="Eintrag nach oben" onclick="reorderMove('{{.ID}}', -1)"></button>
<button type="button" class="button is-small is-white px-1" style="min-width:1.6rem" title="Nach unten" aria-label="Eintrag nach unten" onclick="reorderMove('{{.ID}}', 1)"></button>
</td>
<td>
<span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span>
</td>
<td>
<div>{{if .Title}}<strong>{{.Title}}</strong>{{else}}<em class="has-text-grey">{{shortSrc .Src}}</em>{{end}}</div>
{{if .Title}}<small class="has-text-grey">{{shortSrc .Src}}</small>{{end}}
{{if and .ValidFrom .ValidUntil}}<span class="tag is-info is-light is-small mt-1">{{formatDateDE .ValidFrom}} {{formatDateDE .ValidUntil}}</span>
{{else if .ValidFrom}}<span class="tag is-info is-light is-small mt-1">ab {{formatDateDE .ValidFrom}}</span>
{{else if .ValidUntil}}<span class="tag is-info is-light is-small mt-1">bis {{formatDateDE .ValidUntil}}</span>{{end}}
</td>
<td>{{.DurationSeconds}}&thinsp;s</td>
<td>
@ -954,11 +1092,13 @@ document.addEventListener('keydown', function(e) {
<label class="label is-small">Gültig ab</label>
<input class="input is-small" type="datetime-local" name="valid_from"
value="{{formatDT .ValidFrom}}">
<p class="help">Zeiten in Zeitzone des Servers (Europe/Berlin)</p>
</div>
<div class="column is-narrow">
<label class="label is-small">Gültig bis</label>
<input class="input is-small" type="datetime-local" name="valid_until"
value="{{formatDT .ValidUntil}}">
<p class="help">Zeiten in Zeitzone des Servers (Europe/Berlin)</p>
</div>
<div class="column is-narrow">
<label class="label is-small">Aktiv</label>
@ -1038,7 +1178,7 @@ document.addEventListener('keydown', function(e) {
</table>
</div>
{{else}}
<p class="has-text-grey">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</p>
<div class="notification is-light">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</div>
{{end}}
</div>
@ -1048,8 +1188,8 @@ document.addEventListener('keydown', function(e) {
<div class="tabs" id="upload-tabs">
<ul>
<li id="tab-file" class="is-active"><a onclick="switchTab('file')">📁 Datei hochladen</a></li>
<li id="tab-web"><a onclick="switchTab('web')">🌐 Webseite / URL</a></li>
<li id="tab-file" class="is-active"><a><button type="button" role="tab" aria-selected="true" onclick="switchTab('file')" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;color:inherit">📁 Datei hochladen</button></a></li>
<li id="tab-web"><a><button type="button" role="tab" aria-selected="false" onclick="switchTab('web')" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;color:inherit">🌐 Webseite / URL</button></a></li>
</ul>
</div>
@ -1060,7 +1200,7 @@ document.addEventListener('keydown', function(e) {
<div class="field">
<label class="label">Typ</label>
<div class="select is-fullwidth">
<select name="type">
<select name="type" id="upload-type-select" onchange="updateFileAccept(this.value)">
<option value="image">🖼 Bild</option>
<option value="video">🎬 Video</option>
<option value="pdf">📄 PDF</option>
@ -1070,7 +1210,7 @@ document.addEventListener('keydown', function(e) {
</div>
<div class="column">
<div class="field">
<label class="label">Titel <span class="has-text-grey is-size-7">(optional)</span></label>
<label class="label">Titel <span class="has-text-grey">(optional)</span></label>
<input class="input" type="text" name="title"
placeholder="Wird aus Dateinamen abgeleitet, wenn leer">
</div>
@ -1111,7 +1251,7 @@ document.addEventListener('keydown', function(e) {
</div>
<div class="column">
<div class="field">
<label class="label">Titel <span class="has-text-grey is-size-7">(optional)</span></label>
<label class="label">Titel <span class="has-text-grey">(optional)</span></label>
<input class="input" type="text" name="title" placeholder="Anzeigename">
</div>
</div>
@ -1145,7 +1285,11 @@ document.addEventListener('keydown', function(e) {
function toggleEdit(id) {
var row = document.getElementById('edit-' + id);
if (row) {
row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
var isHidden = (row.style.display === 'none' || row.style.display === '');
row.style.display = isHidden ? 'table-row' : 'none';
if (isHidden) {
row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}
@ -1155,16 +1299,82 @@ function switchTab(tab) {
var panel = document.getElementById('panel-' + p);
var tabEl = document.getElementById('tab-' + p);
if (!panel || !tabEl) return;
var btn = tabEl.querySelector('[role="tab"]');
if (p === tab) {
panel.classList.add('is-active');
tabEl.classList.add('is-active');
if (btn) btn.setAttribute('aria-selected', 'true');
} else {
panel.classList.remove('is-active');
tabEl.classList.remove('is-active');
if (btn) btn.setAttribute('aria-selected', 'false');
}
});
}
// N3: Keyboard-Reorder per ▲/▼-Buttons
function reorderMove(itemId, direction) {
var tbody = document.getElementById('sortable-items');
if (!tbody) return;
var rows = Array.from(tbody.querySelectorAll('tr[id^="item-"]'));
var idx = rows.findIndex(function(r) { return r.id === 'item-' + itemId; });
if (idx < 0) return;
var newIdx = idx + direction;
if (newIdx < 0 || newIdx >= rows.length) return;
// DOM tauschen (auch die zugehörige edit-row mitnehmen)
var itemRow = document.getElementById('item-' + itemId);
var editRow = document.getElementById('edit-' + itemId);
var targetItemRow = rows[newIdx];
var targetEditRow = document.getElementById('edit-' + targetItemRow.id.replace('item-', ''));
if (direction < 0) {
tbody.insertBefore(itemRow, targetItemRow);
if (editRow) tbody.insertBefore(editRow, targetItemRow);
if (targetEditRow) tbody.insertBefore(targetItemRow, editRow || itemRow.nextSibling);
if (targetEditRow) tbody.insertBefore(targetEditRow, editRow || itemRow.nextSibling);
} else {
var after = (targetEditRow || targetItemRow).nextSibling;
tbody.insertBefore(itemRow, after);
if (editRow) tbody.insertBefore(editRow, after);
}
// Neue Reihenfolge ans Backend schicken
var ids = Array.from(tbody.querySelectorAll('tr[id^="item-"]')).map(function(r) {
return r.id.replace('item-', '');
});
fetch('/manage/{{.Screen.Slug}}/reorder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(ids)
}).catch(function() {
showManageError('Reihenfolge konnte nicht gespeichert werden.');
});
}
// M10: Datei-Accept-Attribut dynamisch anpassen
function updateFileAccept(type) {
var inp = document.getElementById('upload-file-input');
if (!inp) return;
var acceptMap = { 'image': 'image/*', 'video': 'video/*', 'pdf': 'application/pdf' };
inp.accept = acceptMap[type] || 'image/*,video/*,application/pdf';
}
function showManageError(msg) {
var n = document.createElement('div');
n.className = 'notification is-danger';
n.setAttribute('role', 'alert');
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
n.innerHTML = '<button class="delete"></button>' + msg;
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
document.body.appendChild(n);
setTimeout(function() {
n.style.transition = 'opacity .5s';
n.style.opacity = '0';
setTimeout(function() { n.remove(); }, 500);
}, 4000);
}
// Drag-and-drop reordering
var sortableEl = document.getElementById('sortable-items');
if (sortableEl) {
@ -1181,6 +1391,14 @@ if (sortableEl) {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(ids)
}).then(function(response) {
if (!response.ok) {
showManageError('Reihenfolge konnte nicht gespeichert werden (HTTP ' + response.status + '). Seite wird neu geladen.');
window.location.reload();
}
}).catch(function() {
showManageError('Netzwerkfehler beim Speichern der Reihenfolge. Seite wird neu geladen.');
window.location.reload();
});
}
});
@ -1267,6 +1485,84 @@ function startUpload() {
}
})();
</script>
<script>
(function() {
document.querySelectorAll('.screen-thumb').forEach(function(img) {
img.src = img.dataset.src;
});
setTimeout(function() {
document.querySelectorAll('.screen-thumb').forEach(function(img) {
img.src = img.dataset.src + '?t=' + Date.now();
});
}, 4000);
})();
</script>
</body>
</html>`
const screenOverviewTmpl = `<!DOCTYPE html>
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>Bildschirme morz infoboard</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
body { background: #f5f5f5; }
</style>
</head>
<body>
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<span class="navbar-item"><strong>morz infoboard</strong></span>
</div>
<div class="navbar-menu">
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button is-white is-outlined is-small" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
<section class="section">
<div class="container">
<h1 class="title is-4 mb-5">Meine Bildschirme</h1>
<div class="columns is-multiline">
{{range .Cards}}
<div class="column is-one-third-desktop is-half-tablet">
<div class="box" style="padding:0;overflow:hidden">
<img class="screen-thumb"
data-src="/api/v1/screens/{{.Screen.Slug}}/screenshot"
alt="{{.Screen.Name}}"
style="width:100%;height:180px;object-fit:cover;background:#222;display:block">
<div style="padding:1rem">
<p class="title is-5 mb-3">{{.Screen.Name}}</p>
<a class="button is-primary is-fullwidth" href="/manage/{{.Screen.Slug}}">Verwalten</a>
</div>
</div>
</div>
{{end}}
</div>
</div>
</section>
<script>
(function() {
document.querySelectorAll('.screen-thumb').forEach(function(img) {
img.src = img.dataset.src;
});
setTimeout(function() {
document.querySelectorAll('.screen-thumb').forEach(function(img) {
img.src = img.dataset.src + '?t=' + Date.now();
});
}, 4000);
})();
</script>
</body>
</html>`

View file

@ -12,6 +12,7 @@ import (
"strings"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/fileutil"
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
@ -129,10 +130,16 @@ var tmplFuncs = template.FuncMap{
}
return t.Format("2006-01-02T15:04")
},
"formatDateDE": func(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("02.01.2006 15:04")
},
}
// HandleAdminUI renders the admin overview page (screens + users tabs).
func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore, auth *store.AuthStore) http.HandlerFunc {
func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore, auth *store.AuthStore, cfg config.Config) http.HandlerFunc {
t := template.Must(template.New("admin").Funcs(tmplFuncs).Parse(adminTmpl))
return func(w http.ResponseWriter, r *http.Request) {
allScreens, err := screens.ListAll(r.Context())
@ -172,12 +179,20 @@ func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore, auth
activeTab = "screens"
}
// M2: ?screen= Parameter weitergeben damit das Template den Screen-Modal öffnen kann.
autoOpenScreen := r.URL.Query().Get("screen")
// M6: CSRF-Token an Template-Daten weitergeben.
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
renderTemplate(w, t, map[string]any{
"Screens": allScreens,
"Tenants": allTenants,
"ScreenUsers": screenUsers,
"ScreenUserMap": screenUserMap,
"ActiveTab": activeTab,
"Screens": allScreens,
"Tenants": allTenants,
"ScreenUsers": screenUsers,
"ScreenUserMap": screenUserMap,
"ActiveTab": activeTab,
"AutoOpenScreen": autoOpenScreen,
"CSRFToken": csrfToken,
})
}
}
@ -219,7 +234,7 @@ func HandleDeleteScreenUser(auth *store.AuthStore) http.HandlerFunc {
if err := auth.DeleteUser(r.Context(), userID); err != nil {
slog.Error("delete screen user failed", "event", "delete_screen_user_failed",
"user_id", userID, "err", err)
http.Error(w, "Fehler beim Löschen", http.StatusInternalServerError)
http.Redirect(w, r, "/admin?tab=users&msg=error_db", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin?tab=users&msg=user_deleted", http.StatusSeeOther)
@ -236,16 +251,16 @@ func HandleAddUserToScreen(screens *store.ScreenStore) http.HandlerFunc {
}
userID := strings.TrimSpace(r.FormValue("user_id"))
if userID == "" {
http.Redirect(w, r, "/admin?msg=error_empty", http.StatusSeeOther)
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=error_empty", http.StatusSeeOther)
return
}
if err := screens.AddUserToScreen(r.Context(), userID, screenID); err != nil {
slog.Error("add user to screen failed", "event", "add_user_to_screen_failed",
"screen_id", screenID, "user_id", userID, "err", err)
http.Redirect(w, r, "/admin?msg=error_db", http.StatusSeeOther)
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=error_db", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin?screen="+screenID+"&msg=user_added_to_screen", http.StatusSeeOther)
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=user_added_to_screen", http.StatusSeeOther)
}
}
@ -257,10 +272,47 @@ func HandleRemoveUserFromScreen(screens *store.ScreenStore) http.HandlerFunc {
if err := screens.RemoveUserFromScreen(r.Context(), userID, screenID); err != nil {
slog.Error("remove user from screen failed", "event", "remove_user_from_screen_failed",
"screen_id", screenID, "user_id", userID, "err", err)
http.Error(w, "db error", http.StatusInternalServerError)
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=error_db", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin?screen="+screenID+"&msg=user_removed_from_screen", http.StatusSeeOther)
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=user_removed_from_screen", http.StatusSeeOther)
}
}
type screenCard struct {
Screen *store.Screen
}
// HandleScreenOverview renders a card-based overview of all accessible screens for a screen_user.
func HandleScreenOverview(screens *store.ScreenStore, notifier *mqttnotifier.Notifier, cfg config.Config) http.HandlerFunc {
t := template.Must(template.New("screenOverview").Funcs(tmplFuncs).Parse(screenOverviewTmpl))
return func(w http.ResponseWriter, r *http.Request) {
u := reqcontext.UserFromContext(r.Context())
if u == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
accessible, err := screens.GetAccessibleScreens(r.Context(), u.ID)
if err != nil || len(accessible) == 0 {
http.Redirect(w, r, "/login?error=no_screens", http.StatusSeeOther)
return
}
if len(accessible) == 1 {
http.Redirect(w, r, "/manage/"+accessible[0].Slug, http.StatusSeeOther)
return
}
for _, sc := range accessible {
notifier.RequestScreenshot(sc.Slug)
}
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
cards := make([]screenCard, 0, len(accessible))
for _, sc := range accessible {
cards = append(cards, screenCard{Screen: sc})
}
renderTemplate(w, t, map[string]any{
"Cards": cards,
"CSRFToken": csrfToken,
})
}
}
@ -270,6 +322,8 @@ func HandleManageUI(
screens *store.ScreenStore,
media *store.MediaStore,
playlists *store.PlaylistStore,
cfg config.Config,
notifier *mqttnotifier.Notifier,
) http.HandlerFunc {
t := template.Must(template.New("manage").Funcs(tmplFuncs).Parse(manageTmpl))
return func(w http.ResponseWriter, r *http.Request) {
@ -281,6 +335,8 @@ func HandleManageUI(
return
}
notifier.RequestScreenshot(screen.Slug)
// K2: Tenant-Isolation — nur eigener Tenant oder Admin.
if !requireScreenAccess(w, r, screen) {
return
@ -320,6 +376,9 @@ func HandleManageUI(
}
}
// M6: CSRF-Token an Template-Daten weitergeben.
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
// Determine back-navigation based on ?from= query parameter.
backLink := "/admin"
backLabel := "← Admin"
@ -334,15 +393,43 @@ func HandleManageUI(
}
}
isAdmin := false
var accessibleScreens []*store.Screen
if u := reqcontext.UserFromContext(r.Context()); u != nil {
switch u.Role {
case "admin":
isAdmin = true
accessibleScreens, _ = screens.ListAll(r.Context())
case "screen_user":
accessibleScreens, _ = screens.GetAccessibleScreens(r.Context(), u.ID)
default:
// tenant_user und ähnliche Rollen: alle Screens des eigenen Tenants.
if u.TenantID != "" {
accessibleScreens, _ = screens.List(r.Context(), u.TenantID)
}
}
}
// M5: Server-Timezone ermitteln — bevorzugt aus TZ-Env-Variable, sonst aus der
// lokalen Zeit-Location des Servers.
serverTimezone := os.Getenv("TZ")
if serverTimezone == "" {
serverTimezone = time.Now().Location().String()
}
renderTemplate(w, t, map[string]any{
"Screen": screen,
"Tenant": tenant,
"Playlist": playlist,
"Items": items,
"Assets": assets,
"AddedAssets": addedAssets,
"BackLink": backLink,
"BackLabel": backLabel,
"Screen": screen,
"Tenant": tenant,
"Playlist": playlist,
"Items": items,
"Assets": assets,
"AddedAssets": addedAssets,
"BackLink": backLink,
"BackLabel": backLabel,
"IsAdmin": isAdmin,
"AccessibleScreens": accessibleScreens,
"ServerTimezone": serverTimezone,
"CSRFToken": csrfToken,
})
}
}
@ -351,14 +438,14 @@ func HandleManageUI(
func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
http.Redirect(w, r, "/admin?tab=screens&msg=error_bad_form", http.StatusSeeOther)
return
}
slug := strings.TrimSpace(r.FormValue("slug"))
name := strings.TrimSpace(r.FormValue("name"))
orientation := r.FormValue("orientation")
if slug == "" || name == "" {
http.Error(w, "slug und name erforderlich", http.StatusBadRequest)
http.Redirect(w, r, "/admin?tab=screens&msg=error_empty", http.StatusSeeOther)
return
}
if orientation == "" {
@ -371,16 +458,20 @@ func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore
}
tenant, err := tenants.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError)
slog.Error("create screen: tenant not found", "event", "create_screen_tenant_not_found",
"tenant_slug", tenantSlug, "err", err)
http.Redirect(w, r, "/admin?tab=screens&msg=error_tenant", http.StatusSeeOther)
return
}
_, err = screens.Create(r.Context(), tenant.ID, slug, name, orientation)
if err != nil {
http.Error(w, "Interner Fehler", http.StatusInternalServerError)
slog.Error("create screen failed", "event", "create_screen_failed",
"tenant_slug", tenantSlug, "slug", slug, "err", err)
http.Redirect(w, r, "/admin?tab=screens&msg=error_exists", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin?msg=added", http.StatusSeeOther)
http.Redirect(w, r, "/admin?tab=screens&msg=added", http.StatusSeeOther)
}
}
@ -389,7 +480,7 @@ func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) h
t := template.Must(template.New("provision").Funcs(tmplFuncs).Parse(provisionTmpl))
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
http.Redirect(w, r, "/admin?tab=screens&msg=error_bad_form", http.StatusSeeOther)
return
}
slug := strings.TrimSpace(r.FormValue("slug"))
@ -399,7 +490,7 @@ func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) h
orientation := r.FormValue("orientation")
if slug == "" || ip == "" {
http.Error(w, "slug und IP-Adresse erforderlich", http.StatusBadRequest)
http.Redirect(w, r, "/admin?tab=screens&msg=error_empty", http.StatusSeeOther)
return
}
if name == "" {
@ -418,13 +509,17 @@ func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) h
}
tenant, err := tenants.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError)
slog.Error("provision screen: tenant not found", "event", "provision_screen_tenant_not_found",
"tenant_slug", tenantSlug, "err", err)
http.Redirect(w, r, "/admin?tab=screens&msg=error_tenant", http.StatusSeeOther)
return
}
screen, err := screens.Upsert(r.Context(), tenant.ID, slug, name, orientation)
if err != nil {
http.Error(w, "Interner Fehler", http.StatusInternalServerError)
slog.Error("provision screen failed", "event", "provision_screen_failed",
"tenant_slug", tenantSlug, "slug", slug, "err", err)
http.Redirect(w, r, "/admin?tab=screens&msg=error_db", http.StatusSeeOther)
return
}

View file

@ -19,8 +19,9 @@ type RouterDeps struct {
MediaStore *store.MediaStore
PlaylistStore *store.PlaylistStore
AuthStore *store.AuthStore
Notifier *mqttnotifier.Notifier
Config config.Config
Notifier *mqttnotifier.Notifier
ScreenshotStore *ScreenshotStore
Config config.Config
UploadDir string
Logger *log.Logger
}
@ -36,6 +37,15 @@ func NewRouter(deps RouterDeps) http.Handler {
})
})
// ── Root redirect ────────────────────────────────────────────────────
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
})
// ── Status / diagnostic UI ───────────────────────────────────────────
mux.HandleFunc("GET /status", handleStatusPage(deps.StatusStore))
mux.HandleFunc("GET /status/{screenId}", handleScreenDetailPage(deps.StatusStore))
@ -58,6 +68,7 @@ func NewRouter(deps RouterDeps) http.Handler {
// ── Player status (existing) ──────────────────────────────────────────
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(deps.StatusStore))
mux.HandleFunc("POST /api/v1/player/screenshot", handlePlayerScreenshot(deps.ScreenshotStore))
mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(deps.StatusStore))
mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(deps.StatusStore))
mux.HandleFunc("DELETE /api/v1/screens/{screenId}/status", handleDeletePlayerStatus(deps.StatusStore))
@ -131,7 +142,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
// ── Admin UI ──────────────────────────────────────────────────────────
mux.Handle("GET /admin",
authAdmin(http.HandlerFunc(manage.HandleAdminUI(d.TenantStore, d.ScreenStore, d.AuthStore))))
authAdmin(http.HandlerFunc(manage.HandleAdminUI(d.TenantStore, d.ScreenStore, d.AuthStore, d.Config))))
mux.Handle("POST /admin/screens/provision",
authAdmin(http.HandlerFunc(manage.HandleProvisionUI(d.TenantStore, d.ScreenStore))))
mux.Handle("POST /admin/screens",
@ -151,8 +162,10 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
// ── Playlist management UI ────────────────────────────────────────────
// authScreen enforces that screen_user only accesses their permitted screens.
mux.Handle("GET /manage",
authOnly(http.HandlerFunc(manage.HandleScreenOverview(d.ScreenStore, notifier, d.Config))))
mux.Handle("GET /manage/{screenSlug}",
authScreen(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore))))
authScreen(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore, d.Config, notifier))))
mux.Handle("POST /manage/{screenSlug}/upload",
authScreen(http.HandlerFunc(manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))))
mux.Handle("POST /manage/{screenSlug}/items",
@ -166,6 +179,10 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
mux.Handle("POST /manage/{screenSlug}/media/{mediaId}/delete",
authScreen(http.HandlerFunc(manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir, notifier))))
// ── Screenshot API ────────────────────────────────────────────────────
mux.Handle("GET /api/v1/screens/{screenId}/screenshot",
authOnly(http.HandlerFunc(handleGetScreenshot(d.ScreenshotStore))))
// ── JSON API — screens ────────────────────────────────────────────────
// Self-registration: no auth (player calls this on startup).
mux.HandleFunc("POST /api/v1/screens/register",
@ -202,7 +219,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
// ── Tenant self-service dashboard ─────────────────────────────────────
mux.Handle("GET /tenant/{tenantSlug}/dashboard",
authTenant(http.HandlerFunc(tenant.HandleTenantDashboard(d.TenantStore, d.ScreenStore, d.MediaStore))))
authTenant(http.HandlerFunc(tenant.HandleTenantDashboard(d.TenantStore, d.ScreenStore, d.MediaStore, d.Config))))
mux.Handle("POST /tenant/{tenantSlug}/upload",
authTenant(http.HandlerFunc(tenant.HandleTenantUpload(d.TenantStore, d.MediaStore, uploadDir))))
mux.Handle("POST /tenant/{tenantSlug}/media/{mediaId}/delete",

View file

@ -0,0 +1,31 @@
package tenant
import (
"crypto/rand"
"encoding/hex"
"net/http"
)
const csrfCookieName = "morz_csrf"
// setCSRFCookie setzt (oder erneuert) den CSRF-Cookie und gibt das Token zurück.
func setCSRFCookie(w http.ResponseWriter, r *http.Request, devMode bool) string {
if c, err := r.Cookie(csrfCookieName); err == nil && c.Value != "" {
return c.Value
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return ""
}
token := hex.EncodeToString(buf)
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: false,
Secure: !devMode,
SameSite: http.SameSiteLaxMode,
MaxAge: 8 * 3600,
})
return token
}

View file

@ -1,10 +1,11 @@
package tenant
const tenantDashTmpl = `<!DOCTYPE html>
<html lang="de">
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>Mein Dashboard morz infoboard</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
@ -25,7 +26,8 @@ const tenantDashTmpl = `<!DOCTYPE html>
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<button class="button is-light is-small" type="submit">Abmelden</button>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button is-white is-outlined is-small" type="submit">Abmelden</button>
</form>
</div>
</div>
@ -38,7 +40,7 @@ const tenantDashTmpl = `<!DOCTYPE html>
<h1 class="title is-4 mb-4">{{.Tenant.Name}}</h1>
{{if .Flash}}
<div class="notification is-success is-light mb-4">
<div class="notification is-success is-light mb-4" role="alert">
<button class="delete" onclick="this.parentElement.remove()"></button>
{{.Flash}}
</div>
@ -61,11 +63,11 @@ const tenantDashTmpl = `<!DOCTYPE html>
{{if .Screens}}
<div class="columns is-multiline">
{{range .Screens}}
<div class="column is-4">
<div class="column is-4-desktop is-6-tablet">
<div class="card">
<div class="card-content">
<p class="title is-5">
{{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}}
<span aria-label="{{if eq .Orientation "portrait"}}Hochformat{{else}}Querformat{{end}}">{{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}}</span>{{/* Fallback: leerer Wert wird als Querformat behandelt */}}
{{.Name}}
</p>
<p class="subtitle is-6 has-text-grey">
@ -147,6 +149,11 @@ const tenantDashTmpl = `<!DOCTYPE html>
</div>
</form>
<div id="upload-error" class="notification is-danger is-light mt-3" style="display:none">
<button class="delete" onclick="this.parentElement.style.display='none'"></button>
<span id="upload-error-text"></span>
</div>
<hr>
<h2 class="title is-5">Vorhandene Medien</h2>
@ -167,13 +174,13 @@ const tenantDashTmpl = `<!DOCTYPE html>
<tr>
<td>{{typeIcon .Type}}</td>
<td>{{.Title}}</td>
<td class="has-text-grey is-size-7">
<td class="has-text-grey">
{{if .SizeBytes}}{{humanSize .SizeBytes}}{{else}}{{end}}
</td>
<td>
<form method="POST"
action="/tenant/{{$.Tenant.Slug}}/media/{{.ID}}/delete"
onsubmit="return confirm('Wirklich löschen?')">
onsubmit="return confirm('Medium löschen? Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.')">
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
</form>
</td>
@ -226,6 +233,16 @@ function toggleUploadFields() {
document.getElementById('url-field').style.display = (t === 'web') ? '' : 'none';
}
// ── Upload-Fehleranzeige ──────────────────────────────────────────────────────
function showUploadError(msg) {
var errDiv = document.getElementById('upload-error');
var errText = document.getElementById('upload-error-text');
if (!errDiv || !errText) return;
errText.textContent = msg;
errDiv.style.display = 'block';
setTimeout(function() { errDiv.style.display = 'none'; }, 8000);
}
// ── Upload-Fortschrittsbalken ─────────────────────────────────────────────────
(function() {
var form = document.getElementById('upload-form');
@ -237,9 +254,11 @@ function toggleUploadFields() {
if (!file) return;
e.preventDefault();
var wrap = document.getElementById('upload-progress-wrap');
var bar = document.getElementById('upload-progress');
var btn = document.getElementById('upload-btn');
var wrap = document.getElementById('upload-progress-wrap');
var bar = document.getElementById('upload-progress');
var btn = document.getElementById('upload-btn');
var errDiv = document.getElementById('upload-error');
if (errDiv) errDiv.style.display = 'none';
wrap.style.display = 'block';
btn.disabled = true;
btn.textContent = 'Lädt hoch';
@ -256,14 +275,14 @@ function toggleUploadFields() {
if (xhr.status >= 200 && xhr.status < 400) {
window.location.href = window.location.pathname + '?tab=media&flash=uploaded';
} else {
alert('Upload fehlgeschlagen: ' + xhr.responseText);
showUploadError('Upload fehlgeschlagen: ' + xhr.responseText);
btn.disabled = false;
btn.textContent = 'Hochladen';
wrap.style.display = 'none';
}
});
xhr.addEventListener('error', function() {
alert('Netzwerkfehler beim Upload.');
showUploadError('Netzwerkfehler beim Upload.');
btn.disabled = false;
btn.textContent = 'Hochladen';
wrap.style.display = 'none';

View file

@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/fileutil"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
@ -48,6 +49,7 @@ func HandleTenantDashboard(
tenantStore *store.TenantStore,
screenStore *store.ScreenStore,
mediaStore *store.MediaStore,
cfg config.Config,
) http.HandlerFunc {
t := template.Must(template.New("tenant-dash").Funcs(tmplFuncs).Parse(tenantDashTmpl))
return func(w http.ResponseWriter, r *http.Request) {
@ -94,13 +96,16 @@ func HandleTenantDashboard(
}
}
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
// W7: Template in Buffer rendern, erst bei Erfolg an Client senden.
var buf bytes.Buffer
if err := t.Execute(&buf, map[string]any{
"Tenant": tenant,
"Screens": screens,
"Assets": assets,
"Flash": flash,
"Tenant": tenant,
"Screens": screens,
"Assets": assets,
"Flash": flash,
"CSRFToken": csrfToken,
}); err != nil {
http.Error(w, "Interner Fehler (Template)", http.StatusInternalServerError)
return