feat: kundenspezifisches Logo unter Einstellungen

Bisher war das Logo fest im Repository verdrahtet. Damit hat ein Update
das Logo eines Kunden ueberschrieben - beim Sprung auf den modularen
Stand zum Beispiel von logo.svg auf die mitgelieferte logo.png.

Unter Einstellungen > Logo laesst sich jetzt ein eigenes Logo hochladen.
Es erscheint in der Seitenleiste, auf der Login-Seite und auf der
Zentrale-Seite, mit Vorschau auf hellem und dunklem Grund.

- Datei liegt unter static/custom/ und ist per .gitignore von der
  Versionierung ausgenommen, ein git pull ueberschreibt sie also nicht
- PNG, JPG, SVG, WEBP, GIF bis 2 MB; Format und Groesse werden geprueft
- es bleibt immer genau eine Datei liegen, alte Endungen werden entfernt
- fehlt die Datei, greift automatisch das mitgelieferte Logo
- Upload und Zuruecksetzen liegen hinter dem Admin-Login
- logo_url() ist als Jinja-Global registriert, damit auch die Seiten ohne
  _ctx() (Login, Zentrale) dasselbe Logo zeigen

.gitignore ergaenzt: static/custom/ sowie die bisher fehlenden
busylight.db-shm, busylight.db-wal und Status.csv - letztere lagen auf
dem Produktivsystem als untracked herum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 08:55:16 +00:00
co-authored by Claude Opus 5
parent b02e6702e4
commit 09d9837dec
8 changed files with 178 additions and 3 deletions
+78
View File
@@ -45,6 +45,7 @@ async def lifespan(application: FastAPI):
os.makedirs(BACKUP_DIR, exist_ok=True)
os.makedirs(WLED_BACKUP_DIR, exist_ok=True)
os.makedirs(FIRMWARE_DIR, exist_ok=True)
os.makedirs(CUSTOM_DIR, exist_ok=True)
# Start background polling
interval = int(database.get_setting("poll_interval", "15"))
@@ -65,6 +66,11 @@ async def lifespan(application: FastAPI):
BACKUP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backups")
WLED_BACKUP_DIR = os.path.join(BACKUP_DIR, "wled")
FIRMWARE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "firmware")
# Kundenspezifische Dateien (Logo). Liegt bewusst ausserhalb der Versionierung,
# damit ein git pull das Kundenlogo nicht ueberschreibt.
CUSTOM_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "custom")
LOGO_EXTENSIONS = (".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif")
LOGO_MAX_BYTES = 2 * 1024 * 1024
def auto_backup_db():
@@ -118,6 +124,27 @@ templates = Jinja2Templates(directory="templates")
SESSION_TOKEN = secrets.token_hex(32) # Regenerated on restart
DEFAULT_LOGO = "/static/logo.png"
def _logo_url():
"""URL des anzuzeigenden Logos - kundenspezifisch, sonst das mitgelieferte.
Als Jinja-Global registriert, damit auch Seiten ohne _ctx() (Login,
Zentrale) dasselbe Logo zeigen.
"""
name = database.get_setting("logo_file", "")
if name:
# Kein Pfad, nur Dateiname - der Wert kommt aus der eigenen DB, aber
# ein Traversal soll auch dann nicht moeglich sein.
name = os.path.basename(name)
if os.path.exists(os.path.join(CUSTOM_DIR, name)):
return f"/static/custom/{name}"
return DEFAULT_LOGO
templates.env.globals["logo_url"] = _logo_url
def _hash_password(pw):
return hashlib.sha256(pw.encode()).hexdigest()
@@ -984,6 +1011,57 @@ async def update_settings(request: Request):
return RedirectResponse("/settings?saved=1", status_code=303)
@app.post("/api/settings/logo")
async def upload_logo(request: Request, logo: UploadFile = File(...)):
"""Kundenspezifisches Logo hochladen."""
redir = _require_login(request)
if redir: return redir
ext = os.path.splitext(logo.filename or "")[1].lower()
if ext not in LOGO_EXTENSIONS:
return RedirectResponse("/settings?logo_error=format", status_code=303)
content = await logo.read()
if not content:
return RedirectResponse("/settings?logo_error=empty", status_code=303)
if len(content) > LOGO_MAX_BYTES:
return RedirectResponse("/settings?logo_error=size", status_code=303)
os.makedirs(CUSTOM_DIR, exist_ok=True)
# Nur ein Logo behalten, sonst bleiben alte Endungen liegen
for old in os.listdir(CUSTOM_DIR):
if old.startswith("logo."):
try:
os.remove(os.path.join(CUSTOM_DIR, old))
except OSError:
pass
filename = f"logo{ext}"
with open(os.path.join(CUSTOM_DIR, filename), "wb") as f:
f.write(content)
database.set_setting("logo_file", filename)
logger.info("Kundenlogo hochgeladen: %s (%d KB)", filename, len(content) // 1024)
return RedirectResponse("/settings?logo_saved=1", status_code=303)
@app.post("/api/settings/logo/reset")
async def reset_logo(request: Request):
"""Zurueck auf das mitgelieferte Logo."""
redir = _require_login(request)
if redir: return redir
if os.path.isdir(CUSTOM_DIR):
for old in os.listdir(CUSTOM_DIR):
if old.startswith("logo."):
try:
os.remove(os.path.join(CUSTOM_DIR, old))
except OSError:
pass
database.set_setting("logo_file", "")
logger.info("Kundenlogo entfernt, Standardlogo aktiv")
return RedirectResponse("/settings?logo_reset=1", status_code=303)
# ── Log ────────────────────────────────────────────────────────────────────
@app.get("/log", response_class=HTMLResponse)