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:
@@ -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)
|
||||
Reference in New Issue
Block a user