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:
Jaroslav Beneš
2026-07-21 11:04:13 +02:00
parent 5ef2af6a9f
commit dd9e0e9440
59 changed files with 6273 additions and 12 deletions
+186
View File
@@ -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