Working chat: auth, connections, streaming, folders
LLeMbas now runs end to end. Register, add an OpenAI-compatible connection, and hold a real streaming conversation organised into folders. Verified against the local llama-swap instance. Streaming is the one genuinely tricky part. Sending a message returns two HTML fragments -- the user bubble and an empty assistant bubble carrying an sse-connect -- and that attribute is the ONLY thing that starts a generation. Rendering an incomplete assistant message as a streaming shell falls out of the same template, which means loading a page whose last reply never finished simply picks it up again. Details worth knowing about, each commented where it matters: - SSE payloads are split across several data: lines. A raw newline in one data: line truncates the event, which shows up the first time a model emits a code block. - Markdown is rendered server-side by the same helper for both the page and the final streamed frame, so the two cannot disagree. The fence renderer is replaced outright rather than using markdown-it's highlight option, which re-wraps output in a second <pre>. - escape_text is html.escape, not nh3.clean_text: it escapes character by character, so escaping stream chunks separately equals escaping the whole string. - The stream opens its own session via session_scope(); it outlives the request handler and the dependency-scoped session may be closed. - Deleting a folder keeps the chats inside it (FK is SET NULL). Losing a conversation to a mis-clicked folder delete is unforgivable. - Login failures use one message for "no such account" and "wrong password" so the form cannot enumerate registered addresses. Also adds deploy/ for the gamebox install at https://chat.lan: system unit, nginx vhost with buffering off (buffering on turns streaming into one lump at the end), and install/update scripts following the same service-user and /srv bind-mount conventions as llama-swap and comfyui. 70 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
"""Registration, sign-in and sign-out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.api.deps import CurrentUser, Db
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import ROLE_ADMIN, ROLE_USER, User
|
||||
from lembas.security.passwords import hash_password, validate_password, verify_password
|
||||
from lembas.security.sessions import COOKIE_NAME, create_session, revoke_session
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _no_users_yet(db: Db) -> bool:
|
||||
return db.scalar(select(func.count()).select_from(User)) == 0
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
token,
|
||||
max_age=settings.session_ttl,
|
||||
httponly=True,
|
||||
# Lax is what makes this application CSRF-safe without tokens: the
|
||||
# cookie is not sent on cross-site POSTs, and every mutating route here
|
||||
# is a POST. Do not relax to "none".
|
||||
samesite="lax",
|
||||
# Only over HTTPS when the deployment is not plain local http. Marking
|
||||
# it secure on http would silently break sign-in for a LAN install.
|
||||
secure=False,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _safe_next(raw: str | None) -> str:
|
||||
"""Reject open redirects: only same-origin absolute paths are allowed."""
|
||||
if not raw or not raw.startswith("/") or raw.startswith("//"):
|
||||
return "/"
|
||||
return raw
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login_form(request: Request, db: Db, user: CurrentUser, next: str = "/"):
|
||||
if user is not None:
|
||||
return RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
|
||||
# An empty database means this install has never been set up. Send the
|
||||
# first visitor straight to registration rather than to a login form they
|
||||
# cannot possibly satisfy.
|
||||
if _no_users_yet(db):
|
||||
return RedirectResponse("/auth/register", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return render(request, "auth/login.html", {"next": _safe_next(next)})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
request: Request,
|
||||
db: Db,
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
next: str = Form("/"),
|
||||
):
|
||||
email = email.strip().lower()
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
|
||||
# One message for "no such account" and "wrong password" alike, so the form
|
||||
# cannot be used to discover which addresses are registered.
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
log.info("failed sign-in for %s", email)
|
||||
return render(
|
||||
request,
|
||||
"auth/login.html",
|
||||
{"error": "That email and password do not match.", "email": email,
|
||||
"next": _safe_next(next)},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not user.active:
|
||||
return render(
|
||||
request,
|
||||
"auth/login.html",
|
||||
{"error": "This account has been deactivated. Ask an administrator.",
|
||||
"email": email, "next": _safe_next(next)},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
token = create_session(
|
||||
db,
|
||||
user,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
ip_address=request.client.host if request.client else "",
|
||||
)
|
||||
response = RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
|
||||
_set_session_cookie(response, token)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/register")
|
||||
async def register_form(request: Request, db: Db, user: CurrentUser):
|
||||
if user is not None:
|
||||
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
first_run = _no_users_yet(db)
|
||||
if not first_run and not settings.allow_signup:
|
||||
return render(
|
||||
request,
|
||||
"auth/login.html",
|
||||
{"error": "Registration is closed. Ask an administrator for an account."},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
return render(request, "auth/register.html", {"first_run": first_run})
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(
|
||||
request: Request,
|
||||
db: Db,
|
||||
name: str = Form(...),
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
first_run = _no_users_yet(db)
|
||||
if not first_run and not settings.allow_signup:
|
||||
return render(
|
||||
request,
|
||||
"auth/login.html",
|
||||
{"error": "Registration is closed. Ask an administrator for an account."},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
name = name.strip()
|
||||
email = email.strip().lower()
|
||||
|
||||
def fail(message: str) -> Response:
|
||||
return render(
|
||||
request,
|
||||
"auth/register.html",
|
||||
{"error": message, "name": name, "email": email, "first_run": first_run},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not name:
|
||||
return fail("Please enter a name.")
|
||||
if "@" not in email or "." not in email.split("@")[-1]:
|
||||
return fail("Please enter a valid email address.")
|
||||
if (problem := validate_password(password)) is not None:
|
||||
return fail(problem)
|
||||
if db.scalar(select(User).where(User.email == email)) is not None:
|
||||
return fail("An account with that email already exists.")
|
||||
|
||||
# Whoever sets the instance up owns it. Everyone after that is a plain user
|
||||
# until an admin says otherwise.
|
||||
user = User(
|
||||
name=name,
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
role=ROLE_ADMIN if first_run else ROLE_USER,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
log.info("registered %s as %s", email, user.role)
|
||||
|
||||
token = create_session(
|
||||
db,
|
||||
user,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
ip_address=request.client.host if request.client else "",
|
||||
)
|
||||
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
_set_session_cookie(response, token)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request, db: Db):
|
||||
revoke_session(db, request.cookies.get(COOKIE_NAME))
|
||||
response = RedirectResponse("/auth/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
return response
|
||||
Reference in New Issue
Block a user