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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user