# CANCOM Simple Signage

Browserbasiertes Digital-Signage-System für interne Info-Screens.

---

## Features

- Browserbasierter Player (Kiosk-Modus, Chromium, Firefox)
- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge
- **Multi-Standort**: Standorte (sites) gruppieren Screens
- **Priority-Playlist**: globale Inhalte wirken auf alle Player
- **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch, pro Standort konfigurierbares Hintergrundbild
- Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge
- Auto-Reload bei Playlist-Änderungen
- Newsticker pro Screen
- **Custom-URL-Button**: pro Screen konfigurierbarer Aktions-Button mit frei wählbarer Position (9 Positionen) im Player (öffnet URL in iframe-Overlay mit Zurück-Button oder per Direkt-Weiterleitung)
- **Digital Voice Agent**: global konfigurierbarer Voice-Agent-Button pro Screen (9 Positionen, iframe-Overlay mit positionsgetreuem Zurück-Button, Typewriter-Tagline mit mehrsprachigen Wechseltexten, optionales Bild über dem Button)
- **Stay-on-First**: Screen kann auf erstem Playlist-Element stehen bleiben (kein automatischer Refresh)
- **Online/Offline-Status**: Player-Status wird via dateibasiertem Heartbeat ermittelt – grün (online) bei aktivem Player, rot (offline) nach 60s ohne Poll
- **Client-Info**: Tooltip beim Hovern über den Online-Button zeigt Browser, Bildschirmauflösung, IP-Adresse und letzten Heartbeat-Zeitpunkt; Info-Tab pro Screen zeigt detaillierte Client-Info-Tabelle
- **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort
- **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash
- **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung
- **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert und im Dashboard angezeigt
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs)
- Priority-Seite ebenfalls mit Tabs: Playlist und Medien
- Dark Mode (localStorage-persistiert)
- CI-konformes Admin-UI (CANCOM-Design: `brand-surface`, `nav-surface` rot)

---

## Architektur

```
Browser (Player)
      │
      ▼
Flask App (Server)
├── Admin UI        /admin/<site>
├── Player UI       /player/<site>/<screen>
├── Priority-Seite  /admin/<site>/priority
├── config.json
├── users.json
├── history.json
├── /tmp/signage-heartbeat/  (Heartbeat-JSONs mit Client-Info für Online-Status)
├── media/
│   ├── <site>/
│   │   ├── <screen>/
│   │   │   ├── bild.jpg
│   │   │   ├── video.mp4
│   │   │   └── welcome.html
│   └── priority/
└── generate_welcome_page.py
```

- **Server:** Python 3 + Flask  
- **Player:** Jeder moderne Browser (Chrome Kiosk, Edge, Firefox)  
- **State:** `config.json` + Dateisystem  
- **Frontend:** Tabler Core + Tabler Icons + SortableJS (CDN)

---

## Projektstruktur

```
signage/
├── app.py                          # Flask-App (alle Routen)
├── generate_welcome_page.py        # Logo-Suche + Willkommensseite-Generierung
├── config.json                     # Persistente Konfiguration
├── users.json                      # User-Datenbank (gehashte Passwörter)
├── history.json                    # Aktivitätsverlauf (Dashboard)
├── media/
│   ├── <site>/
│   │   ├── lobby/
│   │   ├── casino/
│   │   └── videosysteme/
│   └── priority/
├── templates/
│   ├── admin.html                  # Admin-Dashboard (Übersicht)
│   ├── admin_dashboard.html         # Admin Dashboard (Statistiken + Verlauf)
│   ├── priority.html               # Priority-Playlist (eigene Seite)
│   ├── customer.html               # Willkommensseite-Formular
│   ├── player.html                 # Player-Ansicht
│   ├── login.html
│   ├── change_password.html        # Passwort ändern (First-Login / Reset)
│   ├── user_list.html              # User-Liste (Admin)
│   ├── user_create.html            # User anlegen (Admin)
│   ├── user_edit.html              # User bearbeiten (Admin)
│   ├── mfa_setup.html              # MFA einrichten (QR-Code + Deaktivierung)
│   ├── mfa_verify.html             # TOTP-Code nach Login eingeben
│   ├── mfa_recovery.html           # Einmalige Recovery-Codes-Anzeige
│   ├── _header.html                # Gemeinsamer Header (MFA-Link + Aktiv-Badge)
│   ├── _footer.html                # Gemeinsamer Footer
│   └── _styles.html                # Zentrale CSS (Variablen, Dark Mode)
├── static/
│   ├── cancom.svg
│   ├── dva.png
│   └── wallpaper.png
└── AGENTS.md
```

---

## Installation

### Voraussetzungen

- Python ≥ 3.9
- pip
- ffmpeg (optional, für Videokonvertierung)

### Setup

```bash
pip install -r requirements.txt
```

### Starten

```bash
python app.py
```

Server läuft auf `http://localhost:5005`.

### Docker

Empfohlenes `docker-compose.yml` mit Bind-Mounts für persistente Daten:

```yaml
services:
  signage:
    container_name: signage
    build:
      context: .
      dockerfile: Dockerfile
      platforms:
        - linux/amd64
    image: your-registry/signage:latest
    ports:
      - "5005:5005"
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    volumes:
      - ./media:/app/media
      - ./config.json:/app/config.json
      - ./users.json:/app/users.json
      - ./history.json:/app/history.json
    environment:
      - TZ=Europe/Berlin
```

Der Container läuft als User mit **UID 1000** ohne Shell, auf **read-only Root-FS** ohne Capabilities. Daher vor dem ersten Start die Berechtigungen setzen:

```bash
chown 1000:1000 config.json users.json history.json
chown -R 1000:1000 media/
```

Start:

```bash
docker compose up -d
```

Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User).

---

## Routen

| Route | Beschreibung |
|-------|-------------|
| `GET /` | Weiterleitung zum Login |
| `GET /login` | Admin-Login |
| `GET /logout` | Ausloggen |
| `GET /admin` | Redirect zum ersten konfigurierten Standort |
| `GET /admin/dashboard` | Admin Dashboard (Statistiken + Verlauf) |
| `GET /admin/<site>` | Admin-Dashboard für einen Standort |
| `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) |
| `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) |
| `POST /admin/<site>/update-actions/<screen>` | Aktionen-Einstellungen speichern (Custom-URL) |
| `POST /admin/<site>/update-voice/<screen>` | Voice-Agent-Einstellungen speichern (Enabled, Label, Target, Position, Bild) |
| `POST /admin/<site>/upload/<screen>` | Medien hochladen |
| `POST /admin/<site>/add-url/<screen>` | URL zur Playlist hinzufügen |
| `POST /admin/<site>/delete/<screen>/<filename>` | Datei löschen |
| `POST /admin/<site>/playlist/<screen>` | Playlist-Reihenfolge speichern (JSON) |
| `POST /admin/<site>/delete-screen/<screen>` | Screen + Medien löschen |
| `GET /admin/<site>/add-screen?name=<name>` | Neuen Screen anlegen |
| `GET /add-site?name=<name>` | Neuen Standort anlegen |
| `POST /admin/<site>/delete-site` | Standort + alle Screens/Medien löschen |
| `POST /admin/<site>/upload-background` | Hintergrundbild für Willkommensseite hochladen |
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen |
| `GET /player/<site>/<screen>` | Player-Ansicht |
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) + Heartbeat (Online-Status) |
| `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) |
| `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) |
| `GET /change-password` | Passwort ändern (First-Login / nach Admin-Reset) |
| `GET /admin/users` | User-Liste (Admin) |
| `GET /admin/users/create` | User anlegen (Admin) |
| `GET /admin/users/edit/<email>` | User bearbeiten (Admin) |
| `GET /mfa/setup` | MFA einrichten / deaktivieren (QR-Code + Verifikation) |
| `POST /mfa/setup` | TOTP-Code verifizieren und MFA aktivieren |
| `GET /mfa/verify` | TOTP-Code nach Login eingeben |
| `POST /mfa/verify` | TOTP-Code prüfen oder Recovery-Code verwenden |
| `POST /mfa/disable` | MFA deaktivieren (mit Passwort-Bestätigung) |
| `POST /admin/users/create` | User anlegen (Admin) |
| `POST /admin/users/delete/<email>` | User löschen (Admin, letzter Admin geschützt) |
| `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) |
| `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) |
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
| `GET /media/priority/<file>` | Priority-Medien (global) |
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |

### Heartbeat / Online-Status

Der Player ruft alle 5s `/playlist/<site>/<screen>/hash?w=<breite>&h=<höhe>` auf. Der Server speichert dabei ein JSON mit Client-Info (IP, User-Agent, Auflösung) nach `/tmp/signage-heartbeat/<site>/<screen>.json`. Im Admin-Dashboard wird geprüft, ob der Timestamp < 60s alt ist → grüner "Online"-Button mit Tooltip (Browser, Auflösung, IP, letztes Update) oder roter "Offline"-Button pro Screen.

```bash
# Heartbeat-Dateien anzeigen
ls -la /tmp/signage-heartbeat/*/
cat /tmp/signage-heartbeat/stuttgart/lobby.json
```

Nach einem Server-Neustart werden alle Screens kurz rot, bis die Player wieder pollen (max. 60s).

### Admin Portal

```
http://localhost:5005/admin/<standort>
```

- Screens konfigurieren (Tab-basiert: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info)
- Info-Tab pro Screen zeigt Client-Info-Tabelle: IP-Adresse, Browser (User-Agent), Bildschirmauflösung und letzter Heartbeat-Zeitpunkt
- Medien hochladen / löschen
- Playlist per Drag & Drop sortieren
- Priority-Playlist verwalten (ebenfalls mit Tabs)
- Willkommensseite generieren (bis zu 3 Kundenlogos, Hintergrundbild pro Standort)
- Custom-URL-Aktionsbutton pro Screen konfigurieren (Position, iframe-Overlay oder Weiterleitung)
- Digital Voice Agent pro Screen konfigurieren (Position, Bild ein/aus, Typewriter-Tagline)
- Stay-on-First-Modus pro Screen (kein automatischer Refresh)
- Standorte anlegen & löschen
- **Userverwaltung**: User anlegen, bearbeiten, löschen, Passwort-Reset (temporäres Passwort wird angezeigt), MFA-Status pro User in der Liste (grünes "Aktiv"-Badge oder "–")

### Admin Dashboard

```
http://localhost:5005/admin/dashboard
```

- 5 Statistik-Cards (Standorte, Screens, User, Admins, Superuser) mit ApexCharts-Sparkline + Trendanzeige
- Aktivitätsverlauf (History) als Liste mit Zeitstempel, Aktion und User
- Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden protokolliert

### Player-URL

```
http://localhost:5005/player/<standort>/<screen>
```

Beispiel:
```
http://localhost:5005/player/stuttgart/lobby
```

### Willkommensseite

```
http://localhost:5005/willkommen?site=stuttgart
```

Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in der Lobby-Playlist vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`).

**Ansprechpartner:** Pro Kunde können Kontaktdaten eingegeben werden (Markdown-Format).
- **Fett**: `**Text**`
- *Kursiv*: `*Text*`
- `Code`: `` `Code` ``
- Aufzählungen: `- Punkt` oder `* Punkt`
- Zeilenumbrüche werden als `<br>` übernommen

**Layout:**
- Landscape: Logos nebeneinander (280px breit, 120px hoch), Texte linksbündig darunter
- Portrait: Texte links vom Logo, per `order`-CSS gesteuert

---

### Globale Konfiguration (config.json)

| Feld | Typ | Beschreibung |
|------|-----|-------------|
| `server_url` | string | Öffentliche Server-URL für Player-Links |
| `voice_agent_url` | string | URL des Digital Voice Agents (global, read-only im Admin) |

### User-Rollen (users.json)

| Rolle | Zugriff | Userverwaltung |
|-------|---------|----------------|
| **Admin** | Alle Standorte | Ja (anlegen/bearbeiten/löschen/Reset) |
| **Superuser** | Alle Standorte | Nein |
| **User** | Nur freigegebene Standorte | Nein |

- Usernamen sind E-Mail-Adressen
- Passwörter werden als scrypt-Hash gespeichert
- `users.json` wird beim ersten Start aus `config.json.admin` befüllt
- **Notfall**: `users.json` löschen → Server-Neustart erzeugt neuen Admin aus `config.json`

### User-Felder (`users.json`)

| Feld | Typ | Beschreibung |
|------|-----|-------------|
| `password_hash` | string | scrypt-Hash des Passworts |
| `role` | string | `admin`, `superuser` oder `user` |
| `sites` | array | Freigegebene Standorte (nur für User-Rolle) |
| `must_change_password` | bool | Erzwingt Passwort-Änderung beim nächsten Login |
| `first_name` / `last_name` | string | Vor- und Nachname |
| `department` | string | Abteilung |
| `notes` | string | Interne Notizen |
| `mfa_enabled` | bool | MFA aktiv (TOTP) |
| `mfa_secret` | string | TOTP-Secret (sofort beim ersten GET `/mfa/setup` persistiert) |
| `mfa_recovery_codes` | array | 8 Recovery-Codes (hex, 7 Stellen, einmalig verwendbar) |

```json
{
  "server_url": "http://signage.ccmake.de",
  "voice_agent_url": "https://voice-agent.example.com",
  "admin": { "username": "...", "password": "..." },
  "sites": {
    "stuttgart": {
      "screens": {
        "lobby": {
          "playlist": [...],
          "interval": 10,
          "show_images": true,
          "show_videos": true,
          "newsticker_text": "...",
          "newsticker_enabled": false,
          "custom_url_enabled": false,
          "custom_url": "https://...",
          "custom_url_label": "Infos",
          "custom_url_target": "overlay",
          "custom_url_position": "top-left",
          "stay_on_first": false,
          "voice_agent_enabled": true,
          "voice_agent_show_image": false,
          "voice_agent_label": "Digitaler Assistent",
          "voice_agent_target": "overlay",
          "voice_agent_position": "top-left"
        },
        "casino": { "playlist": [...], "interval": 15 }
      },
      "welcome_data": {
        "names": ["Firma A"],
        "logo_urls": ["https://..."],
        "background_url": null
      }
    }
  },
  "priority": {
    "enabled": true,
    "playlist": [...]
  }
}
```

---

## Screen-Konfiguration (pro Screen in `config.json`)

| Feld | Typ | Beschreibung |
|------|-----|-------------|
| `playlist` | Array | Playlist-Einträge (Strings oder Dicts mit `url`/`zoom`) |
| `interval` | int | Anzeige-Intervall in Sekunden |
| `show_images` | bool | Bilder anzeigen |
| `show_videos` | bool | Videos anzeigen |
| `newsticker_text` | string | Text für Newsticker (max. 200 Zeichen) |
| `newsticker_enabled` | bool | Newsticker anzeigen |
| `custom_url_enabled` | bool | Custom-URL-Button im Player anzeigen |
| `custom_url` | string | URL des Aktions-Buttons |
| `custom_url_label` | string | Button-Beschriftung |
| `custom_url_target` | string | `"overlay"` (iframe) oder `"redirect"` |
| `custom_url_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |
| `stay_on_first` | bool | Player bleibt auf erstem Element stehen (kein Refresh) |
| `voice_agent_enabled` | bool | Voice-Agent-Button im Player anzeigen |
| `voice_agent_show_image` | bool | Bild (dva.png) über dem Button anzeigen |
| `voice_agent_label` | string | Button-Beschriftung (Default: "Digitaler Assistent") |
| `voice_agent_target` | string | `"overlay"` (iframe) oder `"redirect"` |
| `voice_agent_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |

## Helper-Funktionen (`app.py`)

- `load_config()` / `save_config()` – JSON lesen/schreiben
- `get_site_list(cfg)` – alle Standorte sortiert
- `get_screen_config(cfg, site, screen)` – Screen-Konfiguration mit Defaults
- `is_url(item)` – prüft ob Item eine URL ist
- `normalize_url(item)` – normalisiert URL-Item zu `{"url", "zoom"}`
- `playlist_item_name(item)` – Name aus String oder Dict extrahieren
- `playlist_item_enabled(item)` – Enabled-Status prüfen
- `load_priority_files()` – Priority-Playlist + Dateien laden
- `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite
- `get_background_url(site)` – URL zum benutzerdefinierten Hintergrundbild oder `None`
- `load_users()` / `save_users()` / `get_user()` – User aus `users.json`
- `init_user_db()` – initialen Admin aus `config.json` anlegen
- `get_accessible_sites(cfg, user)` – Standorte nach User-Berechtigung filtern
- `record_heartbeat(site, screen, ip, ua, resolution)` – Heartbeat-JSON mit Client-Info schreiben
- `screen_is_active(site, screen)` – Prüft ob Heartbeat < 60s alt ist
- `get_client_info(site, screen)` – Gibt gespeicherte Client-Info zurück (Dict mit ip, ua, resolution, last_seen)
- `admin_required` / `site_access_required` – Zugriffs-Dekoratoren

---

## Dark Mode

Wird über `localStorage("signage-theme")` persistiert. Umschalt-Button im Header. CSS-Variablen `--ccm-*` in `_styles.html`.

---

## Sicherheit

- Admin-Bereich per Flask-Login geschützt (E-Mail + Passwort)
- Optionale MFA (TOTP) per User aktivierbar (Google Authenticator, Authy)
- 8 Recovery-Codes pro User, einmalig bei Aktivierung angezeigt
- Passwörter gehasht (scrypt) in `users.json`
- Drei Rollen: Admin (alle Standorte + Userverwaltung), Superuser (alle Standorte), User (nur zugewiesene Standorte)
- Zugriffskontrolle per Decorator (`@admin_required`, `@site_access_required`)
- Letzter Admin kann nicht gelöscht werden
- Player-Seiten Read-Only
- Externe Nutzung via Reverse Proxy + TLS empfohlen

---

## Git & Medien

> Das Repository ist für Code gedacht, nicht für Medien.

`media/` und Medien-Dateiendungen sind in `.gitignore` ausgeschlossen.

---

## Video-Empfehlungen

- Format: MP4 (H.264)
- Auflösung: max. 1920×1080

```bash
ffmpeg -i input.mov -c:v libx264 -pix_fmt yuv420p -movflags +faststart output.mp4
```

---

## Maintainer

**CANCOM Simple Signage** – Interne Lösung, nicht für externe Weitergabe bestimmt.
