"""Per-user preferences set from the browser.""" from __future__ import annotations import contextlib import logging from fastapi import APIRouter, Body, Form, Request, status from fastapi.responses import RedirectResponse, Response from lembas.api.deps import Db, RequiredUser from lembas.config import settings from lembas.security.passwords import hash_password, validate_password, verify_password from lembas.security.sessions import COOKIE_NAME, create_session, revoke_all_for_user log = logging.getLogger(__name__) router = APIRouter(prefix="/api/preferences", tags=["preferences"]) THEMES = ("moria", "shire") @router.post("/theme") async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=True)) -> dict: """Mirror the browser's theme choice onto the account. localStorage is the source of truth for the current tab; this is what makes the choice follow the user to another browser, and what lets the server render the right theme on first paint instead of flashing the default. """ if theme not in THEMES: return {"ok": False, "detail": "Unknown theme."} # Replaced rather than mutated in place: SQLAlchemy only reliably detects # a change to a JSON column when the whole value is reassigned. user.settings_json = {**(user.settings_json or {}), "theme": theme} db.commit() return {"ok": True, "theme": theme} @router.post("/default-model") async def set_default_model( db: Db, user: RequiredUser, model_id: str = Form("") ) -> Response: """Choose which model new chats start with. An empty value clears the choice and falls back to the instance default. Validated against what this user can actually reach, so a model they lose access to cannot linger as a preference that silently fails later. """ from lembas.security import permissions model_id = model_id.strip() if model_id and not permissions.can_use_model(db, user, model_id): return RedirectResponse( "/settings?error=That+model+is+not+available+to+you.", status_code=303 ) settings_map = {**(user.settings_json or {})} if model_id: settings_map["default_model"] = model_id else: settings_map.pop("default_model", None) user.settings_json = settings_map db.commit() return RedirectResponse("/settings?saved=Default+model+updated.", status_code=303) @router.post("/audio") async def set_audio( db: Db, user: RequiredUser, voice: str = Form(""), speed: str = Form(""), language: str = Form(""), autoplay: bool = Form(False), ) -> Response: """Per-reader audio choices, overriding the instance defaults. The voice is deliberately not checked against the discovered list. Voices come and go when a speech server is reconfigured, and rejecting a saved preference because a list fetched a moment ago did not mention it would be a confusing failure with no obvious fix. """ chosen: dict[str, object] = {"autoplay": autoplay} if voice.strip(): chosen["voice"] = voice.strip()[:120] if language.strip(): chosen["language"] = language.strip()[:16] if speed.strip(): # An unreadable speed leaves the default in place rather than failing: # nothing else on the form should be lost to a typo in one field. with contextlib.suppress(ValueError): chosen["speed"] = min(max(float(speed), 0.25), 4.0) # Whole-dict reassignment: an in-place edit of a JSON column is not # reliably detected as a change. user.settings_json = {**(user.settings_json or {}), "audio": chosen} db.commit() return RedirectResponse("/settings?saved=Audio+preferences+updated.", status_code=303) @router.post("/password") async def change_password( request: Request, db: Db, user: RequiredUser, current_password: str = Form(...), new_password: str = Form(...), confirm_password: str = Form(...), ) -> Response: """Change your own password. Every other session is revoked on success. If the reason for changing a password is that someone else knows it, leaving their session alive would defeat the point. """ def back(message: str, ok: bool = False) -> Response: from urllib.parse import quote field = "saved" if ok else "error" return RedirectResponse( f"/settings?{field}={quote(message)}", status_code=status.HTTP_303_SEE_OTHER ) if not verify_password(current_password, user.password_hash): log.info("failed password change for %s: current password wrong", user.email) return back("Your current password is not correct.") if new_password != confirm_password: return back("The new passwords do not match.") if (problem := validate_password(new_password)) is not None: return back(problem) if verify_password(new_password, user.password_hash): return back("That is already your password.") user.password_hash = hash_password(new_password) db.commit() revoke_all_for_user(db, user) token = create_session( db, user, user_agent=request.headers.get("user-agent", ""), ip_address=request.client.host if request.client else "", ) log.info("password changed for %s; other sessions revoked", user.email) # revoke_all_for_user killed this session too, so hand back a fresh cookie # -- otherwise changing your password would sign you out of the tab you are # standing in. response = back("Password changed. Any other sessions have been signed out.", ok=True) response.set_cookie( COOKIE_NAME, token, max_age=settings.session_ttl, httponly=True, samesite="lax", secure=False, path="/", ) return response