Compare commits
8 commits
bb35594211
...
5d232b34cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d232b34cd | ||
|
|
cfc450a9e7 | ||
|
|
15c159456a | ||
|
|
6084712800 | ||
|
|
1357dbe773 | ||
|
|
b73da77835 | ||
|
|
47f65da228 | ||
|
|
097cd58c0c |
37 changed files with 1214 additions and 213 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ all:
|
|||
hosts:
|
||||
info10:
|
||||
info01-dev:
|
||||
info11-dev:
|
||||
info12-dev:
|
||||
debi:
|
||||
signage_servers:
|
||||
hosts:
|
||||
dockerbox:
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ signage_timezone: "Europe/Berlin"
|
|||
signage_base_packages:
|
||||
- curl
|
||||
- ca-certificates
|
||||
- rsync
|
||||
- htop
|
||||
- vim-tiny
|
||||
- bash-completion
|
||||
- ntp
|
||||
- rsync
|
||||
- chrony
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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 }}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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}} {{.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}} 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>`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
59
server/backend/internal/httpapi/screenshot.go
Normal file
59
server/backend/internal/httpapi/screenshot.go
Normal 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
|
||||
}
|
||||
}
|
||||
33
server/backend/internal/httpapi/screenshot_store.go
Normal file
33
server/backend/internal/httpapi/screenshot_store.go
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
31
server/backend/internal/httpapi/tenant/csrf_helpers.go
Normal file
31
server/backend/internal/httpapi/tenant/csrf_helpers.go
Normal 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
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue