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
+40
View File
@@ -0,0 +1,40 @@
"""Password hashing.
Argon2id via argon2-cffi, using the library's current recommended parameters.
``needs_rehash`` lets stored hashes be upgraded transparently when those
defaults tighten in a future release.
"""
from __future__ import annotations
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError, VerificationError
_hasher = PasswordHasher()
MIN_PASSWORD_LENGTH = 8
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
def needs_rehash(password_hash: str) -> bool:
try:
return _hasher.check_needs_rehash(password_hash)
except InvalidHashError:
return True
def validate_password(password: str) -> str | None:
"""Return a human-readable problem with the password, or None if it is fine."""
if len(password) < MIN_PASSWORD_LENGTH:
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
return None
+99
View File
@@ -0,0 +1,99 @@
"""Login session lifecycle.
The browser holds an opaque random token in an httpOnly cookie. The database
stores only its SHA-256, so a dump of the sessions table cannot be replayed as
a live login. Tokens are compared by hash lookup, and revoking is a DELETE.
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.config import settings
from lembas.db.models import Session as SessionRow
from lembas.db.models import User
COOKIE_NAME = "lembas_session"
TOKEN_BYTES = 32
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_session(
db: DBSession,
user: User,
*,
user_agent: str = "",
ip_address: str = "",
) -> str:
"""Open a session for a user and return the raw token for the cookie.
The raw token is returned exactly once and never persisted.
"""
token = secrets.token_urlsafe(TOKEN_BYTES)
row = SessionRow(
user_id=user.id,
token_hash=_hash_token(token),
expires_at=datetime.now(UTC) + timedelta(seconds=settings.session_ttl),
user_agent=user_agent[:500],
ip_address=ip_address[:45],
)
db.add(row)
user.last_login_at = datetime.now(UTC)
db.commit()
return token
def resolve_session(db: DBSession, token: str | None) -> User | None:
"""Return the signed-in user for a cookie value, or None.
Expired and orphaned sessions are cleaned up as they are encountered, which
keeps the table tidy without needing a scheduled job.
"""
if not token:
return None
row = db.scalar(select(SessionRow).where(SessionRow.token_hash == _hash_token(token)))
if row is None:
return None
# SQLite hands back naive datetimes even for timezone-aware columns.
expires_at = row.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < datetime.now(UTC):
db.delete(row)
db.commit()
return None
user = db.get(User, row.user_id)
if user is None or not user.active:
db.delete(row)
db.commit()
return None
return user
def revoke_session(db: DBSession, token: str | None) -> None:
if not token:
return
row = db.scalar(select(SessionRow).where(SessionRow.token_hash == _hash_token(token)))
if row is not None:
db.delete(row)
db.commit()
def revoke_all_for_user(db: DBSession, user: User) -> None:
"""Sign a user out everywhere. Used when deactivating or changing a password."""
for row in db.scalars(select(SessionRow).where(SessionRow.user_id == user.id)):
db.delete(row)
db.commit()