Scaffold project, data model and artwork

Establish the LLeMbas foundation: FastAPI/Jinja/SQLite layout, the ORM
schema, and the original SVG identity.

Notable decisions, all recorded in comments at the point they matter:

- No Alembic. SQLite only, schema created at startup, so models carry a
  few columns nothing reads yet (Message.parent_id for branching,
  content_parts_json for multimodal turns). Adding them later to a live
  database without migrations is the painful path.
- Sessions are server-side rows keyed by a SHA-256 of the cookie value,
  not JWTs, so logout and bans revoke access immediately.
- Upstream API keys are Fernet-encrypted with a key derived from
  LEMBAS_SECRET_KEY. decrypt() fails soft to "" so rotating the secret
  degrades to re-entering keys rather than crashing the admin UI.
- Artwork is generated by scripts/build_artwork.py rather than hand-drawn
  per file: the mallorn leaf appears in the icon, favicon, lockup and
  banner, and one source is the only way those stay in sync. The wordmark
  is Source Serif 4 (OFL) converted to outlines, because a README banner
  cannot load a webfont and <text> would render in whatever serif the
  viewer happens to have.
- Icons live in a template partial, not assets/, because same-document
  <use href="#id"> is universally supported and the cross-document form
  is not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 10:34:48 +02:00
parent 0665027bc6
commit 5ef2af6a9f
32 changed files with 2041 additions and 0 deletions
View File
+91
View File
@@ -0,0 +1,91 @@
"""Shared FastAPI dependencies: database sessions and the current user."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import User
from lembas.db.session import get_session_factory
from lembas.security.sessions import COOKIE_NAME, resolve_session
def get_db() -> Iterator[DBSession]:
"""One database session per request, always closed."""
session = get_session_factory()()
try:
yield session
finally:
session.close()
Db = Annotated[DBSession, Depends(get_db)]
def get_current_user(request: Request, db: Db) -> User | None:
"""Resolve the session cookie to a user, or None when signed out.
Cached on request.state so several dependencies in one request do not each
hit the sessions table.
"""
cached = getattr(request.state, "user", None)
if cached is not None:
return cached
user = resolve_session(db, request.cookies.get(COOKIE_NAME))
request.state.user = user
return user
CurrentUser = Annotated[User | None, Depends(get_current_user)]
class RedirectToLogin(HTTPException):
"""Signals "not signed in" so the exception handler can redirect a browser.
Raised instead of returning a response because dependencies cannot return
one. lembas.main turns this into a 303 for page loads and an HX-Redirect
header for HTMX requests, so a partial swap never renders a login form
inside the chat pane.
"""
def __init__(self, next_url: str = "/") -> None:
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Sign in required")
self.next_url = next_url
def require_user(request: Request, user: CurrentUser) -> User:
if user is None:
raise RedirectToLogin(next_url=request.url.path)
return user
RequiredUser = Annotated[User, Depends(require_user)]
def require_admin(user: RequiredUser) -> User:
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This area is restricted to administrators.",
)
return user
AdminUser = Annotated[User, Depends(require_admin)]
def is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request") == "true"
def login_redirect(next_url: str = "/") -> RedirectResponse:
target = "/auth/login"
if next_url and next_url not in ("/", "/auth/login"):
from urllib.parse import quote
target = f"{target}?next={quote(next_url, safe='')}"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)