- 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
1989 lines
74 KiB
Python
Executable File
1989 lines
74 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
CANCOM Simple Signage – Flask-App für Digital-Signage-Anzeigen.
|
||
──────────────────────────────────────────────────────────────
|
||
Diese App verwaltet mehrere Standorte (sites) mit jeweils mehreren
|
||
Screens. Jeder Screen hat eine eigene Playlist aus Bildern, Videos,
|
||
HTML-Seiten und URLs. Optional kann eine globale Priority-Playlist
|
||
zugeschaltet werden, die auf allen Playern abwechselnd eingeblendet wird.
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import hashlib
|
||
import re
|
||
import time
|
||
import io
|
||
import base64
|
||
import secrets
|
||
import generate_welcome_page # Logo-Suche und Willkommensseite-Generierung
|
||
|
||
import pyotp
|
||
import qrcode
|
||
|
||
from functools import wraps
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||
|
||
from flask import (
|
||
Flask, jsonify, render_template,
|
||
send_from_directory, redirect,
|
||
request, abort, flash, session, url_for
|
||
)
|
||
from flask_login import (
|
||
LoginManager, login_user, current_user,
|
||
login_required, logout_user,
|
||
UserMixin
|
||
)
|
||
from werkzeug.utils import secure_filename
|
||
from werkzeug.security import generate_password_hash, check_password_hash
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Grundkonfiguration
|
||
# -------------------------------------------------
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
MEDIA_DIR = os.path.join(BASE_DIR, "media") # Medien-Dateien je Standort/Screen
|
||
CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration
|
||
USERS_FILE = os.path.join(BASE_DIR, "users.json") # User-Datenbank
|
||
HISTORY_FILE = os.path.join(BASE_DIR, "history.json") # Aktivitätsverlauf
|
||
|
||
APP_VERSION = "6.1.0"
|
||
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
|
||
|
||
app = Flask(__name__)
|
||
app.secret_key = "CHANGE_THIS_SECRET!!!"
|
||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
|
||
|
||
@app.context_processor
|
||
def inject_globals():
|
||
"""Stellt globale Template-Variablen für Header/Footer bereit."""
|
||
import os as _os
|
||
hostname = _os.uname().nodename
|
||
cfg = load_config()
|
||
from flask_login import current_user
|
||
if current_user.is_authenticated:
|
||
site_list = get_accessible_sites(cfg, current_user)
|
||
current_site_val = get_site_list(cfg)[0] if get_site_list(cfg) else ""
|
||
else:
|
||
site_list = get_site_list(cfg)
|
||
current_site_val = site_list[0] if site_list else ""
|
||
return dict(
|
||
site_list=site_list,
|
||
current_site=current_site_val,
|
||
brand_subtitle="",
|
||
site_dropdown_url_prefix="/admin/",
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=hostname
|
||
)
|
||
|
||
login_manager = LoginManager(app)
|
||
login_manager.login_view = "login" # Nicht eingeloggte User werden zum Login umgeleitet
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Config-Helfer
|
||
# -------------------------------------------------
|
||
def load_config():
|
||
"""Liest die config.json und gibt das Dict zurück. Legt ein leeres an, falls nicht vorhanden oder fehlerhaft."""
|
||
if not os.path.exists(CONFIG_FILE):
|
||
return {"admin": {}, "sites": {}}
|
||
try:
|
||
with open(CONFIG_FILE) as f:
|
||
return json.load(f)
|
||
except (json.JSONDecodeError, OSError):
|
||
return {"admin": {}, "sites": {}}
|
||
|
||
|
||
def save_config(cfg):
|
||
"""Schreibt das Config-Dict zurück in die config.json."""
|
||
with open(CONFIG_FILE, "w") as f:
|
||
json.dump(cfg, f, indent=2)
|
||
|
||
|
||
HEARTBEAT_DIR = Path("/tmp/signage-heartbeat")
|
||
HEARTBEAT_TIMEOUT = 60 # Sekunden
|
||
|
||
|
||
def record_heartbeat(site, screen, ip="", ua="", resolution=""):
|
||
"""Aktualisiert den Heartbeat-Timestamp für einen Screen und speichert Client-Info."""
|
||
path = HEARTBEAT_DIR / site / f"{screen}.json"
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
data = {
|
||
"ip": ip,
|
||
"ua": ua[:200] if ua else "",
|
||
"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 ein Screen zuletzt innerhalb des Timeouts registriert wurde."""
|
||
path = HEARTBEAT_DIR / site / f"{screen}.json"
|
||
if not path.exists():
|
||
return False
|
||
return (time.time() - path.stat().st_mtime) < HEARTBEAT_TIMEOUT
|
||
|
||
|
||
def get_client_info(site, screen):
|
||
"""Gibt die gespeicherten Client-Informationen eines Screens zurück."""
|
||
path = HEARTBEAT_DIR / site / f"{screen}.json"
|
||
if not path.exists():
|
||
return {}
|
||
try:
|
||
return json.loads(path.read_text())
|
||
except (json.JSONDecodeError, OSError):
|
||
return {}
|
||
|
||
|
||
def get_site_list(cfg):
|
||
"""Gibt eine sortierte Liste aller konfigurierten Standort-Namen zurück."""
|
||
return sorted(cfg.get("sites", {}).keys())
|
||
|
||
|
||
def add_history_entry(action, detail, user_email):
|
||
"""Hängt einen Eintrag in history.json an (Aktivitätsverlauf für das Admin-Dashboard)."""
|
||
entry = {
|
||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"action": action,
|
||
"detail": detail,
|
||
"user": user_email
|
||
}
|
||
history = []
|
||
if os.path.exists(HISTORY_FILE):
|
||
try:
|
||
with open(HISTORY_FILE) as f:
|
||
history = json.load(f)
|
||
except (json.JSONDecodeError, OSError):
|
||
history = []
|
||
history.insert(0, entry) # Neueste zuerst
|
||
# Maximal 200 Einträge behalten
|
||
history = history[:200]
|
||
with open(HISTORY_FILE, "w") as f:
|
||
json.dump(history, f, indent=2)
|
||
|
||
|
||
def get_history(limit=50):
|
||
"""Gibt die letzten history-Einträge zurück."""
|
||
if not os.path.exists(HISTORY_FILE):
|
||
return []
|
||
try:
|
||
with open(HISTORY_FILE) as f:
|
||
history = json.load(f)
|
||
return history[:limit]
|
||
except (json.JSONDecodeError, OSError):
|
||
return []
|
||
|
||
|
||
def get_screen_config(cfg, site, screen):
|
||
"""Holt die Konfiguration für einen bestimmten Screen.
|
||
Erzeugt automatisch fehlende Dict-Ebenen (sites → site → screens → screen)."""
|
||
return (
|
||
cfg
|
||
.setdefault("sites", {})
|
||
.setdefault(site, {})
|
||
.setdefault("screens", {})
|
||
.setdefault(screen, {})
|
||
)
|
||
|
||
|
||
def is_url(item):
|
||
"""Prüft, ob ein Playlist-Eintrag eine URL ist (String mit http/https) oder ein Dict mit "url"-Key."""
|
||
if isinstance(item, str):
|
||
return item.startswith("http://") or item.startswith("https://")
|
||
return isinstance(item, dict) and "url" in item
|
||
|
||
|
||
def normalize_url(item):
|
||
"""Normalisiert einen URL-Eintrag zu einem Dict mit "url" und "zoom".
|
||
Zoom wird aus Dict übernommen oder defaultmäßig auf 1.0 gesetzt."""
|
||
if isinstance(item, str):
|
||
return {"url": item, "zoom": 1.0}
|
||
if isinstance(item, dict):
|
||
return {"url": item["url"], "zoom": item.get("zoom", 1.0)}
|
||
return None
|
||
|
||
|
||
def playlist_item_name(item):
|
||
"""Ermittelt den Anzeigenamen eines Playlist-Eintrags (String → direkt, Dict → "url" oder "name")."""
|
||
if isinstance(item, str):
|
||
return item
|
||
if isinstance(item, dict):
|
||
return item.get("url") or item.get("name", "")
|
||
return ""
|
||
|
||
|
||
def playlist_item_enabled(item):
|
||
"""Prüft, ob ein Playlist-Eintrag aktiviert ist.
|
||
Strings sind immer aktiv, Dicts haben ein optionales "enabled"-Feld (default: True)."""
|
||
if isinstance(item, (str,)):
|
||
return True
|
||
if isinstance(item, dict):
|
||
return item.get("enabled", True)
|
||
return True
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Priority-Playlist-Helfer
|
||
# -------------------------------------------------
|
||
def load_priority_files():
|
||
"""Lädt die Priority-Playlist aus config.json plus die dazugehörigen Dateien
|
||
aus media/priority/. Gibt eine Liste von Dicts mit name/type/size/enabled zurück.
|
||
Neue Dateien im Ordner, die noch nicht in der Playlist sind, werden angehängt."""
|
||
cfg = load_config()
|
||
prio_dir = os.path.join(MEDIA_DIR, "priority")
|
||
os.makedirs(prio_dir, exist_ok=True)
|
||
|
||
priority_files = []
|
||
prio_playlist = cfg.get("priority", {}).get("playlist", [])
|
||
|
||
# 1. Einträge aus der config.json-Playlist
|
||
for item in prio_playlist:
|
||
item_name = playlist_item_name(item)
|
||
if is_url(item):
|
||
url_data = normalize_url(item)
|
||
if url_data:
|
||
priority_files.append({
|
||
"name": url_data["url"],
|
||
"type": "url",
|
||
"size": "URL",
|
||
"zoom": url_data.get("zoom", 1.0),
|
||
"enabled": playlist_item_enabled(item),
|
||
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False
|
||
})
|
||
continue
|
||
|
||
file_path = os.path.join(prio_dir, item_name)
|
||
if not os.path.exists(file_path):
|
||
continue
|
||
|
||
ext = os.path.splitext(item_name)[1].lower()
|
||
ftype = "video" if ext == ".mp4" else "image"
|
||
size = os.path.getsize(file_path) // 1024
|
||
|
||
priority_files.append({
|
||
"name": item_name,
|
||
"type": ftype,
|
||
"size": size,
|
||
"enabled": playlist_item_enabled(item)
|
||
})
|
||
|
||
# 2. Neue Dateien aus dem Ordner, die noch nicht in der Playlist sind
|
||
playlist_names = {playlist_item_name(item) for item in prio_playlist}
|
||
for f in sorted(os.listdir(prio_dir)):
|
||
if f in playlist_names or f.startswith("._"):
|
||
continue
|
||
|
||
file_path = os.path.join(prio_dir, f)
|
||
ext = os.path.splitext(f)[1].lower()
|
||
ftype = "video" if ext == ".mp4" else "image"
|
||
size = os.path.getsize(file_path) // 1024
|
||
|
||
priority_files.append({
|
||
"name": f,
|
||
"type": ftype,
|
||
"size": size
|
||
})
|
||
|
||
return priority_files
|
||
|
||
|
||
def prio_redirect(site):
|
||
"""Gibt den Redirect-Pfad zurück, der nach Priority-Operationen aufgerufen wird."""
|
||
return f"/admin/{site}/priority"
|
||
|
||
|
||
def get_background_url(site):
|
||
"""Prüft, ob für einen Standort ein benutzerdefiniertes Hintergrundbild existiert.
|
||
Gibt die URL zurück oder None (dann wird das Standard-Wallpaper verwendet)."""
|
||
site_dir = os.path.join(MEDIA_DIR, site)
|
||
if not os.path.isdir(site_dir):
|
||
return None
|
||
for f in sorted(os.listdir(site_dir)):
|
||
if f.startswith("background."):
|
||
ext = os.path.splitext(f)[1].lower()
|
||
if ext in (".jpg", ".jpeg", ".png"):
|
||
return f"/media/{site}/background/{f}"
|
||
return None
|
||
|
||
|
||
def _regenerate_welcome_for_site(site):
|
||
"""Regeneriert die Willkommensseite mit aktuellem Hintergrund,
|
||
falls zuvor Kundendaten gespeichert wurden."""
|
||
config = load_config()
|
||
welcome_data = config.get("sites", {}).get(site, {}).get("welcome_data")
|
||
if welcome_data:
|
||
names = welcome_data.get("names", [])
|
||
urls = welcome_data.get("logo_urls", [])
|
||
if names:
|
||
bg_url = get_background_url(site)
|
||
generate_welcome_page.save_welcome_page(names, urls, site=site, background_url=bg_url)
|
||
print(f"♻️ Willkommensseite für {site} mit aktualisiertem Hintergrund neu generiert")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Medien ausliefern (statische Dateien)
|
||
# -------------------------------------------------
|
||
@app.route("/media/<site>/background/<path:filename>")
|
||
def media_background(site, filename):
|
||
"""Liefert das Hintergrundbild für die Willkommensseite aus."""
|
||
path = os.path.join(MEDIA_DIR, site)
|
||
return send_from_directory(path, filename)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# User-Verwaltung (Flask-Login mit users.json)
|
||
# -------------------------------------------------
|
||
class User(UserMixin):
|
||
"""User mit E-Mail (id), Rolle, Standort-Berechtigungen und Profilfeldern."""
|
||
|
||
def __init__(self, email, password_hash="", role="user", sites=None,
|
||
must_change_password=False, first_name="", last_name="",
|
||
department="", notes="", mfa_enabled=False, mfa_secret="",
|
||
mfa_recovery_codes=None, trusted_devices=None):
|
||
self.email = email
|
||
self.id = email
|
||
self.password_hash = password_hash
|
||
self.role = role
|
||
self.sites = sites or []
|
||
self.must_change_password = must_change_password
|
||
self.first_name = first_name
|
||
self.last_name = last_name
|
||
self.department = department
|
||
self.notes = notes
|
||
self.mfa_enabled = mfa_enabled
|
||
self.mfa_secret = mfa_secret
|
||
self.mfa_recovery_codes = mfa_recovery_codes or []
|
||
self.trusted_devices = trusted_devices or []
|
||
|
||
@property
|
||
def is_admin(self):
|
||
return self.role == "admin"
|
||
|
||
@property
|
||
def is_superuser(self):
|
||
return self.role == "superuser"
|
||
|
||
def can_access_site(self, site):
|
||
return self.is_admin or self.is_superuser or site in self.sites
|
||
|
||
|
||
def load_users():
|
||
"""Liest die users.json. Gibt leeres Dict zurück, wenn nicht vorhanden oder fehlerhaft."""
|
||
if not os.path.exists(USERS_FILE):
|
||
return {}
|
||
try:
|
||
with open(USERS_FILE) as f:
|
||
return json.load(f)
|
||
except (json.JSONDecodeError, OSError):
|
||
return {}
|
||
|
||
|
||
def save_users(users):
|
||
"""Schreibt das User-Dict zurück in die users.json."""
|
||
with open(USERS_FILE, "w") as f:
|
||
json.dump(users, f, indent=2)
|
||
|
||
|
||
def get_user(email):
|
||
"""Lädt einen einzelnen User aus users.json. Gibt None zurück, wenn nicht gefunden."""
|
||
users = load_users()
|
||
data = users.get(email)
|
||
if data:
|
||
return User(email, **data)
|
||
return None
|
||
|
||
|
||
def init_user_db():
|
||
"""Legt users.json mit einem Admin-Konto aus config.json.admin an, falls sie nicht existiert oder leer ist."""
|
||
if os.path.exists(USERS_FILE):
|
||
try:
|
||
with open(USERS_FILE) as f:
|
||
content = f.read().strip()
|
||
if content:
|
||
return
|
||
except OSError:
|
||
pass
|
||
cfg = load_config()
|
||
admin = cfg.get("admin", {})
|
||
username = admin.get("username", "admin@signage.local")
|
||
password = admin.get("password", "admin")
|
||
users = {
|
||
username: {
|
||
"password_hash": generate_password_hash(password),
|
||
"role": "admin",
|
||
"sites": [],
|
||
"must_change_password": False,
|
||
"first_name": "",
|
||
"last_name": "",
|
||
"department": "",
|
||
"notes": ""
|
||
}
|
||
}
|
||
save_users(users)
|
||
print(f"✅ users.json mit Admin-Konto ({username}) angelegt (Passwort aus config.json)")
|
||
|
||
|
||
@login_manager.user_loader
|
||
def load_user(user_id):
|
||
"""Lädt einen User aus users.json anhand der E-Mail (Flask-Login-Callback)."""
|
||
return get_user(user_id)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Dekoratoren: Admin / Standort-Zugriff
|
||
# -------------------------------------------------
|
||
def admin_required(f):
|
||
"""Erfordert einen Admin-User (role=admin)."""
|
||
@wraps(f)
|
||
@login_required
|
||
def decorated_function(*args, **kwargs):
|
||
if not current_user.is_admin:
|
||
abort(403)
|
||
return f(*args, **kwargs)
|
||
return decorated_function
|
||
|
||
|
||
def site_access_required(f):
|
||
"""Erfordert Zugriff auf den Standort (admin →alle, user →nur freigegebene)."""
|
||
@wraps(f)
|
||
@login_required
|
||
def decorated_function(*args, **kwargs):
|
||
site = kwargs.get("site")
|
||
if site and not current_user.can_access_site(site):
|
||
abort(403)
|
||
return f(*args, **kwargs)
|
||
return decorated_function
|
||
|
||
|
||
def get_accessible_sites(cfg, user):
|
||
"""Gibt die Liste der Standorte zurück, auf die ein User Zugriff hat."""
|
||
all_sites = sorted(cfg.get("sites", {}).keys())
|
||
if user.is_admin or user.is_superuser:
|
||
return all_sites
|
||
return [s for s in all_sites if s in user.sites]
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Login / Logout
|
||
# -------------------------------------------------
|
||
@app.route("/login", methods=["GET", "POST"])
|
||
def login():
|
||
"""Login-Seite. Prüft Zugangsdaten gegen users.json (gehashte Passwörter)."""
|
||
error = None
|
||
next_url = None
|
||
|
||
if request.method == "POST":
|
||
email = request.form.get("username", "").strip().lower()
|
||
password = request.form.get("password", "")
|
||
mfa_code = request.form.get("mfa_code", "")
|
||
|
||
user = get_user(email)
|
||
if user:
|
||
if user.password_hash:
|
||
try:
|
||
valid = check_password_hash(user.password_hash, password)
|
||
except (ValueError, TypeError):
|
||
valid = False
|
||
else:
|
||
valid = False
|
||
user.must_change_password = True
|
||
|
||
if valid:
|
||
if user.mfa_enabled:
|
||
if mfa_code:
|
||
totp = pyotp.TOTP(user.mfa_secret)
|
||
if totp.verify(mfa_code):
|
||
login_user(user)
|
||
add_history_entry("user_login", f"User '{email}' angemeldet (mit MFA)", email)
|
||
if user.must_change_password:
|
||
return redirect("/change-password")
|
||
return redirect("/admin")
|
||
# Recovery-Code versuchen
|
||
if mfa_code in user.mfa_recovery_codes:
|
||
codes = user.mfa_recovery_codes[:]
|
||
codes.remove(mfa_code)
|
||
users_data = load_users()
|
||
users_data[email]["mfa_recovery_codes"] = codes
|
||
save_users(users_data)
|
||
login_user(user)
|
||
add_history_entry("user_login", f"User '{email}' angemeldet (mit Recovery-Code)", email)
|
||
return redirect("/mfa/setup?recovered=1")
|
||
error = "Ungültiger Sicherheitscode"
|
||
else:
|
||
# Trusted Device prüfen (Cookie überspringt MFA)
|
||
trust_token = request.cookies.get("mfa_trust")
|
||
if trust_token:
|
||
now = datetime.now()
|
||
# Abgelaufene Einträge bereinigen
|
||
active = [d for d in trusted
|
||
if d.get("token") != trust_token or
|
||
datetime.fromisoformat(d["expires"]) > now]
|
||
changed = len(active) != len(trusted)
|
||
for d in trusted:
|
||
if d.get("token") == trust_token:
|
||
try:
|
||
expires = datetime.fromisoformat(d["expires"])
|
||
if expires > now:
|
||
# Trusted Device gültig → MFA überspringen
|
||
login_user(user)
|
||
add_history_entry("user_login", f"User '{email}' angemeldet (Trusted Device)", email)
|
||
if user.must_change_password:
|
||
return redirect("/change-password")
|
||
return redirect("/admin")
|
||
else:
|
||
pass
|
||
except (ValueError, KeyError):
|
||
pass
|
||
if changed:
|
||
users_data[email]["trusted_devices"] = active
|
||
save_users(users_data)
|
||
session["mfa_user"] = email
|
||
session["mfa_password_ok"] = True
|
||
return redirect("/mfa/verify")
|
||
else:
|
||
login_user(user)
|
||
add_history_entry("user_login", f"User '{email}' angemeldet", email)
|
||
if user.must_change_password:
|
||
return redirect("/change-password")
|
||
return redirect("/admin")
|
||
elif not user.password_hash:
|
||
login_user(user)
|
||
add_history_entry("user_login", f"User '{email}' angemeldet (Passwort-Änderung erforderlich)", email)
|
||
return redirect("/change-password")
|
||
error = "Ungültige Zugangsdaten"
|
||
|
||
return render_template("login.html", error=error, next=next_url)
|
||
|
||
|
||
@app.route("/logout")
|
||
def logout():
|
||
"""Loggt den Admin aus und leitet zur Login-Seite weiter."""
|
||
email = current_user.email if current_user.is_authenticated else "unknown"
|
||
logout_user()
|
||
add_history_entry("user_logout", f"User '{email}' abgemeldet", email)
|
||
return redirect("/login")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# MFA (Multi-Faktor-Authentifizierung per TOTP)
|
||
# -------------------------------------------------
|
||
|
||
@app.route("/mfa/verify", methods=["GET", "POST"])
|
||
def mfa_verify():
|
||
"""TOTP-Code nach erfolgreichem Login (Passwort) eingeben."""
|
||
email = session.get("mfa_user")
|
||
if not email:
|
||
return redirect("/login")
|
||
|
||
if request.method == "POST":
|
||
code = request.form.get("code", "").strip()
|
||
action = request.form.get("action", "")
|
||
user = get_user(email)
|
||
|
||
if not user:
|
||
session.pop("mfa_user", None)
|
||
return redirect("/login")
|
||
|
||
if action == "recovery":
|
||
return render_template("login.html", error=None, show_mfa=True, mfa_user=email, recovery_mode=True)
|
||
|
||
totp = pyotp.TOTP(user.mfa_secret)
|
||
|
||
if code and totp.verify(code):
|
||
login_user(user)
|
||
session.pop("mfa_user", None)
|
||
# Trusted Device speichern wenn gewünscht
|
||
remember_days = request.form.get("remember")
|
||
if remember_days:
|
||
try:
|
||
days = int(remember_days)
|
||
if 1 <= days <= 365:
|
||
token = secrets.token_urlsafe(32)
|
||
exp_date = datetime.now() + timedelta(days=days)
|
||
expires = exp_date.isoformat()
|
||
users_data = load_users()
|
||
trusted = users_data.setdefault(email, {}).setdefault("trusted_devices", [])
|
||
trusted.append({"token": token, "expires": expires})
|
||
save_users(users_data)
|
||
add_history_entry("user_login", f"User '{email}' angemeldet (mit MFA, Gerät für {days} Tage gespeichert)", email)
|
||
target = "/change-password" if user.must_change_password else "/admin"
|
||
resp = redirect(target)
|
||
resp.set_cookie("mfa_trust", token, max_age=days * 86400,
|
||
path="/",
|
||
httponly=True, samesite="Lax",
|
||
secure=False)
|
||
return resp
|
||
except (ValueError, TypeError):
|
||
pass
|
||
add_history_entry("user_login", f"User '{email}' angemeldet (mit MFA)", email)
|
||
if user.must_change_password:
|
||
return redirect("/change-password")
|
||
return redirect("/admin")
|
||
|
||
# Recovery-Code prüfen
|
||
if code in user.mfa_recovery_codes:
|
||
codes = user.mfa_recovery_codes[:]
|
||
codes.remove(code)
|
||
users_data = load_users()
|
||
users_data[email]["mfa_recovery_codes"] = codes
|
||
save_users(users_data)
|
||
login_user(user)
|
||
session.pop("mfa_user", None)
|
||
add_history_entry("user_login", f"User '{email}' angemeldet (mit Recovery-Code)", email)
|
||
return redirect("/mfa/setup?recovered=1")
|
||
|
||
return render_template("mfa_verify.html", error="Ungültiger Code", email=email)
|
||
|
||
return render_template("mfa_verify.html", error=None, email=email)
|
||
|
||
|
||
@app.route("/mfa/setup", methods=["GET", "POST"])
|
||
@login_required
|
||
def mfa_setup():
|
||
"""MFA einrichten: QR-Code anzeigen, ersten Code verifizieren."""
|
||
cfg = load_config()
|
||
site_list = get_accessible_sites(cfg, current_user)
|
||
email = current_user.email
|
||
users_data = load_users()
|
||
user_data = users_data.get(email, {})
|
||
recovered = request.args.get("recovered")
|
||
disabled = request.args.get("disabled")
|
||
error_param = request.args.get("error")
|
||
|
||
secret = user_data.get("mfa_secret") or pyotp.random_base32()
|
||
# Secret sofort persistieren, damit GET (QR-Code) und POST (Verifikation) dasselbe Secret verwenden
|
||
if not user_data.get("mfa_secret"):
|
||
users_data.setdefault(email, {})["mfa_secret"] = secret
|
||
save_users(users_data)
|
||
|
||
totp = pyotp.TOTP(secret)
|
||
|
||
if request.method == "POST":
|
||
code = request.form.get("code", "").strip()
|
||
if totp.verify(code):
|
||
recovery = [secrets.token_hex(3).upper()[:7] for _ in range(8)]
|
||
users_data[email]["mfa_enabled"] = True
|
||
users_data[email]["mfa_recovery_codes"] = recovery
|
||
save_users(users_data)
|
||
add_history_entry("mfa_enabled", f"User '{email}' hat MFA aktiviert", email)
|
||
return render_template("mfa_recovery.html", codes=recovery, email=email,
|
||
site_list=site_list, current_site=site_list[0] if site_list else "",
|
||
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
|
||
return render_template("mfa_setup.html", error="Code ungültig. Bitte versuche es erneut.",
|
||
email=email, secret=secret, qr_data=None,
|
||
site_list=site_list, current_site=site_list[0] if site_list else "",
|
||
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
|
||
|
||
uri = totp.provisioning_uri(name=email, issuer_name="CANCOM Signage")
|
||
img = qrcode.make(uri)
|
||
buf = io.BytesIO()
|
||
img.save(buf, format="PNG")
|
||
qr_b64 = base64.b64encode(buf.getvalue()).decode()
|
||
|
||
return render_template("mfa_setup.html", error=None if not error_param else "Passwort ist falsch",
|
||
email=email, secret=secret, qr_data=qr_b64,
|
||
recovered=recovered, disabled=disabled,
|
||
site_list=site_list, current_site=site_list[0] if site_list else "",
|
||
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
|
||
|
||
|
||
@app.route("/mfa/disable", methods=["POST"])
|
||
@login_required
|
||
def mfa_disable():
|
||
"""MFA deaktivieren (mit Passwort-Bestätigung)."""
|
||
email = current_user.email
|
||
password = request.form.get("password", "")
|
||
|
||
user = get_user(email)
|
||
if user and user.password_hash:
|
||
try:
|
||
valid = check_password_hash(user.password_hash, password)
|
||
except (ValueError, TypeError):
|
||
valid = False
|
||
else:
|
||
valid = False
|
||
|
||
if not valid:
|
||
return redirect("/mfa/setup?error=1")
|
||
|
||
users_data = load_users()
|
||
users_data[email]["mfa_enabled"] = False
|
||
users_data[email]["mfa_secret"] = ""
|
||
users_data[email]["mfa_recovery_codes"] = []
|
||
users_data[email]["trusted_devices"] = []
|
||
save_users(users_data)
|
||
add_history_entry("mfa_disabled", f"User '{email}' hat MFA deaktiviert", email)
|
||
resp = redirect("/mfa/setup?disabled=1")
|
||
resp.delete_cookie("mfa_trust")
|
||
return resp
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Willkommensseite (Kunden-Logos + Generierung)
|
||
# -------------------------------------------------
|
||
@app.route("/willkommen", methods=["GET", "POST"])
|
||
@app.route("/customer", methods=["GET", "POST"])
|
||
@login_required
|
||
def add_customer():
|
||
"""
|
||
Formular für die Willkommensseite:
|
||
- Bis zu 3 Kundennamen eingeben
|
||
- Logos per OpenAI → Brandfetch suchen
|
||
- welcome.html wird generiert und in der Lobby-Playlist vorne platziert
|
||
"""
|
||
error = None
|
||
success = None
|
||
logo_urls = [None, None, None]
|
||
customer_names = ["", "", ""]
|
||
contacts = ["", "", ""]
|
||
site = request.args.get("site") or request.form.get("site") or ""
|
||
|
||
# Fallback auf ersten für den User zugänglichen Standort
|
||
if not site:
|
||
config = load_config()
|
||
sites = get_accessible_sites(config, current_user)
|
||
site = sites[0] if sites else ""
|
||
|
||
# Prüfen, ob der User Zugriff auf den Standort hat
|
||
if site and not current_user.can_access_site(site):
|
||
abort(403)
|
||
|
||
if request.method == "POST":
|
||
customer_names = [
|
||
request.form.get("customer_name_1", "").strip(),
|
||
request.form.get("customer_name_2", "").strip(),
|
||
request.form.get("customer_name_3", "").strip(),
|
||
]
|
||
contacts = [
|
||
request.form.get("contact_1", "").strip(),
|
||
request.form.get("contact_2", "").strip(),
|
||
request.form.get("contact_3", "").strip(),
|
||
]
|
||
filled_names = [n for n in customer_names if n]
|
||
|
||
print(f"🔍 APP.PY - Suche nach Kunden: {filled_names} (site: {site})")
|
||
|
||
if not filled_names:
|
||
error = "Mindestens ein Kundenname erforderlich"
|
||
elif not site:
|
||
error = "Kein Standort ausgewählt"
|
||
else:
|
||
try:
|
||
found_logos = []
|
||
for name in filled_names:
|
||
logo = generate_welcome_page.search_customer_logo(name)
|
||
found_logos.append(logo)
|
||
|
||
valid = [(n, l) for n, l in zip(filled_names, found_logos) if l]
|
||
|
||
if not valid:
|
||
error = "Kein Logo konnte gefunden werden"
|
||
else:
|
||
valid_names = [n for n, l in valid]
|
||
valid_urls = [l for n, l in valid]
|
||
valid_contacts = []
|
||
for name in valid_names:
|
||
for i, cn in enumerate(customer_names):
|
||
if cn == name:
|
||
valid_contacts.append(contacts[i] if i < len(contacts) else "")
|
||
break
|
||
else:
|
||
valid_contacts.append("")
|
||
for i, name in enumerate(customer_names):
|
||
for j, (vn, vu) in enumerate(zip(valid_names, valid_urls)):
|
||
if vn == name:
|
||
logo_urls[i] = vu
|
||
break
|
||
|
||
bg_url = get_background_url(site)
|
||
html_filename = generate_welcome_page.save_welcome_page(valid_names, valid_urls, contacts=valid_contacts, site=site, background_url=bg_url)
|
||
|
||
if html_filename:
|
||
if generate_welcome_page.add_customer_to_lobby_playlist(html_filename, site=site):
|
||
# Letzte Kundendaten für Background-Update speichern
|
||
config = load_config()
|
||
site_cfg = config.setdefault("sites", {}).setdefault(site, {})
|
||
site_cfg["welcome_data"] = {"names": valid_names, "logo_urls": valid_urls, "contacts": valid_contacts}
|
||
save_config(config)
|
||
add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt", current_user.email)
|
||
success = f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt!"
|
||
else:
|
||
error = "Fehler beim Hinzufügen zur Playliste"
|
||
else:
|
||
error = "Fehler beim Speichern der Willkommensseite"
|
||
except ValueError as e:
|
||
error = f"Konfigurationsfehler: {str(e)}"
|
||
except Exception as e:
|
||
error = f"Fehler: {str(e)}"
|
||
|
||
config = load_config()
|
||
site_list = get_site_list(config)
|
||
background_url = get_background_url(site)
|
||
|
||
if request.args.get("reset") and site:
|
||
site_cfg = config.setdefault("sites", {}).setdefault(site, {})
|
||
site_cfg.pop("welcome_data", None)
|
||
save_config(config)
|
||
return redirect(url_for("add_customer", site=site))
|
||
|
||
welcome_data = config.get("sites", {}).get(site, {}).get("welcome_data", {})
|
||
saved_contacts = welcome_data.get("contacts", ["", "", ""])
|
||
if request.method != "POST":
|
||
contacts = saved_contacts
|
||
customer_names = welcome_data.get("names", ["", "", ""])
|
||
logo_urls = welcome_data.get("logo_urls", [None, None, None])
|
||
return render_template(
|
||
"customer.html",
|
||
error=error,
|
||
success=success,
|
||
logo_urls=logo_urls,
|
||
customer_names=customer_names,
|
||
contacts=contacts,
|
||
site=site,
|
||
site_list=site_list,
|
||
current_site=site,
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=os.uname().nodename,
|
||
background_url=background_url
|
||
)
|
||
|
||
|
||
@app.route("/api/customer", methods=["POST"])
|
||
@login_required
|
||
def api_add_customer():
|
||
"""
|
||
API-Endpunkt für die Willkommensseite (JSON).
|
||
Erwartet: {"customer_name": "...", "site": "..."}
|
||
Gibt die Logo-URL und den Status zurück.
|
||
"""
|
||
try:
|
||
data = request.get_json()
|
||
customer_name = data.get("customer_name", "").strip()
|
||
site = data.get("site", "").strip()
|
||
|
||
if not customer_name:
|
||
return jsonify({"error": "Customer name required"}), 400
|
||
|
||
# Default auf ersten Standort
|
||
if not site:
|
||
config = load_config()
|
||
sites = get_site_list(config)
|
||
site = sites[0] if sites else ""
|
||
|
||
if not site:
|
||
return jsonify({"error": "No site configured"}), 400
|
||
|
||
if not current_user.can_access_site(site):
|
||
return jsonify({"error": "Access denied"}), 403
|
||
|
||
logo_url = generate_welcome_page.search_customer_logo(customer_name)
|
||
|
||
if not logo_url:
|
||
return jsonify({"error": "Logo not found"}), 400
|
||
|
||
contact = data.get("contact", "").strip()
|
||
|
||
html_filename = generate_welcome_page.save_welcome_page([customer_name], [logo_url], contacts=[contact], site=site)
|
||
|
||
if not html_filename:
|
||
return jsonify({"error": "Failed to create welcome page"}), 500
|
||
|
||
if generate_welcome_page.add_customer_to_lobby_playlist(html_filename, site=site):
|
||
return jsonify({
|
||
"success": True,
|
||
"message": f"Customer '{customer_name}' added successfully",
|
||
"welcome_page": html_filename,
|
||
"logo_url": logo_url,
|
||
"site": site
|
||
}), 201
|
||
else:
|
||
return jsonify({"error": "Failed to add to playlist"}), 500
|
||
|
||
except ValueError as e:
|
||
return jsonify({"error": str(e)}), 400
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)}), 500
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Medien ausliefern (statische Dateien)
|
||
# -------------------------------------------------
|
||
@app.route("/media/priority/<path:filename>")
|
||
def media_priority(filename):
|
||
"""Liefert globale Priority-Medien aus media/priority/ aus."""
|
||
path = os.path.join(MEDIA_DIR, "priority")
|
||
return send_from_directory(path, filename)
|
||
|
||
|
||
@app.route("/media/<site>/<screen>/<path:filename>")
|
||
def media(site, screen, filename):
|
||
"""Liefert Medien-Dateien für einen bestimmten Screen aus."""
|
||
path = os.path.join(MEDIA_DIR, site, screen)
|
||
return send_from_directory(path, filename)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Player
|
||
# -------------------------------------------------
|
||
@app.route("/player/<site>/<screen>")
|
||
def player(site, screen):
|
||
"""
|
||
Player-Ansicht für einen Screen.
|
||
Baut die Playlist (normal + optional priority) zusammen und rendert player.html.
|
||
Der Player zeigt abwechselnd Bilder, Videos, HTML-Seiten und URLs an.
|
||
"""
|
||
config = load_config()
|
||
sites_cfg = config.setdefault("sites", {})
|
||
site_cfg = sites_cfg.get(site)
|
||
|
||
if not site_cfg:
|
||
abort(404)
|
||
|
||
screens_cfg = site_cfg.setdefault("screens", {})
|
||
screen_cfg = screens_cfg.get(screen)
|
||
|
||
if not screen_cfg:
|
||
abort(404)
|
||
|
||
folder = os.path.join(MEDIA_DIR, site, screen)
|
||
playlist = screen_cfg.get("playlist", [])
|
||
|
||
show_images = screen_cfg.get("show_images", True)
|
||
show_videos = screen_cfg.get("show_videos", True)
|
||
|
||
def allowed_file(name):
|
||
"""Prüft, ob eine Datei angezeigt werden darf (abhängig von show_images / show_videos)."""
|
||
if name.startswith("._"):
|
||
return False
|
||
ext = name.lower()
|
||
if ext.endswith((".jpg", ".jpeg", ".png")):
|
||
return show_images
|
||
if ext.endswith(".mp4"):
|
||
return show_videos
|
||
if ext.endswith((".html", ".htm")):
|
||
return True
|
||
return False
|
||
|
||
# ─── Normale Screen-Playlist ───
|
||
normal_files = []
|
||
|
||
# 1. Playlist-Reihenfolge aus config.json
|
||
for item in playlist:
|
||
if not playlist_item_enabled(item):
|
||
continue
|
||
item_name = playlist_item_name(item)
|
||
if is_url(item):
|
||
url_data = normalize_url(item)
|
||
if url_data:
|
||
normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": True, "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False})
|
||
continue
|
||
|
||
if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name):
|
||
normal_files.append({"kind": "file", "name": item_name, "enabled": True})
|
||
|
||
existing_file_names = {
|
||
item["name"] for item in normal_files if item["kind"] == "file"
|
||
}
|
||
|
||
# 2. Neue Dateien aus dem Medien-Ordner (ohne Playlist-Eintrag) hinten anhängen
|
||
if not playlist:
|
||
for f in sorted(os.listdir(folder)):
|
||
if f in existing_file_names or f.startswith("._"):
|
||
continue
|
||
if allowed_file(f):
|
||
normal_files.append({"kind": "file", "name": f})
|
||
|
||
# ─── Priority-Playlist (global) ───
|
||
priority_cfg = config.get("priority", {})
|
||
|
||
if priority_cfg.get("enabled", False):
|
||
prio_files = []
|
||
for item in priority_cfg.get("playlist", []):
|
||
if not playlist_item_enabled(item):
|
||
continue
|
||
item_name = playlist_item_name(item)
|
||
if is_url(item):
|
||
url_data = normalize_url(item)
|
||
if url_data:
|
||
prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": True, "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False})
|
||
continue
|
||
|
||
file_path = os.path.join(MEDIA_DIR, "priority", item_name)
|
||
if os.path.exists(file_path):
|
||
prio_files.append({"kind": "file", "name": item_name, "enabled": True})
|
||
else:
|
||
prio_files = []
|
||
|
||
return render_template(
|
||
"player.html",
|
||
site=site,
|
||
screen=screen,
|
||
normal_files=[item for item in normal_files if item.get("enabled", True)],
|
||
prio_files=[item for item in prio_files if item.get("enabled", True)],
|
||
interval=screen_cfg.get("interval", 10),
|
||
newsticker_text=screen_cfg.get("newsticker_text", ""),
|
||
newsticker_enabled=screen_cfg.get("newsticker_enabled", False),
|
||
custom_url=screen_cfg.get("custom_url", ""),
|
||
custom_url_label=screen_cfg.get("custom_url_label", ""),
|
||
custom_url_enabled=screen_cfg.get("custom_url_enabled", False),
|
||
custom_url_target=screen_cfg.get("custom_url_target", "overlay"),
|
||
custom_url_position=screen_cfg.get("custom_url_position", "top-left"),
|
||
stay_on_first=screen_cfg.get("stay_on_first", False),
|
||
voice_agent_enabled=screen_cfg.get("voice_agent_enabled", False),
|
||
voice_agent_show_image=screen_cfg.get("voice_agent_show_image") if screen_cfg.get("voice_agent_show_image") is not None else False,
|
||
voice_agent_url=config.get("voice_agent_url", ""),
|
||
voice_agent_label=screen_cfg.get("voice_agent_label", "Digitaler Assistent"),
|
||
voice_agent_target=screen_cfg.get("voice_agent_target", "overlay"),
|
||
voice_agent_position=screen_cfg.get("voice_agent_position", "top-left"),
|
||
player_params=dict(request.args)
|
||
)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Hash für automatisches Player-Reload
|
||
# ─────────────────────────────────────────────────
|
||
# Der Player pollt alle 5s diese Route.
|
||
# Wenn sich der Hash ändert, wird neu geladen.
|
||
# -------------------------------------------------
|
||
@app.route("/playlist/<site>/<screen>/hash")
|
||
def playlist_hash(site, screen):
|
||
"""Berechnet einen MD5-Hash über die aktuelle Konfiguration (Screen + Priority).
|
||
Gibt JSON mit Hash und aktuellem stay_on_first-Wert zurück.
|
||
Zeichnet gleichzeitig den Heartbeat auf (Player aktiv).
|
||
Nimmt optionale Query-Parameter w (Breite) und h (Höhe) für Client-Info."""
|
||
ip = request.remote_addr or ""
|
||
ua = request.headers.get("User-Agent", "")
|
||
resolution = f"{request.args.get('w', '?')}x{request.args.get('h', '?')}"
|
||
record_heartbeat(site, screen, ip=ip, ua=ua, resolution=resolution)
|
||
config = load_config()
|
||
|
||
relevant = {
|
||
"sites": {
|
||
site: config.get("sites", {}).get(site, {})
|
||
},
|
||
"priority": config.get("priority", {})
|
||
}
|
||
|
||
blob = json.dumps(relevant, sort_keys=True).encode()
|
||
h = hashlib.md5(blob).hexdigest()
|
||
|
||
screen_cfg = config.get("sites", {}).get(site, {}).get("screens", {}).get(screen, {})
|
||
return {"hash": h, "stay_on_first": screen_cfg.get("stay_on_first", False)}
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin
|
||
# -------------------------------------------------
|
||
|
||
@app.route("/", strict_slashes=False)
|
||
def root_redirect():
|
||
"""Root weiterleiten zum Login."""
|
||
return redirect("/login")
|
||
|
||
|
||
@app.route("/admin", strict_slashes=False)
|
||
@login_required
|
||
def admin_redirect():
|
||
"""Leitet zum ersten für den User zugänglichen Standort weiter."""
|
||
cfg = load_config()
|
||
sites = get_accessible_sites(cfg, current_user)
|
||
if sites:
|
||
return redirect(f"/admin/{sites[0]}")
|
||
return "<h1>Kein Standort konfiguriert</h1><p>Bitte lege einen Standort im media-Verzeichnis an.</p>", 200
|
||
|
||
|
||
@app.route("/add-site")
|
||
@admin_required
|
||
def add_site():
|
||
"""Legt einen neuen Standort an (Verzeichnis + Config-Eintrag). Nur für Admins."""
|
||
name = request.args.get("name", "").strip().lower()
|
||
if not name:
|
||
return redirect("/admin")
|
||
|
||
cfg = load_config()
|
||
cfg.setdefault("sites", {}).setdefault(name, {"screens": {}})
|
||
os.makedirs(os.path.join(MEDIA_DIR, name), exist_ok=True)
|
||
save_config(cfg)
|
||
|
||
add_history_entry("site_created", f"Standort '{name}' angelegt", current_user.email)
|
||
|
||
return redirect(f"/admin/{name}")
|
||
|
||
|
||
@app.route("/admin/<site>/delete-site", methods=["POST"])
|
||
@admin_required
|
||
def delete_site(site):
|
||
"""Löscht einen Standort inkl. aller Screens und Medien (Config + Verzeichnis). Nur für Admins."""
|
||
cfg = load_config()
|
||
cfg.get("sites", {}).pop(site, None)
|
||
save_config(cfg)
|
||
|
||
site_dir = os.path.join(MEDIA_DIR, site)
|
||
if os.path.isdir(site_dir):
|
||
import shutil
|
||
shutil.rmtree(site_dir)
|
||
|
||
add_history_entry("site_deleted", f"Standort '{site}' gelöscht", current_user.email)
|
||
|
||
sites = get_site_list(cfg)
|
||
return redirect(f"/admin/{sites[0]}" if sites else "/admin")
|
||
|
||
|
||
@app.route("/admin/<site>", strict_slashes=False)
|
||
@site_access_required
|
||
def admin(site):
|
||
"""
|
||
Admin-Dashboard für einen Standort.
|
||
Zeigt alle Screens als Cards mit Tabler-Tabs (Playlist, Einstellungen, Medien).
|
||
"""
|
||
cfg = load_config()
|
||
|
||
site_list = get_accessible_sites(cfg, current_user)
|
||
|
||
# Prüfen, ob der Standort existiert
|
||
if site not in get_site_list(cfg):
|
||
if os.path.isdir(os.path.join(MEDIA_DIR, site)):
|
||
cfg.setdefault("sites", {})[site] = {"screens": {}}
|
||
save_config(cfg)
|
||
site_list = get_site_list(cfg)
|
||
else:
|
||
abort(404)
|
||
|
||
current_site = site
|
||
screens = {}
|
||
media_files = {}
|
||
screen_status = {}
|
||
client_info = {}
|
||
|
||
site_media_base = os.path.join(MEDIA_DIR, current_site)
|
||
|
||
# Screens erkennen: aus Config + Dateisystem
|
||
known_screens = set()
|
||
site_cfg = cfg.setdefault("sites", {}).setdefault(current_site, {"screens": {}})
|
||
config_screens = site_cfg.setdefault("screens", {})
|
||
|
||
# Ordner im Medienverzeichnis = Screens
|
||
if os.path.isdir(site_media_base):
|
||
for entry in os.listdir(site_media_base):
|
||
entry_path = os.path.join(site_media_base, entry)
|
||
if os.path.isdir(entry_path) and not entry.startswith("._"):
|
||
known_screens.add(entry)
|
||
|
||
# Screens aus Config ergänzen (auch ohne Ordner)
|
||
for s in config_screens:
|
||
known_screens.add(s)
|
||
|
||
for screen_name in sorted(known_screens):
|
||
path = os.path.join(site_media_base, screen_name)
|
||
os.makedirs(path, exist_ok=True)
|
||
|
||
files = []
|
||
screen_cfg = get_screen_config(cfg, current_site, screen_name)
|
||
playlist = screen_cfg.get("playlist", [])
|
||
screen_cfg["newsticker_text"] = screen_cfg.get("newsticker_text", "")
|
||
screen_cfg["newsticker_enabled"] = screen_cfg.get("newsticker_enabled", False)
|
||
|
||
# 1. Playlist-Einträge in Config-Reihenfolge
|
||
for item in playlist:
|
||
item_name = playlist_item_name(item)
|
||
|
||
if is_url(item):
|
||
url_data = normalize_url(item)
|
||
if url_data:
|
||
files.append({
|
||
"name": url_data["url"],
|
||
"type": "url",
|
||
"size": "URL",
|
||
"zoom": url_data.get("zoom", 1.0),
|
||
"enabled": playlist_item_enabled(item),
|
||
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False
|
||
})
|
||
continue
|
||
|
||
file_path = os.path.join(path, item_name)
|
||
if not os.path.exists(file_path):
|
||
continue
|
||
|
||
ext = os.path.splitext(item_name)[1].lower()
|
||
if ext == ".mp4":
|
||
ftype = "video"
|
||
if ext in (".jpg", ".jpeg", ".png"):
|
||
ftype = "image"
|
||
if ext == ".html":
|
||
ftype = "html"
|
||
|
||
size = os.path.getsize(file_path) // 1024
|
||
|
||
files.append({
|
||
"name": item_name,
|
||
"type": ftype,
|
||
"size": size,
|
||
"enabled": playlist_item_enabled(item)
|
||
})
|
||
|
||
# 2. Neue Dateien (ohne Playlist-Eintrag) anhängen
|
||
playlist_names = {playlist_item_name(item) for item in playlist}
|
||
for f in sorted(os.listdir(path)):
|
||
if f in playlist_names or f.startswith("._"):
|
||
continue
|
||
|
||
file_path = os.path.join(path, f)
|
||
ext = os.path.splitext(f)[1].lower()
|
||
ftype = "video" if ext == ".mp4" else "image"
|
||
size = os.path.getsize(file_path) // 1024
|
||
|
||
files.append({
|
||
"name": f,
|
||
"type": ftype,
|
||
"size": size
|
||
})
|
||
|
||
screens[screen_name] = screen_cfg
|
||
media_files[screen_name] = files
|
||
screen_status[screen_name] = "online" if screen_is_active(current_site, screen_name) else "offline"
|
||
client_info[screen_name] = get_client_info(current_site, screen_name)
|
||
|
||
return render_template(
|
||
"admin.html",
|
||
site_list=site_list,
|
||
current_site=current_site,
|
||
screens=screens,
|
||
media_files=media_files,
|
||
screen_status=screen_status,
|
||
client_info=client_info,
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=os.uname().nodename,
|
||
server_url=cfg.get("server_url", ""),
|
||
voice_agent_url=cfg.get("voice_agent_url", "")
|
||
)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Priority-Playlist (eigene Seite)
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/priority")
|
||
@site_access_required
|
||
def admin_priority(site):
|
||
"""Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien)."""
|
||
cfg = load_config()
|
||
site_list = get_accessible_sites(cfg, current_user)
|
||
priority_files = load_priority_files()
|
||
|
||
return render_template(
|
||
"priority.html",
|
||
site_list=site_list,
|
||
current_site=site,
|
||
priority_files=priority_files,
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=os.uname().nodename,
|
||
server_url=cfg.get("server_url", "")
|
||
)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Hintergrundbild für Willkommensseite hochladen
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/upload-background", methods=["POST"])
|
||
@site_access_required
|
||
def upload_background(site):
|
||
"""Lädt ein Hintergrundbild für die Willkommensseite hoch (pro Standort).
|
||
Gespeichert als media/<site>/background.<ext>."""
|
||
file = request.files.get("background")
|
||
if not file:
|
||
return redirect(f"/willkommen?site={site}")
|
||
|
||
ext = os.path.splitext(file.filename)[1].lower()
|
||
if ext not in UPLOAD_EXTENSIONS:
|
||
return "Ungültiger Dateityp", 400
|
||
|
||
site_dir = os.path.join(MEDIA_DIR, site)
|
||
os.makedirs(site_dir, exist_ok=True)
|
||
|
||
for f in os.listdir(site_dir):
|
||
if f.startswith("background."):
|
||
os.remove(os.path.join(site_dir, f))
|
||
|
||
filename = f"background{ext}"
|
||
file.save(os.path.join(site_dir, filename))
|
||
|
||
_regenerate_welcome_for_site(site)
|
||
|
||
add_history_entry("background_uploaded", f"Hintergrundbild für Standort '{site}' hochgeladen", current_user.email)
|
||
|
||
return redirect(f"/willkommen?site={site}")
|
||
|
||
|
||
@app.route("/admin/<site>/delete-background", methods=["POST"])
|
||
@site_access_required
|
||
def delete_background(site):
|
||
"""Löscht das Hintergrundbild für die Willkommensseite."""
|
||
site_dir = os.path.join(MEDIA_DIR, site)
|
||
if os.path.isdir(site_dir):
|
||
for f in os.listdir(site_dir):
|
||
if f.startswith("background."):
|
||
os.remove(os.path.join(site_dir, f))
|
||
|
||
_regenerate_welcome_for_site(site)
|
||
|
||
add_history_entry("background_deleted", f"Hintergrundbild für Standort '{site}' zurückgesetzt", current_user.email)
|
||
|
||
return redirect(f"/willkommen?site={site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Screen-Einstellungen speichern
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/update/<screen>", methods=["POST"])
|
||
@site_access_required
|
||
def update_screen(site, screen):
|
||
"""Speichert die allgemeinen Einstellungen für einen Screen (Intervall, Newsticker, Show-Bilder/Videos)."""
|
||
config = load_config()
|
||
cfg = get_screen_config(config, site, screen)
|
||
|
||
cfg["interval"] = int(request.form.get("interval") or 10)
|
||
cfg["show_images"] = "show_images" in request.form
|
||
cfg["show_videos"] = "show_videos" in request.form
|
||
cfg["newsticker_text"] = request.form.get("newsticker_text", "")
|
||
cfg["newsticker_enabled"] = "newsticker_enabled" in request.form
|
||
cfg["stay_on_first"] = "stay_on_first" in request.form
|
||
|
||
save_config(config)
|
||
return redirect(f"/admin/{site}")
|
||
|
||
|
||
@app.route("/admin/<site>/update-actions/<screen>", methods=["POST"])
|
||
@site_access_required
|
||
def update_screen_actions(site, screen):
|
||
"""Speichert die Aktionen-Einstellungen für einen Screen (Custom-URL + Position)."""
|
||
config = load_config()
|
||
cfg = get_screen_config(config, site, screen)
|
||
|
||
cfg["custom_url"] = request.form.get("custom_url", "").strip()
|
||
cfg["custom_url_label"] = request.form.get("custom_url_label", "").strip()
|
||
cfg["custom_url_enabled"] = "custom_url_enabled" in request.form
|
||
cfg["custom_url_target"] = request.form.get("custom_url_target", "overlay")
|
||
cfg["custom_url_position"] = request.form.get("custom_url_position", "top-left")
|
||
|
||
save_config(config)
|
||
return redirect(f"/admin/{site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Voice-Agent-Einstellungen speichern
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/update-voice/<screen>", methods=["POST"])
|
||
@site_access_required
|
||
def update_screen_voice(site, screen):
|
||
"""Speichert die Voice-Agent-Einstellungen für einen Screen (Enabled, Label, Target, Position)."""
|
||
config = load_config()
|
||
cfg = get_screen_config(config, site, screen)
|
||
|
||
cfg["voice_agent_enabled"] = "voice_agent_enabled" in request.form
|
||
cfg["voice_agent_show_image"] = "voice_agent_show_image" in request.form
|
||
cfg["voice_agent_label"] = request.form.get("voice_agent_label", "").strip()
|
||
cfg["voice_agent_target"] = request.form.get("voice_agent_target", "overlay")
|
||
cfg["voice_agent_position"] = request.form.get("voice_agent_position", "top-left")
|
||
|
||
save_config(config)
|
||
return redirect(f"/admin/{site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Screen löschen
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/delete-screen/<screen>", methods=["POST"])
|
||
@site_access_required
|
||
def delete_screen(site, screen):
|
||
"""Löscht einen Screen (Config-Eintrag + Medien-Verzeichnis)."""
|
||
config = load_config()
|
||
|
||
screens_cfg = config.get("sites", {}).get(site, {}).get("screens", {})
|
||
screens_cfg.pop(screen, None)
|
||
save_config(config)
|
||
|
||
screen_dir = os.path.join(MEDIA_DIR, site, screen)
|
||
if os.path.isdir(screen_dir):
|
||
import shutil
|
||
shutil.rmtree(screen_dir)
|
||
|
||
add_history_entry("screen_deleted", f"Screen '{screen}' an Standort '{site}' gelöscht", current_user.email)
|
||
|
||
return redirect(f"/admin/{site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Neuen Screen anlegen
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/add-screen", methods=["GET"])
|
||
@site_access_required
|
||
def add_screen(site):
|
||
"""Legt einen neuen Screen an (Verzeichnis + Config-Eintrag)."""
|
||
screen_name = request.args.get("name", "").strip().lower()
|
||
if not screen_name:
|
||
return redirect(f"/admin/{site}")
|
||
|
||
config = load_config()
|
||
|
||
os.makedirs(os.path.join(MEDIA_DIR, site, screen_name), exist_ok=True)
|
||
|
||
screens_cfg = config.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {})
|
||
if screen_name not in screens_cfg:
|
||
screens_cfg[screen_name] = {"playlist": [], "interval": 10, "show_images": True, "show_videos": True}
|
||
|
||
save_config(config)
|
||
add_history_entry("screen_created", f"Screen '{screen_name}' an Standort '{site}' angelegt", current_user.email)
|
||
return redirect(f"/admin/{site}#screen-{screen_name}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Medien-Upload
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/upload/<screen>", methods=["POST"])
|
||
@site_access_required
|
||
def upload(site, screen):
|
||
"""Lädt eine Datei hoch (Bild oder Video). Bei screen="priority" ins globale Verzeichnis."""
|
||
file = request.files.get("file")
|
||
if not file:
|
||
return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}")
|
||
|
||
ext = os.path.splitext(file.filename)[1].lower()
|
||
if ext not in UPLOAD_EXTENSIONS:
|
||
return "Ungültiger Dateityp", 400
|
||
|
||
filename = secure_filename(file.filename)
|
||
|
||
if screen == "priority":
|
||
file.save(os.path.join(MEDIA_DIR, "priority", filename))
|
||
else:
|
||
file.save(os.path.join(MEDIA_DIR, site, screen, filename))
|
||
|
||
return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: URL zur Playlist hinzufügen
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/add-url/<screen>", methods=["POST"])
|
||
@site_access_required
|
||
def add_url(site, screen):
|
||
"""Fügt eine URL (mit Zoom-Faktor) zur Playlist eines Screens oder der Priority-Playlist hinzu."""
|
||
url = request.form.get("url", "").strip()
|
||
zoom = request.form.get("zoom", "1.0").strip()
|
||
|
||
if not is_url(url):
|
||
return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}")
|
||
|
||
try:
|
||
zoom_factor = float(zoom)
|
||
if zoom_factor <= 0:
|
||
zoom_factor = 1.0
|
||
except (ValueError, TypeError):
|
||
zoom_factor = 1.0
|
||
|
||
url_data = {"url": url, "zoom": zoom_factor}
|
||
|
||
config = load_config()
|
||
if screen == "priority":
|
||
config.setdefault("priority", {}).setdefault("playlist", []).append(url_data)
|
||
else:
|
||
cfg = get_screen_config(config, site, screen)
|
||
cfg.setdefault("playlist", []).append(url_data)
|
||
|
||
save_config(config)
|
||
return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Datei aus Playlist löschen
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/delete/<screen>/<path:filename>", methods=["POST"])
|
||
@site_access_required
|
||
def delete_file(site, screen, filename):
|
||
"""Löscht eine Datei aus der Playlist (Config + Dateisystem)."""
|
||
cfg = load_config()
|
||
|
||
if screen == "priority":
|
||
path = os.path.join(MEDIA_DIR, "priority", filename)
|
||
playlist = cfg.get("priority", {}).get("playlist", [])
|
||
playlist[:] = [item for item in playlist if not (
|
||
(isinstance(item, str) and item == filename) or
|
||
(isinstance(item, dict) and item.get("url") == filename)
|
||
)]
|
||
else:
|
||
path = os.path.join(MEDIA_DIR, site, screen, filename)
|
||
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
|
||
playlist = screens_cfg.get(screen, {}).get("playlist", [])
|
||
playlist[:] = [item for item in playlist if not (
|
||
(isinstance(item, str) and item == filename) or
|
||
(isinstance(item, dict) and item.get("url") == filename)
|
||
)]
|
||
|
||
if os.path.exists(path):
|
||
os.remove(path)
|
||
|
||
save_config(cfg)
|
||
return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Playlist-Reihenfolge speichern (Drag & Drop)
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/playlist/<screen>", methods=["POST"])
|
||
@site_access_required
|
||
def save_playlist(site, screen):
|
||
"""
|
||
Speichert die über SortableJS per Drag & Drop neu sortierte Playlist.
|
||
Der Client schickt ein JSON mit der neuen Reihenfolge und enabled-Status.
|
||
"""
|
||
cfg = load_config()
|
||
data = request.get_json()
|
||
new_order = data["playlist"]
|
||
enabled_map = data.get("enabled", {})
|
||
|
||
if screen == "priority":
|
||
old_playlist = cfg.get("priority", {}).get("playlist", [])
|
||
else:
|
||
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
|
||
old_playlist = screens_cfg.get(screen, {}).get("playlist", [])
|
||
|
||
# Mapping: Name → Original-Item (für Zoom etc.)
|
||
item_map = {}
|
||
for item in old_playlist:
|
||
item_map[playlist_item_name(item)] = item
|
||
|
||
# Neue Playlist in der gewünschten Reihenfolge bauen
|
||
new_playlist = []
|
||
for key in new_order:
|
||
if key in item_map:
|
||
item = dict(item_map[key]) if isinstance(item_map[key], dict) else {"name": item_map[key]}
|
||
item["enabled"] = enabled_map.get(key, True if not isinstance(item_map[key], dict) else item_map[key].get("enabled", True))
|
||
new_playlist.append(item)
|
||
else:
|
||
new_playlist.append({"url": key, "zoom": 1.0, "enabled": enabled_map.get(key, True)} if is_url(key) else {"name": key, "enabled": enabled_map.get(key, True)})
|
||
|
||
if screen == "priority":
|
||
cfg.setdefault("priority", {})["playlist"] = new_playlist
|
||
else:
|
||
cfg.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {}).setdefault(screen, {})["playlist"] = new_playlist
|
||
|
||
save_config(cfg)
|
||
return "", 204
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Playlist-Item bearbeiten (URL + Zoom)
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/update-playlist-item/<screen>", methods=["POST"])
|
||
@site_access_required
|
||
def update_playlist_item(site, screen):
|
||
"""Aktualisiert URL und Zoom eines einzelnen Playlist-Items."""
|
||
data = request.get_json()
|
||
old_name = data.get("old_name")
|
||
new_url = data.get("new_url", "").strip()
|
||
zoom = data.get("zoom", 1.0)
|
||
|
||
if not is_url(new_url):
|
||
return jsonify({"error": "Ungültige URL"}), 400
|
||
|
||
try:
|
||
zoom = float(zoom)
|
||
if zoom <= 0:
|
||
zoom = 1.0
|
||
except (ValueError, TypeError):
|
||
zoom = 1.0
|
||
|
||
config = load_config()
|
||
|
||
if screen == "priority":
|
||
playlist = config.setdefault("priority", {}).setdefault("playlist", [])
|
||
else:
|
||
playlist = config["sites"][site]["screens"][screen].setdefault("playlist", [])
|
||
|
||
for i, item in enumerate(playlist):
|
||
name = playlist_item_name(item)
|
||
if name == old_name:
|
||
updated = {"url": new_url, "zoom": zoom}
|
||
passthrough = data.get("passthrough", None)
|
||
if passthrough is not None:
|
||
updated["passthrough_params"] = bool(passthrough)
|
||
else:
|
||
# Bestehenden Wert übernehmen falls vorhanden
|
||
existing = item.get("passthrough_params") if isinstance(item, dict) else None
|
||
if existing is not None:
|
||
updated["passthrough_params"] = existing
|
||
playlist[i] = updated
|
||
save_config(config)
|
||
return jsonify({"success": True})
|
||
|
||
return jsonify({"error": "Item nicht gefunden"}), 404
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Change-Password (für First-Login / Reset)
|
||
# -------------------------------------------------
|
||
@app.route("/change-password", methods=["GET", "POST"])
|
||
@login_required
|
||
def change_password():
|
||
"""Seite zum Ändern des Passworts (bei First-Login oder nach Admin-Reset)."""
|
||
cfg = load_config()
|
||
site_list = get_accessible_sites(cfg, current_user)
|
||
current_site = site_list[0] if site_list else ""
|
||
error = None
|
||
success = None
|
||
|
||
if request.method == "POST":
|
||
new_pw = request.form.get("new_password", "")
|
||
confirm = request.form.get("confirm_password", "")
|
||
|
||
if not new_pw or len(new_pw) < 6:
|
||
error = "Passwort muss mindestens 6 Zeichen lang sein"
|
||
elif new_pw != confirm:
|
||
error = "Passwörter stimmen nicht überein"
|
||
else:
|
||
users = load_users()
|
||
email = current_user.email
|
||
if email in users:
|
||
users[email]["password_hash"] = generate_password_hash(new_pw)
|
||
users[email]["must_change_password"] = False
|
||
save_users(users)
|
||
add_history_entry("password_changed", f"Passwort für User '{email}' geändert", current_user.email)
|
||
return redirect("/admin")
|
||
|
||
return render_template(
|
||
"change_password.html",
|
||
error=error,
|
||
success=success,
|
||
site_list=site_list,
|
||
current_site=current_site,
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=os.uname().nodename,
|
||
server_url=cfg.get("server_url", "")
|
||
)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# User-Verwaltung (nur für Admins)
|
||
# -------------------------------------------------
|
||
@app.route("/admin/dashboard")
|
||
@admin_required
|
||
def admin_dashboard():
|
||
"""Admin-Dashboard mit Statistiken: Sites, Screens, User, Admins, Superuser."""
|
||
cfg = load_config()
|
||
users_data = load_users()
|
||
|
||
sites = get_site_list(cfg)
|
||
screen_count = sum(len(site_cfg.get("screens", {})) for site_cfg in cfg.get("sites", {}).values())
|
||
|
||
user_count = len(users_data)
|
||
admin_count = sum(1 for u in users_data.values() if u.get("role") == "admin")
|
||
superuser_count = sum(1 for u in users_data.values() if u.get("role") == "superuser")
|
||
|
||
history = get_history(30)
|
||
|
||
# Sparkline-Daten aus dem Verlauf berechnen
|
||
def build_sparkline(entries, creates, deletes):
|
||
seq = list(reversed(entries))
|
||
data = [0]
|
||
for e in seq:
|
||
a = e.get("action")
|
||
if a in creates:
|
||
data.append(data[-1] + 1)
|
||
elif a in deletes:
|
||
data.append(data[-1] - 1)
|
||
if len(data) < 3:
|
||
data = [0, 0, 0]
|
||
return data
|
||
|
||
site_spark = build_sparkline(history, {"site_created"}, {"site_deleted"})
|
||
screen_spark = build_sparkline(history, {"screen_created"}, {"screen_deleted"})
|
||
user_spark = build_sparkline(history, {"user_created"}, {"user_deleted"})
|
||
admin_spark = build_sparkline(history, {"admin_created"}, {"admin_deleted"})
|
||
superuser_spark = build_sparkline(history, {"superuser_created"}, {"superuser_deleted"})
|
||
|
||
def trend_val(creates, deletes):
|
||
c = sum(1 for e in history if e.get("action") in creates)
|
||
d = sum(1 for e in history if e.get("action") in deletes)
|
||
return c - d
|
||
|
||
site_trend = trend_val({"site_created"}, {"site_deleted"})
|
||
screen_trend = trend_val({"screen_created"}, {"screen_deleted"})
|
||
user_trend = trend_val({"user_created"}, {"user_deleted"})
|
||
admin_trend = trend_val({"admin_created"}, {"admin_deleted"})
|
||
superuser_trend = trend_val({"superuser_created"}, {"superuser_deleted"})
|
||
|
||
return render_template(
|
||
"admin_dashboard.html",
|
||
site_count=len(sites),
|
||
screen_count=screen_count,
|
||
user_count=user_count,
|
||
admin_count=admin_count,
|
||
superuser_count=superuser_count,
|
||
site_spark=site_spark,
|
||
screen_spark=screen_spark,
|
||
user_spark=user_spark,
|
||
admin_spark=admin_spark,
|
||
superuser_spark=superuser_spark,
|
||
site_trend=site_trend,
|
||
screen_trend=screen_trend,
|
||
user_trend=user_trend,
|
||
admin_trend=admin_trend,
|
||
superuser_trend=superuser_trend,
|
||
history=history,
|
||
site_list=get_accessible_sites(cfg, current_user),
|
||
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=os.uname().nodename,
|
||
server_url=cfg.get("server_url", "")
|
||
)
|
||
|
||
|
||
@app.route("/admin/help")
|
||
@login_required
|
||
def admin_help():
|
||
"""Hilfeseite zur Bedienung des Tools."""
|
||
cfg = load_config()
|
||
return render_template(
|
||
"help.html",
|
||
site_list=get_accessible_sites(cfg, current_user),
|
||
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=os.uname().nodename,
|
||
server_url=cfg.get("server_url", "")
|
||
)
|
||
|
||
|
||
@app.route("/admin/users")
|
||
@admin_required
|
||
def admin_users():
|
||
"""Listet alle User."""
|
||
cfg = load_config()
|
||
users_data = load_users()
|
||
|
||
users_list = []
|
||
for email, data in users_data.items():
|
||
users_list.append({
|
||
"email": email,
|
||
"role": data.get("role", "user"),
|
||
"sites": data.get("sites", []),
|
||
"must_change_password": data.get("must_change_password", False),
|
||
"first_name": data.get("first_name", ""),
|
||
"last_name": data.get("last_name", ""),
|
||
"department": data.get("department", ""),
|
||
"notes": data.get("notes", ""),
|
||
"mfa_enabled": data.get("mfa_enabled", False)
|
||
})
|
||
|
||
return render_template(
|
||
"user_list.html",
|
||
users=sorted(users_list, key=lambda u: (u["last_name"].lower(), u["first_name"].lower())),
|
||
site_list=get_accessible_sites(cfg, current_user),
|
||
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=os.uname().nodename,
|
||
server_url=cfg.get("server_url", "")
|
||
)
|
||
|
||
|
||
@app.route("/admin/users/create", methods=["GET"])
|
||
@admin_required
|
||
def admin_users_create_page():
|
||
"""Zeigt das Formular zum Anlegen eines neuen Users."""
|
||
cfg = load_config()
|
||
all_sites = get_site_list(cfg)
|
||
|
||
return render_template(
|
||
"user_create.html",
|
||
all_sites=all_sites,
|
||
site_list=get_accessible_sites(cfg, current_user),
|
||
current_site=all_sites[0] if all_sites else "",
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=os.uname().nodename,
|
||
server_url=cfg.get("server_url", "")
|
||
)
|
||
|
||
|
||
@app.route("/admin/users/create", methods=["POST"])
|
||
@admin_required
|
||
def admin_users_create():
|
||
"""Legt einen neuen User an (E-Mail=Username, Passwort, Rolle, Standorte)."""
|
||
email = request.form.get("email", "").strip().lower()
|
||
password = request.form.get("password", "")
|
||
role = request.form.get("role", "user")
|
||
sites = request.form.getlist("sites")
|
||
|
||
# Validierung
|
||
if not email or not re.match(r"[^@]+@[^@]+\.[^@]+", email):
|
||
return "Ungültige E-Mail-Adresse", 400
|
||
if not password or len(password) < 6:
|
||
return "Passwort muss mindestens 6 Zeichen lang sein", 400
|
||
if role not in ("admin", "superuser", "user"):
|
||
return "Ungültige Rolle", 400
|
||
|
||
users = load_users()
|
||
if email in users:
|
||
return "User existiert bereits", 400
|
||
|
||
users[email] = {
|
||
"password_hash": generate_password_hash(password),
|
||
"role": role,
|
||
"sites": sites if role == "user" else [],
|
||
"must_change_password": True,
|
||
"first_name": request.form.get("first_name", "").strip(),
|
||
"last_name": request.form.get("last_name", "").strip(),
|
||
"department": request.form.get("department", "").strip(),
|
||
"notes": request.form.get("notes", "").strip()
|
||
}
|
||
save_users(users)
|
||
add_history_entry("user_created", f"User '{email}' angelegt (Rolle: {role})", current_user.email)
|
||
if role == "admin":
|
||
add_history_entry("admin_created", f"Admin '{email}' angelegt", current_user.email)
|
||
elif role == "superuser":
|
||
add_history_entry("superuser_created", f"Superuser '{email}' angelegt", current_user.email)
|
||
return redirect("/admin/users")
|
||
|
||
|
||
@app.route("/admin/users/edit/<path:email>", methods=["GET", "POST"])
|
||
@admin_required
|
||
def admin_users_edit(email):
|
||
"""Bearbeitet einen User (E-Mail, Rolle, Standorte)."""
|
||
cfg = load_config()
|
||
all_sites = get_site_list(cfg)
|
||
users_data = load_users()
|
||
|
||
if email not in users_data:
|
||
return "User nicht gefunden", 404
|
||
|
||
error = None
|
||
|
||
if request.method == "POST":
|
||
new_email = request.form.get("email", "").strip().lower()
|
||
new_role = request.form.get("role", "user")
|
||
new_sites = request.form.getlist("sites")
|
||
|
||
if not new_email or not re.match(r"[^@]+@[^@]+\.[^@]+", new_email):
|
||
error = "Ungültige E-Mail-Adresse"
|
||
elif new_role not in ("admin", "superuser", "user"):
|
||
error = "Ungültige Rolle"
|
||
else:
|
||
user_data = users_data.pop(email)
|
||
old_role = user_data.get("role", "user")
|
||
user_data["role"] = new_role
|
||
user_data["sites"] = new_sites if new_role == "user" else []
|
||
user_data["first_name"] = request.form.get("first_name", "").strip()
|
||
user_data["last_name"] = request.form.get("last_name", "").strip()
|
||
user_data["department"] = request.form.get("department", "").strip()
|
||
user_data["notes"] = request.form.get("notes", "").strip()
|
||
users_data[new_email] = user_data
|
||
save_users(users_data)
|
||
add_history_entry("user_updated", f"User '{email}' bearbeitet (Rolle: {old_role} → {new_role})", current_user.email)
|
||
if old_role == "admin" and new_role != "admin":
|
||
add_history_entry("admin_deleted", f"Admin-Rechte von '{email}' entzogen", current_user.email)
|
||
elif old_role != "admin" and new_role == "admin":
|
||
add_history_entry("admin_created", f"'{email}' zum Admin ernannt", current_user.email)
|
||
if old_role == "superuser" and new_role != "superuser":
|
||
add_history_entry("superuser_deleted", f"Superuser-Rechte von '{email}' entzogen", current_user.email)
|
||
elif old_role != "superuser" and new_role == "superuser":
|
||
add_history_entry("superuser_created", f"'{email}' zum Superuser ernannt", current_user.email)
|
||
return redirect(f"/admin/users?updated={new_email}")
|
||
|
||
user = {
|
||
"email": email,
|
||
"role": users_data[email].get("role", "user"),
|
||
"sites": users_data[email].get("sites", []),
|
||
"must_change_password": users_data[email].get("must_change_password", False),
|
||
"first_name": users_data[email].get("first_name", ""),
|
||
"last_name": users_data[email].get("last_name", ""),
|
||
"department": users_data[email].get("department", ""),
|
||
"notes": users_data[email].get("notes", "")
|
||
}
|
||
|
||
return render_template(
|
||
"user_edit.html",
|
||
user=user,
|
||
all_sites=all_sites,
|
||
error=error,
|
||
site_list=get_accessible_sites(cfg, current_user),
|
||
current_site=all_sites[0] if all_sites else "",
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=os.uname().nodename,
|
||
server_url=cfg.get("server_url", "")
|
||
)
|
||
|
||
|
||
@app.route("/admin/users/delete/<path:email>", methods=["POST"])
|
||
@admin_required
|
||
def admin_users_delete(email):
|
||
"""Löscht einen User. Der eigene Account kann nicht gelöscht werden.
|
||
Der letzte verbleibende Admin kann nicht gelöscht werden."""
|
||
if email == current_user.email:
|
||
return "Du kannst dich nicht selbst löschen", 400
|
||
|
||
users = load_users()
|
||
|
||
role = users.get(email, {}).get("role", "user")
|
||
|
||
# Prüfen, ob es der letzte Admin wäre
|
||
if role == "admin":
|
||
admin_count = sum(1 for u in users.values() if u.get("role") == "admin")
|
||
if admin_count <= 1:
|
||
return "Letzter Admin kann nicht gelöscht werden", 400
|
||
|
||
users.pop(email, None)
|
||
save_users(users)
|
||
add_history_entry("user_deleted", f"User '{email}' gelöscht", current_user.email)
|
||
if role == "admin":
|
||
add_history_entry("admin_deleted", f"Admin '{email}' gelöscht", current_user.email)
|
||
elif role == "superuser":
|
||
add_history_entry("superuser_deleted", f"Superuser '{email}' gelöscht", current_user.email)
|
||
return redirect("/admin/users")
|
||
|
||
|
||
@app.route("/admin/users/reset-password/<path:email>", methods=["POST"])
|
||
@admin_required
|
||
def admin_users_reset_password(email):
|
||
"""Setzt ein temporäres Passwort und zwingt User zur Änderung beim nächsten Login."""
|
||
import secrets
|
||
import string
|
||
|
||
users = load_users()
|
||
if email not in users:
|
||
return "User nicht gefunden", 404
|
||
|
||
temp_password = secrets.choice(string.ascii_lowercase) + secrets.choice(string.ascii_uppercase) + secrets.choice(string.digits) + secrets.token_hex(4)
|
||
users[email]["password_hash"] = generate_password_hash(temp_password)
|
||
users[email]["must_change_password"] = True
|
||
users[email]["mfa_enabled"] = False
|
||
users[email]["mfa_secret"] = ""
|
||
users[email]["mfa_recovery_codes"] = []
|
||
users[email]["trusted_devices"] = []
|
||
save_users(users)
|
||
add_history_entry("user_password_reset", f"Passwort für User '{email}' zurückgesetzt (MFA deaktiviert)", current_user.email)
|
||
|
||
return redirect(f"/admin/users?reset={email}&temp={temp_password}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Main
|
||
# -------------------------------------------------
|
||
try:
|
||
init_user_db()
|
||
except Exception as e:
|
||
print(f"⚠️ init_user_db fehlgeschlagen: {e}")
|
||
|
||
if __name__ == "__main__":
|
||
app.run(debug=True, host="0.0.0.0", port=5005)
|