diff --git a/api/router.py b/api/router.py index 84bc291..42d8c98 100644 --- a/api/router.py +++ b/api/router.py @@ -1,6 +1,8 @@ import asyncio import os -from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends +from typing import Optional + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException, Header from api.state import state, Status from config import load as load_config @@ -11,28 +13,54 @@ _ws_clients: list[WebSocket] = [] # --------------------------------------------------------------------------- -# Auth stub — replaced by auth.py in Task 13. -# current_user() returns a dict with at least {"username": str, "output_dir": str}. -# Until auth is wired up, every request runs as an anonymous guest using the -# global output path from config. +# Auth dependency # --------------------------------------------------------------------------- -def _guest_user() -> dict: - cfg = load_config() - return {"username": "guest", "output_dir": cfg["output"]["path"]} - - -def current_user(user: dict = Depends(_guest_user)) -> dict: +async def current_user(authorization: Optional[str] = Header(None)) -> dict: + from auth import get_user_for_token + token = None + if authorization and authorization.startswith("Bearer "): + token = authorization[7:] + if not token: + raise HTTPException(status_code=401, detail="Nicht angemeldet") + user = get_user_for_token(token) + if not user: + raise HTTPException(status_code=401, detail="Ungültiger oder abgelaufener Token") return user # --------------------------------------------------------------------------- -# Endpoints +# Auth endpoints (no current_user dependency — these are unauthenticated) +# --------------------------------------------------------------------------- + +@router.post("/login") +async def login(body: dict): + from auth import authenticate + username = body.get("username", "") + password = body.get("password", "") + if not username or not password: + raise HTTPException(status_code=400, detail="Benutzername und Passwort erforderlich") + token = authenticate(username, password) + if not token: + raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten") + return {"token": token, "username": username} + + +@router.post("/logout") +async def logout(authorization: Optional[str] = Header(None)): + from auth import invalidate_token + if authorization and authorization.startswith("Bearer "): + invalidate_token(authorization[7:]) + return {"ok": True} + + +# --------------------------------------------------------------------------- +# Protected endpoints # --------------------------------------------------------------------------- @router.get("/status") -async def get_status(): - return {"status": state.status} +async def get_status(user: dict = Depends(current_user)): + return {"status": state.status, "username": user["username"]} @router.post("/toggle") @@ -46,7 +74,7 @@ async def toggle_recording(user: dict = Depends(current_user)): state._recorder = AudioRecorder() state._recorder.start() state.recording_user = user["username"] - state._recording_output_dir = user["output_dir"] + state._recording_output_dir = os.path.join(user["output_dir"], user["username"]) state._recording_instructions = user.get("instructions", "") await state.set_status(Status.RECORDING) return {"action": "started"} @@ -55,40 +83,48 @@ async def toggle_recording(user: dict = Depends(current_user)): @router.post("/instructions") async def set_instructions(body: dict, user: dict = Depends(current_user)): - # Instructions are per-user; stored on state only for the active recording. - # Full per-user persistence comes in Task 13. user["instructions"] = body.get("instructions", "") return {"ok": True} @router.get("/transcripts") async def get_transcripts(user: dict = Depends(current_user)): - return list_transcripts(user["output_dir"]) + user_dir = os.path.join(user["output_dir"], user["username"]) + return list_transcripts(user_dir) @router.get("/config") -async def get_config(): +async def get_config(user: dict = Depends(current_user)): return load_config() @router.put("/config") -async def put_config(body: dict): +async def put_config(body: dict, user: dict = Depends(current_user)): + if not user.get("is_admin"): + raise HTTPException(status_code=403, detail="Nur Administratoren können die Config ändern") cfg = load_config() cfg.update(body) return cfg @router.post("/open") -async def open_file(body: dict): +async def open_file(body: dict, user: dict = Depends(current_user)): import subprocess path = body.get("path", "") - if path and os.path.exists(path): + # Only allow opening files within the user's own output directory + user_dir = os.path.join(user["output_dir"], user["username"]) + if path and os.path.exists(path) and os.path.abspath(path).startswith(os.path.abspath(user_dir)): subprocess.Popen(["xdg-open", path]) return {"ok": True} @router.websocket("/ws") -async def websocket_endpoint(ws: WebSocket): +async def websocket_endpoint(ws: WebSocket, token: str = ""): + from auth import get_user_for_token + user = get_user_for_token(token) + if not user: + await ws.close(code=4001) + return await ws.accept() _ws_clients.append(ws) try: diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..e3148a6 --- /dev/null +++ b/auth.py @@ -0,0 +1,128 @@ +import getpass +import hashlib +import os +import secrets +import tomllib +from typing import Optional + +import tomli_w + +USERS_PATH = os.path.expanduser("~/.config/tueit-transcriber/users.toml") + +# In-memory session store: token → username +# Users must re-login after server restart — acceptable for a desktop app. +_sessions: dict[str, str] = {} + + +def _hash_password(password: str) -> str: + salt = secrets.token_hex(16) + key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex() + return f"{salt}:{key}" + + +def _verify_password(password: str, stored: str) -> bool: + try: + salt, key = stored.split(":", 1) + except ValueError: + return False + new_key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex() + return secrets.compare_digest(new_key, key) + + +# ── User store ───────────────────────────────────────────────────────────────── + +def has_users() -> bool: + return bool(_load_users()) + + +def _load_users() -> dict: + if not os.path.exists(USERS_PATH): + return {} + with open(USERS_PATH, "rb") as f: + return tomllib.load(f).get("users", {}) + + +def _save_users(users: dict): + os.makedirs(os.path.dirname(USERS_PATH), exist_ok=True) + with open(USERS_PATH, "wb") as f: + tomli_w.dump({"users": users}, f) + + +def create_user(username: str, password: str, output_dir: str, is_admin: bool = False): + users = _load_users() + users[username] = { + "password_hash": _hash_password(password), + "output_dir": output_dir, + "is_admin": is_admin, + } + _save_users(users) + + +# ── Session management ───────────────────────────────────────────────────────── + +def authenticate(username: str, password: str) -> Optional[str]: + """Verify credentials. Returns a session token on success, None on failure.""" + users = _load_users() + user = users.get(username) + if not user: + return None + if not _verify_password(password, user["password_hash"]): + return None + token = secrets.token_urlsafe(32) + _sessions[token] = username + return token + + +def get_user_for_token(token: str) -> Optional[dict]: + """Return user info dict for a valid token, or None.""" + username = _sessions.get(token) + if not username: + return None + users = _load_users() + user = users.get(username) + if not user: + return None + return { + "username": username, + "output_dir": user["output_dir"], + "is_admin": user.get("is_admin", False), + } + + +def invalidate_token(token: str): + _sessions.pop(token, None) + + +# ── First-run setup wizard ───────────────────────────────────────────────────── + +def setup_wizard(): + """Interactive console setup. Runs when no users exist yet.""" + print("\n=== tüit Transkriptor — Ersteinrichtung ===\n") + print("Bitte richte den ersten Nutzer ein (wird Administrator).\n") + + while True: + username = input("Benutzername: ").strip() + if username: + break + print("Benutzername darf nicht leer sein.") + + while True: + password = getpass.getpass("Passwort: ") + confirm = getpass.getpass("Passwort bestätigen: ") + if password != confirm: + print("Passwörter stimmen nicht überein.") + continue + if len(password) < 6: + print("Passwort muss mindestens 6 Zeichen lang sein.") + continue + break + + default_dir = os.path.expanduser(f"~/Transkripte/{username}") + answer = input(f"Transkripte speichern unter [{default_dir}]: ").strip() + output_dir = answer if answer else default_dir + + create_user(username, password, output_dir, is_admin=True) + + print(f"\nNutzer '{username}' wurde angelegt.") + print(f"Transkripte werden gespeichert unter: {output_dir}") + print("\nWeitere Nutzer können später über die Web-Oberfläche hinzugefügt werden.\n") diff --git a/config.py b/config.py index 5d4d7ae..ceaeb4e 100644 --- a/config.py +++ b/config.py @@ -21,6 +21,9 @@ DEFAULTS = { "~/cloud.shron.de/Hetzner Storagebox/work" ), }, + "network": { + "host": "127.0.0.1", + }, "pid_file": os.path.expanduser("~/.local/run/tueit-transcriber.pid"), } diff --git a/frontend/login.html b/frontend/login.html new file mode 100644 index 0000000..c37769e --- /dev/null +++ b/frontend/login.html @@ -0,0 +1,151 @@ + + +
+ + +