Files
signage/AGENTS.md
Erik Thiele 22104b54cd Query-Parameter-Passthrough per URL steuerbar (Checkbox im Edit-Modal), Standard aus, sichtbar via Link-Icon in Playlist
- Neue Config-Felder: passthrough_params (true/false) pro URL-Item
- Edit-Modal in admin.html + priority.html mit Checkbox
- Player checkt item.passthrough === true vor appendParams()
- Default: false (keine Parameter-Übergabe ohne Checkbox)
- Link-Icon (ti-link / ti-link-off) in Playlist-Zeile als Status-Indikator
- Doku: help.html, README.md, AGENTS.md
2026-07-10 17:44:57 +02:00

16 KiB
Raw Blame History

CANCOM Simple Signage — Agent Guide

Start

pip install -r requirements.txt
python app.py
docker compose up -d

python app.py (via .venv/bin/python app.py) serves on http://localhost:5005. app.py runs Flask with debug=True, host="0.0.0.0", and port=5005.

Source Of Truth

  • Single Flask app in app.py; there is no database.
  • Persistent state is config.json plus files under media/<site>/<screen>/.
  • User data in users.json (hashed passwords via werkzeug.security scrypt).
  • Port 5005; README.md hat den korrekten Port.
  • Existing repo instructions in this file are the main local guidance; there is no opencode.json or workflow config in this repo.

Multi-Standort-URL-Struktur

Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.

Route Beschreibung
GET /player/<site>/<screen> Player für Screen an einem Standort
GET /admin Redirect zum ersten zugänglichen Standort
GET /admin/<site> Admin-Dashboard für einen Standort (@site_access_required)
GET /admin/<site>/priority Priority-Playlist als separate Seite
GET /media/<site>/<screen>/<file> Medien-Datei ausliefern
GET /media/priority/<file> Priority-Medien (global)
GET /media/<site>/background/<filename> Hintergrundbild der Willkommensseite
GET /playlist/<site>/<screen>/hash Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status)
GET /willkommen?site=<site> Willkommensseite für Standort generieren
POST /admin/<site>/upload-background Hintergrundbild für Willkommensseite hochladen
POST /admin/<site>/delete-background Hintergrundbild zurücksetzen auf Standard
POST /api/customer API-Endpunkt (JSON mit "site"-Feld)
GET /admin/<site>/add-screen?name=<name> Neuen Screen anlegen
POST /admin/<site>/delete-screen/<screen> Screen + Medien löschen
GET /add-site?name=<name> Neuen Standort anlegen (nur Admin)
POST /admin/<site>/update-actions/<screen> Aktionen-Einstellungen speichern (Custom-URL + Position)
POST /admin/<site>/update-voice/<screen> Voice-Agent-Einstellungen speichern (Enabled, Label, Target, Position)
POST /admin/<site>/delete-site Standort + alle Screens/Medien löschen (nur Admin)
GET /change-password Passwort ändern-Seite
GET /admin/users User-Liste (nur Admin)
GET /admin/users/create User anlegen (nur Admin)
GET /admin/users/edit/<email> User bearbeiten (nur Admin)

User-Verwaltung (seit v5.6.0)

  • Login via E-Mail + Passwort (scrypt-gehasht in users.json)
  • Drei Rollen:
    • Admin: Zugriff auf alle Standorte + Userverwaltung (anlegen/bearbeiten/löschen/Reset)
    • Superuser: Zugriff auf alle Standorte, keine Userverwaltung
    • User: Zugriff nur auf zugewiesene Standorte
  • Erster Login erfordert Passwort-Änderung (must_change_password=True)
  • Admin-Reset generiert temporäres Passwort (wird im UI angezeigt)
  • Fehlender/leerer password_hash → Login ohne Prüfung, direkt zu /change-password
  • Letzter Admin kann nicht gelöscht werden
  • users.json wird beim ersten Start aus config.json.admin befüllt
  • Notfall: users.json löschen → Server-Neustart erzeugt neuen Admin

User-Routen

Route Beschreibung
GET /admin/users User-Liste (Admin, mit MFA-Spalte)
GET /admin/users/create User anlegen (Formular)
POST /admin/users/create User anlegen
GET /admin/users/edit/<email> User bearbeiten (Formular)
POST /admin/users/edit/<email> User bearbeiten speichern
POST /admin/users/delete/<email> User löschen (letzter Admin geschützt)
POST /admin/users/reset-password/<email> Passwort-Reset (temporäres Passwort)
GET /mfa/setup MFA einrichten (QR-Code + Verifikation)
POST /mfa/setup TOTP-Code verifizieren → MFA aktivieren
GET /mfa/verify TOTP-Code nach Login eingeben
POST /mfa/verify Code prüfen oder Recovery-Code verwenden
POST /mfa/disable MFA deaktivieren (mit Passwort)

MFA (Multi-Faktor-Authentifizierung)

  • TOTP via pyotp, QR-Code via qrcode[pil] (nur in .venv installiert)
  • Der TOTP-Secret wird sofort beim ersten Aufruf von /mfa/setup (GET) in users.json persistiert, sodass die nachfolgende POST-Verifikation denselben Secret verwendet
  • Nach erfolgreicher Aktivierung: 8 Recovery-Codes (hex, 7 Stellen), einmalig im UI angezeigt, jeder nur einmal verwendbar
  • Recovery-Code-Einsatz erzwingt erneute MFA-Einrichtung
  • Deaktivierung via /mfa/disable mit Passwort-Bestätigung
  • MFA-Status in der Userliste (/admin/users) als grünes "Aktiv"-Badge oder ""
  • Trusted Device: Nach erfolgreichem TOTP-Code kann das Gerät für 130 Tage gespeichert werden. Bei erneuter Anmeldung wird der MFA-Code dann übersprungen (Cookie mfa_trust mit Token, gespeichert in users.json[email].trusted_devices). Beim Logout oder Deaktivieren von MFA wird der Cookie gelöscht.

MFA-Login-Flow

  1. POST /login mit Passwort → bei aktivem MFA: Session mfa_pending setzen → redirect /mfa/verify
  2. GET/POST /mfa/verify → TOTP-Code prüfen → login_user() aufrufen → redirect /admin/<site>
  3. Recovery-Code: POST /mfa/verify mit recovery=true → Code prüfen → mfa_enabled=False → Login erlauben, aber User muss MFA neu einrichten
  4. Trusted Device: POST /mfa/verify mit remember (Tage) → Token generieren → in users.json[email].trusted_devices speichern → Cookie mfa_trust setzen. Nächstes POST /login: Cookie erkannt → MFA überspringen → direkt einloggen

Zugriffs-Dekoratoren

Dekorator Wirkung
@login_required User muss eingeloggt sein
@admin_required Nur Admins
@site_access_required Admin/Superuser → alle Sites, User → nur freigegebene

Behavior To Preserve

  • GET /player/<site>/<screen> renders the playlist and auto-reloads from /playlist/<site>/<screen>/hash.

  • GET /admin requires login; redirects to first accessible site for the user.

  • URL playlist items are stored as dicts like {"url": "https://...", "zoom": 0.8} and the zoom value must survive save/reorder flows.

  • .html items in media/ are rendered inline as content, not in an iframe.

  • config.priority.enabled makes the priority playlist show on every screen.

  • POST /api/customer generates welcome.html and inserts it at the front of the lobby playlist for the specified site.

  • New standorte can be added by creating media/<neuer-standort>/<screen>/ directories and optionally adding config to config.json["sites"][<neuer-standort>].

  • Priority playlist (config.priority) is global and affects all sites/screens.

  • Willkommensseite (customer.html) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column).

  • generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None) takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo.

  • Contact info supports Markdown: **bold**, *italic*, `code`, - list items, and line breaks (preserved as <br>).

  • customer_names are preserved in form fields after POST (via value-Attribute).

  • Admin-UI nutzt keyadmin-Design: brand-surface (#2b2f36), nav-surface (rot #DA002D), Dark Mode per localStorage("signage-theme").

  • Gemeinsame HTML-Bausteine: _header.html, _footer.html, _styles.html (CSS-Variablen --ccm-*, Dark Mode, Card-Border-Radius 1rem).

  • add_customer_to_lobby_playlist entfernt welcome.html sowohl als String als auch als Dict aus der Playlist vor dem Einfügen.

  • add_screen legt ein Verzeichnis unter media/<site>/<screen>/ an und einen Config-Eintrag.

  • delete_screen entfernt den Screen aus der Config und löscht das Verzeichnis rekursiv.

  • Custom-URL-Button: pro Screen konfigurierbar (custom_url + custom_url_label + custom_url_enabled + custom_url_target + custom_url_position) im Admin-Formular (Aktionen-Tab); wird im Player als Button an wählbarer Position (9 Positionen: oben/mitte/unten × links/mitte/rechts) angezeigt und öffnet die URL wahlweise in einem iframe-Overlay mit Zurück-Button (overlay) oder per Direkt-Weiterleitung (redirect); Player pausiert während das Overlay geöffnet ist. Die unteren Positionen weichen automatisch 16px über dem Newsticker-Balken aus.

  • Voice-Agent-Button: globale config.voice_agent_url wird pro Screen im Tab "Digital Voice Agent" konfiguriert; Einstellungen (voice_agent_enabled, voice_agent_label, voice_agent_target, voice_agent_position) funktionieren identisch zum Aktionen-Tab. Die URL ist read-only im Admin sichtbar und wird global in config.json gesetzt.

  • Voice-Agent-Typewriter: Tagline im Button zeigt wechselnde mehrsprachige Texte mit Buchstaben-für-Buchstaben-Effekt; nach 2,5s Pause fade-out über 0,8s, dann nächster Text.

  • Voice-Agent-Overlay: positioniert den Zurück-Button exakt an der Position des geklickten Buttons (identisches Positionssystem).

  • voice_agent_show_image (per Screen): optionales Bild (static/dva.png) über dem Button, per Admin-Toggle schaltbar (Beta).

  • Action-Button (custom_url) und Voice-Agent-Button haben einheitliches Styling: weißer Hintergrund, schwarzer Text, 20px Border-Radius, fette Schrift.

  • Neue Standorte können über den +-Button im Header oder über GET /add-site?name=<name> angelegt werden (nur Admins).

  • delete_site entfernt den Standort aus Config und löscht das Medienverzeichnis rekursiv (nur Admins).

  • config.json["server_url"] (z.B. http://signage.ccmake.de) wird in der Admin-Ansicht für die Player-URLs verwendet.

  • Query-Parameter-Passthrough: Query-Parameter an der Player-URL (/player/<site>/<screen>) können pro Playlist-URL im Edit-Modal (Checkbox) gesteuert werden. Standardmäßig aus nur URLs mit aktivierter Checkbox erhalten die Parameter. Beispiel: /player/stuttgart/lobby?customer=acme hängt ?customer=acme nur an URLs mit Passthrough=an. Funktioniert über appendParams() in player.html + player_params im Flask-Route.

  • Screen-Card-Body hat Tabler-Tabs: Playlist (1, aktiv), Einstellungen (2), Aktionen (3), Digital Voice Agent (4), Medien (5), Info (6); Priority-Seite ebenfalls Tabs Playlist und Medien.

  • stay_on_first: Wenn aktiviert bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf).

  • Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien → Info.

  • Player-URL im Screen-Header ist ein klickbarer Link in grauer Farbe.

  • add_customer und /customer erfordern jetzt Login (@login_required) mit Site-Zugriffsprüfung.

  • /admin redirectet zum ersten für den User zugänglichen Standort (nicht mehr global ersten).

  • User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden.

  • Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen

Repo Quirks

  • media/ and media file extensions are gitignored.
  • users.json is NOT gitignored (tracked for initial admin setup, contains no secrets by default).
  • The app has no configured tests, lint, typecheck, formatter, or CI.
  • Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
  • Wichtige Helper-Funktionen in app.py: load_config(), save_config(), get_site_list(), get_screen_config(), is_url(), normalize_url(), playlist_item_name(), playlist_item_enabled(), load_priority_files(), prio_redirect(), get_background_url(), load_users(), save_users(), get_user(), init_user_db(), get_accessible_sites(), record_heartbeat(), screen_is_active(), get_client_info(), add_history_entry().
  • Zugriffs-Dekoratoren in app.py: admin_required, site_access_required.
  • Hintergrundbild der Willkommensseite wird pro Standort unter media/<site>/background.* gespeichert; Fallback auf static/wallpaper.png wenn keine Datei existiert.
  • get_background_url(site) prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.
  • generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None) akzeptiert optionale contacts-, site- und background_url-Parameter.
  • welcome.html wird in media/<site>/lobby/ gespeichert.
  • search_customer_logo in generate_welcome_page.py nutzt OpenAI GPT-4 + Brandfetch CDN.
  • admin_priority rendert priority.html mit site_list, current_site, priority_files, server_url.
  • static/dva.png: Bild für den Voice-Agent-Button (optional, per Admin-Toggle ein-/ausblendbar).

Heartbeat / Online-Status (seit v6.1.0)

Der Player-Status (Online/Offline) wird mittels dateibasiertem Heartbeat ermittelt.

Funktionsweise

Schritt Beschreibung
Player-pollt alle 5s /playlist/<site>/<screen>/hash (checkForUpdates) Bestehender Mechanismus, kein Extra-Request
Server zeichnet Heartbeat auf record_heartbeat(site, screen) erstellt/updated /tmp/signage-heartbeat/<site>/<screen> via path.touch()
Admin-UI fragt Status ab screen_is_active(site, screen) prüft ob mtime < 60s alt
Anzeige Grüner "Online"-Button (filled success) oder roter "Offline"-Button (filled danger) im Card-Header neben "Löschen"
Client-Info Beim Hovern über den Online-Button: Tooltip mit Browser, Auflösung, IP, letztem Seen-Zeitpunkt

Helper-Funktionen in app.py

HEARTBEAT_DIR = Path("/tmp/signage-heartbeat")
HEARTBEAT_TIMEOUT = 60  # Sekunden

def record_heartbeat(site, screen, ip="", ua="", resolution=""):
    """Schreibt JSON mit Client-Info (ip, ua, resolution, last_seen)."""
    path = HEARTBEAT_DIR / site / f"{screen}.json"
    path.parent.mkdir(parents=True, exist_ok=True)
    data = {"ip": ip, "ua": ua[:200], "resolution": resolution,
            "last_seen": datetime.now().strftime("%d.%m.%Y %H:%M:%S")}
    path.write_text(json.dumps(data))

def screen_is_active(site, screen):
    """Prüft, ob Heartbeat < 60s alt ist (anhand .json-mtime)."""
    path = HEARTBEAT_DIR / site / f"{screen}.json"
    if not path.exists():
        return False
    return (time.time() - path.stat().st_mtime) < 60

def get_client_info(site, screen):
    """Gibt das gespeicherte Client-Info-JSON zurück."""
    ...

Integration

  • playlist_hash()-Route liest Query-Parameter w (Breite) und h (Höhe) + remote_addr + User-Agent und übergibt sie an record_heartbeat()
  • admin()-Route sammelt client_info[screen] = get_client_info(...) für jeden Screen
  • Admin-Template (admin.html) zeigt den Online-Button mit title-Tooltip (Browser, Auflösung, IP, letztes Update) und den Info-Tab mit Client-Info-Tabelle
  • Player (player.html) sendet screen.width und screen.height als ?w=...&h=... im checkForUpdates-Fetch
  • Die Lösch-Buttons (btn-outline-danger btn-sm) haben gleiche Größe durch gleiche btn-sm-Klasse, Form-Wrapper hat m-0 p-0

Docker-Hinweis

Im Docker-Container liegt der Heartbeat-Pfad unter /tmp/signage-heartbeat/ im Container. Da die Docker-Umgebung keine Volumes mounted, sind Heartbeats container-lokal. Nach docker compose restart werden alle Screens kurz rot, bis die Player innerhalb von max. 60s wieder pollen.

Debugging

# Alle Heartbeat-Dateien anzeigen
ls -la /tmp/signage-heartbeat/*/
find /tmp/signage-heartbeat -type f -exec ls -la {} \;