Compare commits

...

8 commits

Author SHA1 Message Date
Jesko Anschütz
5d232b34cd docs: Ansible-Playbook-Schritte und X11-Abhängigkeiten dokumentieren
Screenshot-Tools (scrot, imagemagick, x11-apps) und DISPLAY/XAUTHORITY
im systemd-Service in DEVELOPMENT.md ergänzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:58:11 +01:00
Jesko Anschütz
cfc450a9e7 fix(ansible): DISPLAY und XAUTHORITY im morz-agent-Service setzen
scrot braucht X11-Zugang für Screenshots. Der systemd-Service hatte
keine DISPLAY-Variable, weshalb alle Screenshot-Versuche fehlschlugen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:44:55 +01:00
Jesko Anschütz
15c159456a fix(ansible): Screenshot-Dependencies und MQTT-Variablennamen korrigieren
scrot, imagemagick und x11-apps werden jetzt automatisch auf allen
signage_players installiert. Außerdem MQTT_USER/PASS in compose auf
MQTT_USERNAME/PASSWORD korrigiert (passt zu den Backend-Env-Var-Namen).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:22:52 +01:00
Jesko Anschütz
6084712800 feat(mqtt): MQTT-Config per Heartbeat-Response vom Server an Agents übertragen
Server gibt bei POST /api/v1/player/status jetzt mqtt-Block zurück (broker,
username, password) wenn MORZ_INFOBOARD_MQTT_BROKER gesetzt ist. Agents
parsen die Response und verbinden sich bei Config-Änderung automatisch neu
(applyMQTTConfig mit Reconnect-Logik, thread-safe via Mutex).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:03:15 +01:00
Jesko Anschütz
1357dbe773 fix(ansible): morz-agent nach Binary-Build neu starten
go build hatte changed_when: true aber kein notify — Handler wurde
nie ausgelöst, neues Binary blieb ohne Dienst-Neustart wirkungslos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:43:43 +01:00
Jesko Anschütz
b73da77835 feat(screens): Screen-Übersicht mit On-Demand-Screenshots für Multi-Screen-User
- GET /manage: neue Übersichtsseite mit Bulma-Karten für screen_user mit ≥2 Screens
- handleScreenUserRedirect leitet bei ≥2 Screens auf /manage statt auf ersten Screen
- On-Demand-Screenshot-Flow via MQTT:
  - Backend publiziert signage/screen/{slug}/screenshot-request beim Seitenaufruf
  - Player-Agent empfängt Topic, ruft TakeAndSendOnce() auf
  - Player POST /api/v1/player/screenshot → Backend speichert in ScreenshotStore (RAM)
  - GET /api/v1/screens/{screenId}/screenshot liefert gespeichertes Bild (authOnly)
- ScreenshotStore: In-Memory, thread-safe, kein Persistenz-Overhead
- JS-Retry nach 4s in Templates (Screenshot braucht 1-3s für MQTT-Roundtrip)
- manageTmpl zeigt Screenshot-Thumbnail beim Einzelscreen-Aufruf
- Doku: neue Endpoints, MQTT-Topics, Screenshot-Flow in SERVER-KONZEPT.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:27:10 +01:00
Jesko Anschütz
47f65da228 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>
2026-03-24 14:26:52 +01:00
Jesko Anschütz
097cd58c0c docs: Dokumentation validiert und korrigiert
- SCHEMA.md: screen_id in user_screen_permissions von uuid auf text korrigiert
- SCHEMA.md: Phasen-Hinweis zur screens-Tabelle hinzugefügt (6 Spalten aktuell implementiert, erweiterte Phase 2-3)
- SERVER-KONZEPT.md: RequireScreenAccess-Middleware dokumentiert inkl. Route-Gruppen und Verhaltens-Details
- server/backend/README.md: Env-Variable DATABASE_URL → MORZ_INFOBOARD_DATABASE_URL korrigiert
- DEVELOPMENT.md: Compose-Stack von "später" auf "existiert bereits" aktualisiert
- API-ENDPOINTS.md: HandlePlayerPlaylist Response um fehlende Felder ergänzt (playlist_id, media_asset_id, order_index, created_at)
- DEVELOPMENT.md: Architekturentscheidungen präzisiert (message_wall=implementiert, Kampagnen=geplant)

Co-Authored-By: Klaus <noreply@example.com>
2026-03-24 00:10:50 +01:00
37 changed files with 1214 additions and 213 deletions

View file

@ -26,7 +26,7 @@ Projektwurzel:
- `server/backend/` fuer das zentrale Go-Backend
- `player/agent/` fuer den Go-basierten Player-Agent
- `ansible/` fuer Deployment und Provisionierung
- `compose/` spaeter fuer den zentralen Server-Stack
- `compose/` fuer den zentralen Server-Stack
## Aktueller Entwicklungsstand
@ -275,10 +275,11 @@ Das Playbook erledigt:
1. Agent-Binary cross-kompilieren (lokal, `GOOS=linux GOARCH=arm64`)
2. Binary und Konfiguration auf den Zielrechner uebertragen
3. systemd-Unit fuer den Agent anlegen und starten
4. journald auf RAM-Speicherung konfigurieren (SD-Karte schonen)
5. X11-Paketstack und Chromium installieren
6. Kiosk-Startskript und systemd-Unit fuer die Anzeige anlegen
3. Screenshot-Abhaengigkeiten installieren (`scrot`, `imagemagick`, `x11-apps`)
4. systemd-Unit fuer den Agent anlegen und starten (inkl. `DISPLAY=:0` und `XAUTHORITY` fuer X11-Zugriff)
5. journald auf RAM-Speicherung konfigurieren (SD-Karte schonen)
6. X11-Paketstack und Chromium installieren
7. Kiosk-Startskript und systemd-Unit fuer die Anzeige anlegen
### Rollen
@ -287,8 +288,8 @@ Das Playbook erledigt:
## Aktuelle Architekturentscheidungen mit direkter Auswirkung auf Entwicklung
- `message_wall` wird serverseitig in konkrete Screen-Szenen aufgeloest
- Kampagnengruppen werden serverseitig in konkrete Screen-Assignments expandiert
- `message_wall` wird serverseitig in konkrete Screen-Szenen aufgeloest (implementiert)
- Kampagnengruppen werden serverseitig in konkrete Screen-Assignments expandiert (geplant)
- `playlist_items` haben im finalen Implementierungsschema keinen direkten `screen_id`-Fremdschluessel
- Provisionierung wird worker-/jumphost-faehig geplant
- API-Fehler sollen einen einheitlichen Fehlerumschlag nutzen
@ -379,6 +380,8 @@ Die Datei `/tmp/screen-status.json` enthaelt nach dem ersten Heartbeat den persi
**Infrastruktur und Deployment:**
- vollstaendiger Compose-Stack (`compose/server-stack.yml`) mit PostgreSQL 17, Mosquitto, Backend-Service und Health-Checks
- Ansible-Rollen `signage_player` und `signage_display` fuer vollstaendiges Deployment
- `signage_player`-Rolle installiert Screenshot-Tools (`scrot`, `imagemagick`, `x11-apps`) automatisch
- systemd-Unit des Agents setzt `DISPLAY=:0` und `XAUTHORITY` fuer X11-Zugriff bei Screenshots
- journald auf volatile Storage konfiguriert (SD-Karte schonen)
- Cross-Compile fuer ARM64 im Ansible-Playbook
- systemd-Units fuer Agent und Kiosk-Display

View file

@ -1,5 +1,5 @@
---
morz_server_base_url: "http://10.0.0.70:8080"
morz_server_base_url: "http://192.168.64.1:8080"
morz_mqtt_broker: "tcp://dockerbox.morz.de:1883"
morz_heartbeat_every_seconds: 30
morz_status_report_every_seconds: 60

View file

@ -5,6 +5,9 @@ all:
hosts:
info10:
info01-dev:
info11-dev:
info12-dev:
debi:
signage_servers:
hosts:
dockerbox:

View file

@ -5,8 +5,8 @@ signage_timezone: "Europe/Berlin"
signage_base_packages:
- curl
- ca-certificates
- rsync
- htop
- vim-tiny
- bash-completion
- ntp
- rsync
- chrony

View file

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

View file

@ -1,9 +1,12 @@
---
- name: Update apt cache and upgrade installed packages
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
become: true
- name: Upgrade installed packages
ansible.builtin.apt:
upgrade: dist
cache_valid_time: 3600
become: true
- name: Install base packages
@ -16,11 +19,18 @@
community.general.timezone:
name: "{{ signage_timezone }}"
become: true
notify: Restart cron
- name: Ensure NTP service is enabled and running
- name: Disable systemd-timesyncd if present (chrony replaces it)
ansible.builtin.systemd:
name: ntp
name: systemd-timesyncd
enabled: false
state: stopped
become: true
failed_when: false
- name: Ensure chrony NTP service is enabled and running
ansible.builtin.systemd:
name: chrony
enabled: true
state: started
become: true

View file

@ -3,7 +3,7 @@ signage_user: morz
signage_config_dir: /etc/signage
signage_binary_dest: /usr/local/bin/morz-agent
morz_server_base_url: "http://10.0.0.70:8080"
morz_server_base_url: "http://192.168.64.1:8080"
morz_mqtt_broker: ""
morz_mqtt_username: ""
morz_mqtt_password: ""

View file

@ -8,6 +8,7 @@
GOARCH: arm64
delegate_to: localhost
changed_when: true
notify: Restart morz-agent
- name: Ensure signage user exists
ansible.builtin.user:
@ -16,6 +17,15 @@
state: present
become: true
- name: Install screenshot tools for morz-agent
ansible.builtin.apt:
name:
- scrot
- imagemagick
- x11-apps
state: present
become: true
- name: Ensure config directory exists
ansible.builtin.file:
path: "{{ signage_config_dir }}"

View file

@ -6,6 +6,8 @@ Wants=network-online.target
[Service]
Type=simple
User={{ signage_user }}
Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/{{ signage_user }}/.Xauthority
ExecStart={{ signage_binary_dest }}
Restart=on-failure
RestartSec=10

View file

@ -4,7 +4,7 @@
signage_admin_token: ""
# Server base URL reachable from the Ansible controller
signage_server_base_url: "http://10.0.0.70:8080"
signage_server_base_url: "http://192.168.64.1:8080"
# SSH public key to deploy to the signage user
signage_ssh_public_key: ""

View file

@ -32,9 +32,12 @@ services:
MORZ_INFOBOARD_HTTP_ADDR: ":8080"
MORZ_INFOBOARD_DATABASE_URL: "postgres://morz_infoboard:morz_infoboard@postgres:5432/morz_infoboard?sslmode=disable"
MORZ_INFOBOARD_UPLOAD_DIR: "/uploads"
MORZ_INFOBOARD_MQTT_BROKER: "tcp://mosquitto:1883"
MORZ_INFOBOARD_MQTT_BROKER: "${MORZ_INFOBOARD_MQTT_BROKER}"
MORZ_INFOBOARD_MQTT_USERNAME: "${MORZ_INFOBOARD_MQTT_USERNAME}"
MORZ_INFOBOARD_MQTT_PASSWORD: "${MORZ_INFOBOARD_MQTT_PASSWORD}"
MORZ_INFOBOARD_ADMIN_PASSWORD: "${MORZ_INFOBOARD_ADMIN_PASSWORD}"
MORZ_INFOBOARD_DEV_MODE: "${MORZ_INFOBOARD_DEV_MODE:-false}"
TZ: "Europe/Berlin"
MORZ_INFOBOARD_DEFAULT_TENANT: "${MORZ_INFOBOARD_DEFAULT_TENANT:-morz}"
volumes:
- uploads:/uploads

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
@ -88,6 +89,24 @@ Der Player-Agent sendet seinen aktuellen Status an den Server.
{ "status": "accepted" }
```
Wenn auf dem Server ein MQTT-Broker konfiguriert ist (`MORZ_INFOBOARD_MQTT_BROKER`), enthält die Response zusätzlich ein `mqtt`-Objekt mit den Verbindungsdaten. Der Agent soll diese Konfiguration übernehmen und seine MQTT-Verbindung bei Bedarf neu aufbauen.
```json
{
"status": "accepted",
"mqtt": {
"broker": "tcp://mqtt.example.com:1883",
"username": "agent",
"password": "secret"
}
}
```
- `mqtt` — nur vorhanden, wenn ein Broker konfiguriert ist (omitempty); fehlt das Feld, bleibt die bestehende MQTT-Konfiguration des Agents unverändert
- `mqtt.broker` — MQTT-Broker-URL (immer gesetzt, wenn `mqtt` vorhanden)
- `mqtt.username` — Benutzername (nur wenn konfiguriert, omitempty)
- `mqtt.password` — Passwort (nur wenn konfiguriert, omitempty)
---
## Screen Management (JSON API)
@ -204,23 +223,31 @@ Der Player ruft diesen Endpoint auf, um die aktuellen Inhalte zu laden.
"items": [
{
"id": "uuid...",
"playlist_id": "uuid...",
"media_asset_id": null,
"order_index": 0,
"type": "web",
"src": "http://example.com/page1",
"title": "Startseite",
"duration_seconds": 30,
"enabled": true,
"valid_from": null,
"valid_until": null
"valid_until": null,
"created_at": "2026-03-22T16:00:00Z"
},
{
"id": "uuid...",
"playlist_id": "uuid...",
"media_asset_id": "uuid...",
"order_index": 1,
"type": "image",
"src": "/uploads/banner.jpg",
"title": "Werbebanner",
"duration_seconds": 20,
"enabled": true,
"valid_from": null,
"valid_until": null
"valid_until": null,
"created_at": "2026-03-22T16:00:00Z"
}
]
}
@ -511,6 +538,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.
@ -528,9 +568,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)
@ -547,6 +591,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
@ -554,6 +607,7 @@ Meldet den aktuellen Benutzer ab.
**Status:**
- `303 See Other`
- `403 Forbidden` — CSRF-Token fehlt oder ungueltig
---
@ -779,6 +833,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.
@ -1024,19 +1093,62 @@ 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):** MQTT-Konfiguration in POST /api/v1/player/status Response dokumentiert (Doris / Doku-Review)
- Response enthält jetzt optionales `mqtt`-Objekt mit `broker`, `username`, `password` (alle omitempty wenn leer)
- Feld wird nur gesendet wenn `MORZ_INFOBOARD_MQTT_BROKER` konfiguriert ist
- Agent übernimmt die Konfiguration und reconnectet MQTT bei Änderung
- **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

@ -76,7 +76,7 @@ Spalten:
```sql
id uuid primary key
user_id text not null references users(id) on delete cascade
screen_id uuid not null references screens(id) on delete cascade
screen_id text not null references screens(id) on delete cascade
created_at timestamptz not null default now()
unique(user_id, screen_id)
```
@ -139,6 +139,8 @@ Zweck:
- physische Displays bzw. Player-Geraete
**Hinweis:** Die Spalten unten beschreiben das geplante Schema (Phase 2-3). Der aktuelle Code (Migration `001_core.sql`) implementiert nur eine Teilmenge: `id`, `tenant_id`, `slug`, `name`, `orientation`, `created_at` (6 Spalten). Weitere Felder werden in späteren Phasen hinzugefügt.
Spalten:
```sql

View file

@ -91,6 +91,16 @@ Aufgaben:
- Events
- Kommandos und ACKs
### MQTT-Topics (implementiert)
| Topic | Publisher | Subscriber | Beschreibung |
|----------------------------------------------|------------|---------------|---------------------------------------------------|
| `signage/screen/{slug}/playlist-changed` | Backend | Player-Agent | Benachrichtigung bei Playlist-Aenderung; Backend debounced 2 s |
| `signage/screen/{slug}/screenshot-request` | Backend | Player-Agent | Fordert sofortigen On-Demand-Screenshot an |
Der Backend-`Notifier` (`internal/mqttnotifier/notifier.go`) veroeffentlicht beide Topics.
Der Player-`Subscriber` (`player/agent/internal/mqttsubscriber/subscriber.go`) abonniert beide Topics fuer den eigenen Screen-Slug. Auf ein `screenshot-request`-Signal ruft der Agent `Screenshotter.TakeAndSendOnce(ctx)` auf und laedt das Bild direkt per `POST /api/v1/player/screenshot` hoch.
### Dateispeicher
Aufgaben:
@ -210,9 +220,16 @@ Der Server speichert:
- letzten bekannten Heartbeat
- letzten Status
- letzten Screenshot
- letzten Screenshot (In-Memory, nicht persistiert)
- aktuelle Inhaltsquelle pro Screen
### Screenshot-Flow
1. Der Player-Agent sendet periodisch (Intervall: `MORZ_INFOBOARD_SCREENSHOT_EVERY`) einen Screenshot per `POST /api/v1/player/screenshot` (Multipart, kein Auth).
2. Alternativ kann das Backend per MQTT-Topic `signage/screen/{slug}/screenshot-request` einen On-Demand-Screenshot anfordern (`Notifier.RequestScreenshot(slug)`). Der Player-Agent empfaengt das Signal und ruft `Screenshotter.TakeAndSendOnce(ctx)` auf.
3. Das Backend speichert den Screenshot im `ScreenshotStore` (In-Memory, keyed by `screen_id`). Pro Screen wird nur der jeweils neueste Screenshot gehalten.
4. Eingeloggte Benutzer koennen den Screenshot unter `GET /api/v1/screens/{screenId}/screenshot` abrufen (`authOnly`). Der Response-Header enthaelt den vom Player gemeldeten MIME-Typ sowie `Cache-Control: no-store`.
Die Admin-UI soll damit erkennen:
- online/offline
@ -291,24 +308,64 @@ Eingehende Anfrage
├─► RequireAdmin Prueft user.Role == "admin"
│ → Fehler: 403 Forbidden
└─► RequireTenant Prueft user.TenantSlug == {tenantSlug} aus dem URL-Pfad.
Access Admins duerfen immer durch.
→ Fehler: 403 Forbidden
├─► RequireTenant Prueft user.TenantSlug == {tenantSlug} aus dem URL-Pfad.
│ Access Admins duerfen immer durch.
│ → Fehler: 403 Forbidden
└─► RequireScreen Enforces per-screen access control.
Access Admins duerfen auf alle Screens zugreifen.
Screen-User brauchen expliziten Eintrag in `user_screen_permissions`.
Tenant-User duerfen auf alle Screens ihres Tenants zugreifen.
→ Fehler: 404 Not Found (Screen) oder 403 Forbidden (kein Zugriff)
```
### Route-Gruppen im Router
| Gruppe | Middleware | Beispielrouten |
|----------------|------------------------------------|---------------------------------------------|
| Oeffentlich | keine | `/healthz`, `/login`, `/api/v1/screens/register` |
| Auth-only | RequireAuth | `/manage/{screenSlug}/...` |
| Admin-only | RequireAuth + RequireAdmin | `/admin`, `/admin/screens/...` |
| Tenant-scoped | RequireAuth + RequireTenantAccess | `/tenant/{tenantSlug}/...`, `/api/v1/tenants/{tenantSlug}/...` |
| Gruppe | Middleware | Beispielrouten |
|----------------|----------------------------------------------------|---------------------------------------------|
| Oeffentlich | keine | `/healthz`, `/login`, `/api/v1/screens/register` |
| Auth-only | RequireAuth | `/api/v1/items/{itemId}`, `/api/v1/media/{id}` |
| Admin-only | RequireAuth + RequireAdmin | `/admin`, `/admin/screens/...` |
| Tenant-scoped | RequireAuth + RequireTenantAccess | `/tenant/{tenantSlug}/...`, `/api/v1/tenants/{tenantSlug}/...` |
| Screen-scoped | RequireAuth + RequireScreenAccess | `/manage/{screenSlug}/...`, `/api/v1/screens/{screenId}/playlist` |
Der Hilfsfunktion `chain(middlewares...)` in `router.go` wrappet Handler von aussen nach innen.
---
## RequireScreenAccess Middleware
Die Middleware `RequireScreenAccess` erzwingt Zugriffskontrolle auf Screen-Ressourcen und wird ausschliesslich fuer Routen verwendet, deren Handler screen-spezifische Operationen durchfuehren.
**Verhalten:**
- **Admin-User**: duerfen auf alle Screens zugreifen (Bypass).
- **Screen-User**: duerfen nur auf Screens zugreifen, fuer die sie einen expliziten Eintrag in `user_screen_permissions` haben.
- **Tenant-User**: duerfen auf alle Screens ihres Tenants zugreifen (noch nicht vollstaendig implementiert).
**Implementierung:**
Die Middleware extrahiert den `screenSlug` aus dem URL-Pfad-Parameter, schlaegt den Screen in der Datenbank auf und prueft via `ScreenStore.HasUserScreenAccess()`, ob der Nutzer Zugriff hat. Admins umgehen diese Pruefung.
**Fehlerbehandlung:**
- Screen nicht gefunden: `404 Not Found`
- Kein Zugriff auf Screen: `403 Forbidden`
**Verwendung in Router:**
Die `authScreen`-Middleware-Kombination wird fuer folgende Routes verwendet:
- `GET /manage/{screenSlug}` — Playlist-Management-UI
- `POST /manage/{screenSlug}/upload` — Medium hochladen
- `POST /manage/{screenSlug}/items` — Item hinzufuegen
- `POST /manage/{screenSlug}/items/{itemId}` — Item aktualisieren
- `POST /manage/{screenSlug}/items/{itemId}/delete` — Item loeschen
- `POST /manage/{screenSlug}/reorder` — Items reordnen
- `POST /manage/{screenSlug}/media/{mediaId}/delete` — Medium loeschen
---
## Tenant-Dashboard
Das Tenant-Self-Service-Dashboard ist unter `/tenant/{tenantSlug}/dashboard` erreichbar.

View file

@ -62,6 +62,11 @@ type App struct {
startedAt time.Time
lastHeartbeatAt time.Time
// mqttMu guards mqttPub and mqttSub as well as the MQTT fields in Config
// (Config.MQTTBroker, Config.MQTTUsername, Config.MQTTPassword).
mqttMu sync.Mutex
mqttSub mqttCloser
// Playlist fetched from the backend (protected by playlistMu).
playlistMu sync.RWMutex
playlist []playerserver.PlaylistItem
@ -70,10 +75,18 @@ type App struct {
// arrives (after debouncing in the subscriber). pollPlaylist listens on
// this channel to trigger an immediate fetchPlaylist call.
mqttFetchC chan struct{}
// screenshotFn is kept so that applyMQTTConfig can pass it to the new subscriber.
screenshotFn func()
}
// mqttCloser is implemented by mqttsubscriber.Subscriber.
type mqttCloser interface {
Close()
}
type statusSender interface {
Send(ctx context.Context, snapshot statusreporter.Snapshot) error
Send(ctx context.Context, snapshot statusreporter.Snapshot) (statusreporter.MQTTConfig, error)
}
type mqttSender interface {
@ -199,7 +212,16 @@ func (a *App) Run(ctx context.Context) error {
// Self-register this screen in the backend (best-effort, non-blocking).
go a.registerScreen(ctx)
// Subscribe to playlist-changed MQTT notifications (optional; fallback = polling).
// Screenshot-Instanz immer anlegen (für periodische und On-Demand-Screenshots).
ss := screenshot.New(a.Config.ScreenID, a.Config.ServerBaseURL, a.Config.ScreenshotEvery, a.logger)
// Keep screenshot callback so applyMQTTConfig can hand it to new subscribers.
a.screenshotFn = func() {
a.logger.Printf("event=mqtt_screenshot_request screen_id=%s", a.Config.ScreenID)
go ss.TakeAndSendOnce(ctx)
}
// Subscribe to playlist-changed and screenshot-request MQTT notifications (optional; fallback = polling).
sub := mqttsubscriber.New(
a.Config.MQTTBroker,
a.Config.ScreenID,
@ -213,11 +235,15 @@ func (a *App) Run(ctx context.Context) error {
}
a.logger.Printf("event=mqtt_playlist_notification screen_id=%s", a.Config.ScreenID)
},
a.screenshotFn,
)
a.mqttMu.Lock()
a.mqttSub = sub
a.mqttMu.Unlock()
if sub != nil {
a.logger.Printf("event=mqtt_subscriber_enabled broker=%s screen_id=%s topic=%s",
a.Config.MQTTBroker, a.Config.ScreenID, mqttsubscriber.Topic(a.Config.ScreenID))
defer sub.Close()
a.logger.Printf("event=mqtt_subscriber_enabled broker=%s screen_id=%s topic=%s screenshot_topic=%s",
a.Config.MQTTBroker, a.Config.ScreenID, mqttsubscriber.Topic(a.Config.ScreenID),
mqttsubscriber.ScreenshotRequestTopic(a.Config.ScreenID))
}
// Start polling the backend for playlist updates (60 s fallback + MQTT trigger).
@ -225,7 +251,6 @@ func (a *App) Run(ctx context.Context) error {
// Phase 6: Periodische Screenshot-Erzeugung, wenn konfiguriert.
if a.Config.ScreenshotEvery > 0 {
ss := screenshot.New(a.Config.ScreenID, a.Config.ServerBaseURL, a.Config.ScreenshotEvery, a.logger)
go ss.Run(ctx)
a.logger.Printf("event=screenshot_enabled screen_id=%s interval_seconds=%d",
a.Config.ScreenID, a.Config.ScreenshotEvery)
@ -249,9 +274,14 @@ func (a *App) Run(ctx context.Context) error {
a.mu.Lock()
a.status = StatusStopped
a.mu.Unlock()
a.mqttMu.Lock()
if a.mqttPub != nil {
a.mqttPub.Close()
}
if a.mqttSub != nil {
a.mqttSub.Close()
}
a.mqttMu.Unlock()
a.logger.Printf("event=agent_stopped screen_id=%s", a.Config.ScreenID)
return nil
case <-ticker.C:
@ -404,7 +434,7 @@ func (a *App) reportStatus(ctx context.Context) {
payloadConnectivity = ConnectivityOnline
}
err := a.reporter.Send(ctx, statusreporter.Snapshot{
mqttCfg, err := a.reporter.Send(ctx, statusreporter.Snapshot{
Status: string(snapshot.Status),
ServerConnectivity: string(payloadConnectivity),
ScreenID: snapshot.ScreenID,
@ -430,4 +460,59 @@ func (a *App) reportStatus(ctx context.Context) {
a.consecutiveReportFailures = 0
a.serverConnectivity = ConnectivityOnline
a.mu.Unlock()
// Apply MQTT config from server response if broker was provided.
if mqttCfg.Broker != "" {
a.applyMQTTConfig(mqttCfg.Broker, mqttCfg.Username, mqttCfg.Password)
}
}
// applyMQTTConfig checks whether the MQTT configuration has changed and, if so,
// gracefully stops the existing MQTT clients and starts new ones.
// It is safe for concurrent use — all MQTT client mutations are protected by mqttMu.
func (a *App) applyMQTTConfig(broker, username, password string) {
a.mqttMu.Lock()
defer a.mqttMu.Unlock()
// Nothing to do when the config is unchanged.
if a.Config.MQTTBroker == broker &&
a.Config.MQTTUsername == username &&
a.Config.MQTTPassword == password {
return
}
a.logger.Printf("event=mqtt_config_changed screen_id=%s old_broker=%s new_broker=%s",
a.Config.ScreenID, a.Config.MQTTBroker, broker)
// Stop existing clients.
if a.mqttPub != nil {
a.mqttPub.Close()
a.mqttPub = nil
}
if a.mqttSub != nil {
a.mqttSub.Close()
a.mqttSub = nil
}
// Update stored config.
a.Config.MQTTBroker = broker
a.Config.MQTTUsername = username
a.Config.MQTTPassword = password
// Start new clients.
a.mqttPub = mqttheartbeat.New(broker, a.Config.ScreenID, username, password)
a.logger.Printf("event=mqtt_heartbeat_restarted screen_id=%s broker=%s", a.Config.ScreenID, broker)
playlistChangedFn := func() {
select {
case a.mqttFetchC <- struct{}{}:
default:
}
a.logger.Printf("event=mqtt_playlist_notification screen_id=%s", a.Config.ScreenID)
}
sub := mqttsubscriber.New(broker, a.Config.ScreenID, username, password, playlistChangedFn, a.screenshotFn)
a.mqttSub = sub
if sub != nil {
a.logger.Printf("event=mqtt_subscriber_restarted screen_id=%s broker=%s", a.Config.ScreenID, broker)
}
}

View file

@ -19,15 +19,15 @@ type recordingReporter struct {
snapshots []statusreporter.Snapshot
}
func (r *recordingReporter) Send(_ context.Context, snapshot statusreporter.Snapshot) error {
func (r *recordingReporter) Send(_ context.Context, snapshot statusreporter.Snapshot) (statusreporter.MQTTConfig, error) {
r.callCount++
r.snapshots = append(r.snapshots, snapshot)
if len(r.errs) > 0 {
err := r.errs[0]
r.errs = r.errs[1:]
return err
return statusreporter.MQTTConfig{}, err
}
return r.err
return statusreporter.MQTTConfig{}, r.err
}
func TestAppRunUpdatesHealthAndLogsStructuredEvents(t *testing.T) {

View file

@ -17,21 +17,29 @@ const (
// playlistChangedTopicTemplate is the topic the backend publishes to.
playlistChangedTopic = "signage/screen/%s/playlist-changed"
// screenshotRequestTopicTemplate is the topic the backend publishes to for on-demand screenshots.
screenshotRequestTopicTemplate = "signage/screen/%s/screenshot-request"
)
// PlaylistChangedFunc is called when a debounced playlist-changed notification arrives.
type PlaylistChangedFunc func()
// ScreenshotRequestFunc is called when a screenshot-request notification arrives.
type ScreenshotRequestFunc func()
// Subscriber listens for playlist-changed notifications on MQTT and calls the
// provided callback at most once per debounceDuration.
type Subscriber struct {
client mqtt.Client
timer *time.Timer
onChange PlaylistChangedFunc
client mqtt.Client
timer *time.Timer
onChange PlaylistChangedFunc
onScreenshotRequest ScreenshotRequestFunc
// timerC serializes timer resets through a dedicated goroutine.
resetC chan struct{}
stopC chan struct{}
resetC chan struct{}
screenshotReqC chan struct{}
stopC chan struct{}
}
// Topic returns the MQTT topic for a given screenSlug.
@ -39,23 +47,32 @@ func Topic(screenSlug string) string {
return "signage/screen/" + screenSlug + "/playlist-changed"
}
// ScreenshotRequestTopic returns the MQTT topic for on-demand screenshot requests for a given screenSlug.
func ScreenshotRequestTopic(screenSlug string) string {
return "signage/screen/" + screenSlug + "/screenshot-request"
}
// New creates a Subscriber that connects to broker and subscribes to the
// playlist-changed topic for screenSlug. onChange is called (in its own
// goroutine) at most once per debounceDuration.
// onScreenshotRequest is called (in its own goroutine) when a screenshot-request message arrives.
//
// Returns nil when broker is empty — callers must handle nil.
func New(broker, screenSlug, username, password string, onChange PlaylistChangedFunc) *Subscriber {
func New(broker, screenSlug, username, password string, onChange PlaylistChangedFunc, onScreenshotRequest ScreenshotRequestFunc) *Subscriber {
if broker == "" {
return nil
}
s := &Subscriber{
onChange: onChange,
resetC: make(chan struct{}, 16),
stopC: make(chan struct{}),
onChange: onChange,
onScreenshotRequest: onScreenshotRequest,
resetC: make(chan struct{}, 16),
screenshotReqC: make(chan struct{}, 16),
stopC: make(chan struct{}),
}
topic := Topic(screenSlug)
screenshotTopic := ScreenshotRequestTopic(screenSlug)
opts := mqtt.NewClientOptions().
AddBroker(broker).
@ -72,6 +89,12 @@ func New(broker, screenSlug, username, password string, onChange PlaylistChanged
default: // channel full — debounce timer will fire anyway
}
})
c.Subscribe(screenshotTopic, 0, func(_ mqtt.Client, _ mqtt.Message) { //nolint:errcheck
select {
case s.screenshotReqC <- struct{}{}:
default: // channel full — request already pending
}
})
})
if username != "" {
@ -104,6 +127,10 @@ func (s *Subscriber) run() {
timer = time.AfterFunc(debounceDuration, func() {
go s.onChange()
})
case <-s.screenshotReqC:
if s.onScreenshotRequest != nil {
go s.onScreenshotRequest()
}
}
}
}

View file

@ -78,6 +78,12 @@ func (s *Screenshotter) Run(ctx context.Context) {
}
}
// TakeAndSendOnce macht genau einen Screenshot und lädt ihn hoch.
// Nicht-blockierend gegenüber dem periodischen Loop.
func (s *Screenshotter) TakeAndSendOnce(ctx context.Context) {
s.takeAndSend(ctx)
}
// takeAndSend erzeugt einen Screenshot und sendet ihn an den Server.
func (s *Screenshotter) takeAndSend(ctx context.Context) {
path, err := s.capture()

View file

@ -33,6 +33,27 @@ type statusPayload struct {
LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"`
}
// MQTTConfig holds the MQTT broker configuration returned by the server in the
// status-report response. All fields are empty when the server did not send
// a mqtt object (e.g. MQTT is disabled on the server side).
type MQTTConfig struct {
Broker string
Username string
Password string
}
// serverResponse is the JSON body returned by POST /api/v1/player/status.
type serverResponse struct {
Status string `json:"status"`
MQTT *serverMQTTBlock `json:"mqtt,omitempty"`
}
type serverMQTTBlock struct {
Broker string `json:"broker"`
Username string `json:"username"`
Password string `json:"password"`
}
type Reporter struct {
baseURL string
client *http.Client
@ -55,34 +76,47 @@ func New(baseURL string, client *http.Client, now func() time.Time) *Reporter {
}
}
func (r *Reporter) Send(ctx context.Context, snapshot Snapshot) error {
// Send reports the snapshot to the server and returns the MQTT configuration
// provided by the server in the response body. If the server does not include
// a mqtt object the returned MQTTConfig will have an empty Broker field.
func (r *Reporter) Send(ctx context.Context, snapshot Snapshot) (MQTTConfig, error) {
payload := buildPayload(snapshot, r.now())
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal status payload: %w", err)
return MQTTConfig{}, fmt.Errorf("marshal status payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.baseURL+"/api/v1/player/status", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build status request: %w", err)
return MQTTConfig{}, fmt.Errorf("build status request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
return fmt.Errorf("send status request: %w", err)
return MQTTConfig{}, fmt.Errorf("send status request: %w", err)
}
statusCode := resp.StatusCode
statusText := resp.Status
if err := resp.Body.Close(); err != nil {
return fmt.Errorf("close status response body: %w", err)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return MQTTConfig{}, fmt.Errorf("unexpected status response: %s", resp.Status)
}
if statusCode != http.StatusOK {
return fmt.Errorf("unexpected status response: %s", statusText)
var sr serverResponse
if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
// Non-fatal: decoding failure just means no MQTT config update.
return MQTTConfig{}, nil
}
return nil
if sr.MQTT != nil {
return MQTTConfig{
Broker: sr.MQTT.Broker,
Username: sr.MQTT.Username,
Password: sr.MQTT.Password,
}, nil
}
return MQTTConfig{}, nil
}
func buildPayload(snapshot Snapshot, now time.Time) statusPayload {

View file

@ -70,7 +70,7 @@ func TestReporterSendStatus(t *testing.T) {
return time.Date(2026, 3, 22, 16, 0, 0, 0, time.UTC)
})
err := reporter.Send(context.Background(), Snapshot{
_, err := reporter.Send(context.Background(), Snapshot{
Status: "running",
ServerConnectivity: "online",
ScreenID: "info01-dev",

View file

@ -5,6 +5,7 @@ COPY . .
RUN go build -o /out/backend ./cmd/api
FROM alpine:3.22
RUN apk add --no-cache tzdata
WORKDIR /app
COPY --from=build /out/backend /usr/local/bin/backend
EXPOSE 8080

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`)
@ -132,7 +138,7 @@ Alle Werte per Umgebungsvariable:
| Variable | Bedeutung | Standard |
|-----------------------------------|----------------------------------------------------------|---------------|
| `MORZ_INFOBOARD_HTTP_ADDR` | HTTP-Listen-Adresse | `:8080` |
| `DATABASE_URL` | PostgreSQL-Connection-String | — |
| `MORZ_INFOBOARD_DATABASE_URL` | PostgreSQL-Connection-String | — |
| `MORZ_INFOBOARD_UPLOAD_DIR` | Verzeichnis fuer hochgeladene Medien | `/tmp/morz-uploads` |
| `MORZ_INFOBOARD_STATUS_STORE_PATH`| Pfad zur JSON-Persistenz-Datei fuer Status-Store | leer (in-memory) |
| `MORZ_INFOBOARD_ADMIN_PASSWORD` | Passwort des initialen Admin-Users (leer = kein Anlegen) | leer |

View file

@ -76,6 +76,9 @@ func New() (*App, error) {
// Non-fatal: server starts even if admin setup fails.
}
// Screenshot store (in-memory).
ss := httpapi.NewScreenshotStore()
// MQTT notifier (no-op when broker not configured).
notifier := mqttnotifier.New(cfg.MQTTBroker, cfg.MQTTUsername, cfg.MQTTPassword)
if cfg.MQTTBroker != "" {
@ -85,16 +88,17 @@ func New() (*App, error) {
}
handler := httpapi.NewRouter(httpapi.RouterDeps{
StatusStore: statusStore,
TenantStore: tenants,
ScreenStore: screens,
MediaStore: media,
PlaylistStore: playlists,
AuthStore: authStore,
Notifier: notifier,
Config: cfg,
UploadDir: cfg.UploadDir,
Logger: logger,
StatusStore: statusStore,
TenantStore: tenants,
ScreenStore: screens,
MediaStore: media,
PlaylistStore: playlists,
AuthStore: authStore,
Notifier: notifier,
ScreenshotStore: ss,
Config: cfg,
UploadDir: cfg.UploadDir,
Logger: logger,
})
return &App{

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

@ -379,9 +379,16 @@ func parseOptionalTime(s string) (*time.Time, error) {
if s == "" {
return nil, nil
}
// Accept RFC3339 (API) and datetime-local HTML input format.
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02T15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
// RFC3339 already carries timezone info — use as-is.
if t, err := time.Parse(time.RFC3339, s); err == nil {
return &t, nil
}
// datetime-local HTML inputs ("2006-01-02T15:04" / "2006-01-02T15:04:05") carry
// no timezone. Interpret them as local time so the value the user sees in their
// browser matches what PostgreSQL stores and what NOW() (also local on the DB
// server) is compared against.
for _, layout := range []string{"2006-01-02T15:04:05", "2006-01-02T15:04"} {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return &t, nil
}
}

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

@ -49,7 +49,21 @@ type playerStatusRequest struct {
LastHeartbeatAt string `json:"last_heartbeat_at"`
}
func handlePlayerStatus(store playerStatusStore) http.HandlerFunc {
// playerStatusMQTTConfig is the MQTT configuration returned to agents in the
// status-report response. It is omitted entirely when Broker is empty.
type playerStatusMQTTConfig struct {
Broker string `json:"broker"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
}
// playerStatusResponse is the JSON body returned for POST /api/v1/player/status.
type playerStatusResponse struct {
Status string `json:"status"`
MQTT *playerStatusMQTTConfig `json:"mqtt,omitempty"`
}
func handlePlayerStatus(store playerStatusStore, mqttBroker, mqttUsername, mqttPassword string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var request playerStatusRequest
if err := decodeJSON(r, &request); err != nil {
@ -116,9 +130,15 @@ func handlePlayerStatus(store playerStatusStore) http.HandlerFunc {
LastHeartbeatAt: request.LastHeartbeatAt,
})
writeJSON(w, http.StatusOK, map[string]string{
"status": "accepted",
})
resp := playerStatusResponse{Status: "accepted"}
if mqttBroker != "" {
resp.MQTT = &playerStatusMQTTConfig{
Broker: mqttBroker,
Username: mqttUsername,
Password: mqttPassword,
}
}
writeJSON(w, http.StatusOK, resp)
}
}

View file

@ -26,7 +26,7 @@ func TestHandlePlayerStatusAccepted(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(store)(w, req)
handlePlayerStatus(store, "", "", "")(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -66,7 +66,7 @@ func TestHandlePlayerStatusRejectsInvalidJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString("{"))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -83,7 +83,7 @@ func TestHandlePlayerStatusRejectsMissingScreenID(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -102,7 +102,7 @@ func TestHandlePlayerStatusStoresNormalizedScreenID(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(store)(w, req)
handlePlayerStatus(store, "", "", "")(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -122,7 +122,7 @@ func TestHandlePlayerStatusRejectsMissingTimestamp(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -138,7 +138,7 @@ func TestHandlePlayerStatusRejectsMissingStatus(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -155,7 +155,7 @@ func TestHandlePlayerStatusRejectsUnknownStatus(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -173,7 +173,7 @@ func TestHandlePlayerStatusRejectsUnknownServerConnectivity(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -191,7 +191,7 @@ func TestHandlePlayerStatusRejectsNonPositiveHeartbeatInterval(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -208,7 +208,7 @@ func TestHandlePlayerStatusRejectsMalformedTimestamps(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -226,7 +226,7 @@ func TestHandlePlayerStatusRejectsMalformedStartedAt(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -244,7 +244,7 @@ func TestHandlePlayerStatusRejectsMalformedLastHeartbeatAt(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
handlePlayerStatus(newInMemoryPlayerStatusStore(), "", "", "")(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)

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))
@ -57,7 +67,8 @@ func NewRouter(deps RouterDeps) http.Handler {
mux.HandleFunc("GET /api/v1/meta", handleMeta)
// ── Player status (existing) ──────────────────────────────────────────
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(deps.StatusStore))
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(deps.StatusStore, deps.Config.MQTTBroker, deps.Config.MQTTUsername, deps.Config.MQTTPassword))
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,59 @@
package httpapi
import (
"io"
"net/http"
)
const maxScreenshotSize = 3 << 20 // 3 MB
func handlePlayerScreenshot(store *ScreenshotStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxScreenshotSize)
if err := r.ParseMultipartForm(maxScreenshotSize); err != nil {
http.Error(w, "bad multipart form", http.StatusBadRequest)
return
}
screenID := r.FormValue("screen_id")
if screenID == "" {
http.Error(w, "screen_id required", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("screenshot")
if err != nil {
http.Error(w, "screenshot file required", http.StatusBadRequest)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, "read error", http.StatusInternalServerError)
return
}
mimeType := header.Header.Get("Content-Type")
if mimeType == "" {
mimeType = "image/png"
}
store.Save(screenID, data, mimeType)
w.WriteHeader(http.StatusOK)
}
}
func handleGetScreenshot(store *ScreenshotStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenID := r.PathValue("screenId")
data, mimeType, ok := store.Get(screenID)
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Cache-Control", "no-store")
w.Write(data) //nolint:errcheck
}
}

View file

@ -0,0 +1,33 @@
package httpapi
import "sync"
type screenshotRecord struct {
Data []byte
MimeType string
}
type ScreenshotStore struct {
mu sync.RWMutex
records map[string]screenshotRecord
}
func NewScreenshotStore() *ScreenshotStore {
return &ScreenshotStore{records: make(map[string]screenshotRecord)}
}
func (s *ScreenshotStore) Save(screenID string, data []byte, mimeType string) {
s.mu.Lock()
defer s.mu.Unlock()
s.records[screenID] = screenshotRecord{Data: data, MimeType: mimeType}
}
func (s *ScreenshotStore) Get(screenID string) ([]byte, string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
rec, ok := s.records[screenID]
if !ok {
return nil, "", false
}
return rec.Data, rec.MimeType, true
}

View file

@ -436,12 +436,13 @@ var statusTemplateFuncs = template.FuncMap{
}
var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusTemplateFuncs).Parse(`<!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">
<meta name="color-scheme" content="light">
<meta http-equiv="refresh" content="{{.RefreshSeconds}}">
<title>Bildschirmstatus</title>
<title>Bildschirmstatus morz infoboard</title>
` + statusPageCSSBlock + `
</head>
<body>
@ -509,7 +510,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
</div>
<div class="field">
<label for="server_connectivity">Serverkonnektivität</label>
<label for="server_connectivity">Verbindung zum Server</label>
<select id="server_connectivity" name="server_connectivity">
<option value="" {{if eq .Filters.ServerConnectivity ""}}selected{{end}}>Alle</option>
<option value="online" {{if eq .Filters.ServerConnectivity "online"}}selected{{end}}>Online</option>
@ -520,7 +521,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
</div>
<div class="field">
<label for="stale">Aktualität</label>
<label for="stale">Meldungsalter</label>
<select id="stale" name="stale">
<option value="" {{if eq .Filters.Stale ""}}selected{{end}}>Alle</option>
<option value="true" {{if eq .Filters.Stale "true"}}selected{{end}}>Nur veraltet</option>
@ -529,7 +530,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
</div>
<div class="field">
<label for="derived_state">Abgeleiteter Status</label>
<label for="derived_state">Gesamtstatus</label>
<select id="derived_state" name="derived_state">
<option value="" {{if eq .Filters.DerivedState ""}}selected{{end}}>Alle</option>
<option value="online" {{if eq .Filters.DerivedState "online"}}selected{{end}}>Online</option>
@ -539,8 +540,8 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
</div>
<div class="field full">
<label for="updated_since">Aktualisiert seit (RFC3339)</label>
<input id="updated_since" name="updated_since" type="text" placeholder="2026-03-22T16:05:00Z" value="{{.Filters.UpdatedSince}}">
<label for="updated_since">Aktualisiert seit</label>
<input id="updated_since" name="updated_since" type="datetime-local" value="{{.Filters.UpdatedSince}}">
</div>
<div class="field">
@ -643,16 +644,43 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
updateRelTimes();
setInterval(updateRelTimes, 30000);
})();
// Beim Laden: RFC3339-Wert in datetime-local-Format konvertieren (YYYY-MM-DDTHH:MM)
(function() {
var input = document.getElementById('updated_since');
if (input && input.value) {
var d = new Date(input.value);
if (!isNaN(d)) {
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
input.value = d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
}
})();
// Beim Submit: datetime-local Wert zu RFC3339 konvertieren
(function() {
var form = document.querySelector('form.filter-form');
if (form) {
form.addEventListener('submit', function(e) {
var input = document.getElementById('updated_since');
if (input && input.value) {
input.value = new Date(input.value).toISOString();
}
});
}
})();
</script>
</body>
</html>
`))
var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(statusTemplateFuncs).Parse(`<!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">
<meta name="color-scheme" content="light">
<meta http-equiv="refresh" content="{{.RefreshSeconds}}">
<title>{{.Record.ScreenID}} Bildschirmstatus</title>
` + statusPageCSSBlock + `
@ -662,6 +690,9 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
<section class="hero">
<div class="hero-top">
<div>
<!-- N5: ScreenID (Slug) als Titel. Displayname könnte hier ergänzt werden,
wenn handleScreenDetailPage zusätzlich *store.ScreenStore erhält
und GetBySlug(ctx, screenID) aufruft. -->
<h1>{{.Record.ScreenID}}</h1>
<p class="lead">Detailansicht auf Basis des zuletzt akzeptierten Status-Reports.</p>
</div>
@ -781,10 +812,11 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
`))
var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(statusTemplateFuncs).Parse(`<!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">
<meta name="color-scheme" content="light">
<title>Ungültiger Filter Bildschirmstatus</title>
` + statusPageCSSBlock + `
</head>

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

View file

@ -83,6 +83,18 @@ func (n *Notifier) NotifyChanged(screenSlug string) {
})
}
// RequestScreenshot publishes a screenshot-request message to the screen's MQTT topic.
// It is a no-op when the client is not connected.
func (n *Notifier) RequestScreenshot(screenSlug string) {
if n.client == nil {
return
}
topic := fmt.Sprintf("signage/screen/%s/screenshot-request", screenSlug)
payload := []byte(fmt.Sprintf(`{"ts":%d}`, time.Now().UnixMilli()))
token := n.client.Publish(topic, 0, false, payload)
token.WaitTimeout(3 * time.Second)
}
func (n *Notifier) publish(screenSlug string) {
topic := Topic(screenSlug)
payload := []byte(fmt.Sprintf(`{"ts":%d}`, time.Now().UnixMilli()))