diff --git a/app.py b/app.py index 86aaaf7..b4d7252 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 """Busylight WLED Controller - Web Application.""" +import hashlib import json import logging import os +import secrets import shutil import sys from contextlib import asynccontextmanager @@ -114,11 +116,64 @@ app = FastAPI(title="Busylight Manager", lifespan=lifespan) app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") +SESSION_TOKEN = secrets.token_hex(32) # Regenerated on restart + + +def _hash_password(pw): + return hashlib.sha256(pw.encode()).hexdigest() + + +def _is_logged_in(request): + return request.cookies.get("session") == SESSION_TOKEN + + +def _require_login(request): + """Redirect to login if not authenticated. Returns RedirectResponse or None.""" + if not _is_logged_in(request): + # Check if a password is set + admin_pw = database.get_setting("admin_password", "") + if admin_pw: + return RedirectResponse(f"/login?next={request.url.path}", status_code=303) + return None + def _ctx(request, page, **kwargs): """Build template context with common variables.""" authenticated = scheduler.graph is not None and scheduler.graph.is_authenticated() - return request, f"{page}.html", {"page": page, "authenticated": authenticated, **kwargs} + logged_in = _is_logged_in(request) + return request, f"{page}.html", {"page": page, "authenticated": authenticated, "logged_in": logged_in, **kwargs} + + +# ── Login ────────────────────────────────────────────────────────────────── + +@app.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + next_url = request.query_params.get("next", "/") + return templates.TemplateResponse(request, "login.html", { + "page": "login", "next": next_url, + "error": request.query_params.get("error"), + }) + + +@app.post("/login") +async def login_submit( + request: Request, + password: str = Form(...), + next: str = Form("/"), +): + admin_pw = database.get_setting("admin_password", "") + if admin_pw and _hash_password(password) == admin_pw: + response = RedirectResponse(next, status_code=303) + response.set_cookie("session", SESSION_TOKEN, httponly=True, max_age=86400) + return response + return RedirectResponse("/login?error=1&next=" + next, status_code=303) + + +@app.get("/logout") +async def logout(): + response = RedirectResponse("/", status_code=303) + response.delete_cookie("session") + return response # ── Dashboard ────────────────────────────────────────────────────────────── @@ -191,6 +246,8 @@ async def dashboard(request: Request): @app.get("/users", response_class=HTMLResponse) async def users_page(request: Request): + redir = _require_login(request) + if redir: return redir conn = database.get_db() users = conn.execute(""" SELECT u.*, a.id as assignment_id @@ -262,6 +319,8 @@ async def toggle_user(azure_id: str): @app.get("/devices", response_class=HTMLResponse) async def devices_page(request: Request): + redir = _require_login(request) + if redir: return redir conn = database.get_db() devices_raw = conn.execute( "SELECT * FROM wled_devices ORDER BY name, hostname" @@ -366,6 +425,8 @@ async def check_single_device(device_id: int): @app.get("/assignments", response_class=HTMLResponse) async def assignments_page(request: Request): + redir = _require_login(request) + if redir: return redir conn = database.get_db() assignments = conn.execute(""" SELECT a.id, a.segment, u.display_name, u.azure_id, @@ -419,6 +480,8 @@ async def delete_assignment(assignment_id: int): @app.get("/colors", response_class=HTMLResponse) async def colors_page(request: Request): + redir = _require_login(request) + if redir: return redir conn = database.get_db() rules = conn.execute("SELECT * FROM color_rules ORDER BY status").fetchall() conn.close() @@ -475,6 +538,8 @@ async def add_color( @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): + redir = _require_login(request) + if redir: return redir settings = database.get_all_settings() return templates.TemplateResponse(*_ctx(request, "settings", settings=settings)) @@ -492,6 +557,12 @@ async def update_settings(request: Request): value = form.get(key, "") database.set_setting(key, value) + # Admin password + new_pw = form.get("admin_password", "") + if new_pw: + database.set_setting("admin_password", _hash_password(new_pw)) + logger.info("Admin-Passwort geaendert") + # Update scheduler interval try: interval = int(form.get("poll_interval", "15")) @@ -507,6 +578,8 @@ async def update_settings(request: Request): @app.get("/log", response_class=HTMLResponse) async def log_page(request: Request): + redir = _require_login(request) + if redir: return redir conn = database.get_db() logs = conn.execute( "SELECT * FROM activity_log ORDER BY id DESC LIMIT 200" @@ -544,6 +617,8 @@ async def trigger_poll(): @app.get("/backup", response_class=HTMLResponse) async def backup_page(request: Request): + redir = _require_login(request) + if redir: return redir # Portal backups db_backups = [] if os.path.exists(BACKUP_DIR): diff --git a/static/style.css b/static/style.css index f888d8e..a5c8811 100644 --- a/static/style.css +++ b/static/style.css @@ -119,6 +119,45 @@ body { vertical-align: middle; } +/* Compact dashboard */ +.seg-compact { + padding: 4px 8px; + border-radius: 4px; + margin-bottom: 3px; + font-size: 0.8rem; +} + +.seg-compact .seg-name { + display: block; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.78rem; +} + +.seg-compact .seg-badges { + display: flex; + gap: 3px; + flex-wrap: wrap; + margin-top: 2px; +} + +.seg-compact.seg-empty { + background: #f8f8f8; + border-left: 3px solid #ddd; +} + +.badge-sm { + font-size: 0.65rem; + padding: 1px 5px; + border-radius: 3px; + font-weight: 500; + color: #fff; + display: inline-block; + line-height: 1.4; +} + @media (max-width: 768px) { .sidebar { width: 100%; diff --git a/templates/base.html b/templates/base.html index 304b600..a5b6bfb 100644 --- a/templates/base.html +++ b/templates/base.html @@ -22,6 +22,7 @@ Dashboard + {% if logged_in %}
python app.py --auth ausfuehren.
+