From 5ef2af6a9f99fbcdf762cb72f5aac1302a55f035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 21 Jul 2026 10:34:48 +0200 Subject: [PATCH] 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 would render in whatever serif the viewer happens to have. - Icons live in a template partial, not assets/, because same-document is universally supported and the cross-document form is not. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 34 ++ .gitignore | 27 ++ assets/banner.svg | 264 +++++++++++ assets/favicon.svg | 27 ++ assets/logo-lockup.svg | 67 +++ assets/logo-mark.svg | 49 ++ assets/wordmark.svg | 23 + pyproject.toml | 66 +++ scripts/build_artwork.py | 473 +++++++++++++++++++ src/lembas/__init__.py | 3 + src/lembas/api/__init__.py | 0 src/lembas/api/deps.py | 91 ++++ src/lembas/config.py | 64 +++ src/lembas/db/__init__.py | 0 src/lembas/db/base.py | 54 +++ src/lembas/db/models/__init__.py | 45 ++ src/lembas/db/models/chat.py | 117 +++++ src/lembas/db/models/connection.py | 82 ++++ src/lembas/db/models/setting.py | 28 ++ src/lembas/db/models/user.py | 89 ++++ src/lembas/db/session.py | 102 ++++ src/lembas/db/types.py | 14 + src/lembas/schemas/__init__.py | 0 src/lembas/security/__init__.py | 0 src/lembas/security/passwords.py | 40 ++ src/lembas/security/sessions.py | 99 ++++ src/lembas/services/__init__.py | 0 src/lembas/services/crypto.py | 58 +++ src/lembas/services/llm/__init__.py | 0 src/lembas/web/__init__.py | 0 src/lembas/web/templates/partials/icons.html | 125 +++++ tests/__init__.py | 0 32 files changed, 2041 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 assets/banner.svg create mode 100644 assets/favicon.svg create mode 100644 assets/logo-lockup.svg create mode 100644 assets/logo-mark.svg create mode 100644 assets/wordmark.svg create mode 100644 pyproject.toml create mode 100644 scripts/build_artwork.py create mode 100644 src/lembas/__init__.py create mode 100644 src/lembas/api/__init__.py create mode 100644 src/lembas/api/deps.py create mode 100644 src/lembas/config.py create mode 100644 src/lembas/db/__init__.py create mode 100644 src/lembas/db/base.py create mode 100644 src/lembas/db/models/__init__.py create mode 100644 src/lembas/db/models/chat.py create mode 100644 src/lembas/db/models/connection.py create mode 100644 src/lembas/db/models/setting.py create mode 100644 src/lembas/db/models/user.py create mode 100644 src/lembas/db/session.py create mode 100644 src/lembas/db/types.py create mode 100644 src/lembas/schemas/__init__.py create mode 100644 src/lembas/security/__init__.py create mode 100644 src/lembas/security/passwords.py create mode 100644 src/lembas/security/sessions.py create mode 100644 src/lembas/services/__init__.py create mode 100644 src/lembas/services/crypto.py create mode 100644 src/lembas/services/llm/__init__.py create mode 100644 src/lembas/web/__init__.py create mode 100644 src/lembas/web/templates/partials/icons.html create mode 100644 tests/__init__.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2a0dcbe --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# LLeMbas configuration +# Copy to .env and edit. All variables are prefixed LEMBAS_. + +# REQUIRED. Secret used to sign session cookies and to derive the key that +# encrypts stored API keys at rest. Generate one with: +# python -c "import secrets; print(secrets.token_urlsafe(48))" +# Changing this invalidates all sessions AND makes stored API keys unreadable. +LEMBAS_SECRET_KEY= + +# Where the SQLite database and uploaded files live. +LEMBAS_DATA_DIR=./data + +# HTTP server bind address. +LEMBAS_HOST=127.0.0.1 +LEMBAS_PORT=8080 + +# Autoreload on code change. Development only. +LEMBAS_RELOAD=false + +# debug | info | warning | error +LEMBAS_LOG_LEVEL=info + +# Allow new accounts to register themselves. The very first account created is +# always an admin, regardless of this setting. Turn off once your users exist. +LEMBAS_ALLOW_SIGNUP=true + +# Default theme for signed-out visitors: moria (dark) or shire (light). +LEMBAS_DEFAULT_THEME=moria + +# Seconds a login session stays valid. Default 30 days. +LEMBAS_SESSION_TTL=2592000 + +# Seconds to wait on an upstream LLM endpoint before giving up. +LEMBAS_REQUEST_TIMEOUT=300 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30d6526 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +venv/ + +# Tooling +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ + +# LLeMbas runtime +.env +data/ +*.db +*.db-journal +*.db-wal +*.db-shm + +# Editors / OS +.vscode/ +.idea/ +.DS_Store +*.swp diff --git a/assets/banner.svg b/assets/banner.svg new file mode 100644 index 0000000..a0080b1 --- /dev/null +++ b/assets/banner.svg @@ -0,0 +1,264 @@ + + LLeMbas + Waybread for the long road of thought. A mallorn leaf and wafer above the mountains at night. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..6b4c4aa --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1,27 @@ + + LLeMbas + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logo-lockup.svg b/assets/logo-lockup.svg new file mode 100644 index 0000000..37fbd2e --- /dev/null +++ b/assets/logo-lockup.svg @@ -0,0 +1,67 @@ + + LLeMbas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logo-mark.svg b/assets/logo-mark.svg new file mode 100644 index 0000000..787cb74 --- /dev/null +++ b/assets/logo-mark.svg @@ -0,0 +1,49 @@ + + LLeMbas + A silver mallorn leaf laid across a scored golden lembas wafer. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/wordmark.svg b/assets/wordmark.svg new file mode 100644 index 0000000..dcfc679 --- /dev/null +++ b/assets/wordmark.svg @@ -0,0 +1,23 @@ + + LLeMbas + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..60a6266 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "lembas" +version = "0.1.0" +description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints" +readme = "README.md" +requires-python = ">=3.11" +license = { file = "LICENSE" } +authors = [{ name = "Jaroslav Benes", email = "admin@ecoposta.sk" }] +keywords = ["llm", "webui", "openai", "chat", "self-hosted"] +classifiers = [ + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3", + "Topic :: Communications :: Chat", +] + +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.32", + "jinja2>=3.1", + "sqlalchemy>=2.0", + "pydantic>=2.9", + "pydantic-settings>=2.6", + "httpx>=0.27", + "python-multipart>=0.0.12", + "argon2-cffi>=23.1", + "cryptography>=43.0", + "markdown-it-py>=3.0", + "mdit-py-plugins>=0.4", + "pygments>=2.18", + "nh3>=0.2.18", + "typer>=0.12", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3", + "pytest-asyncio>=0.24", + "ruff>=0.7", +] + +[project.scripts] +lembas = "lembas.cli:app" + +[project.urls] +Homepage = "https://github.com/homer/LLeMbas" + +[tool.hatch.build.targets.wheel] +packages = ["src/lembas"] + +[tool.ruff] +line-length = 100 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "C4"] +ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +filterwarnings = ["ignore::DeprecationWarning"] diff --git a/scripts/build_artwork.py b/scripts/build_artwork.py new file mode 100644 index 0000000..548c0e8 --- /dev/null +++ b/scripts/build_artwork.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +"""Generate every LLeMbas SVG asset from one source of truth. + +Why a generator rather than five hand-written files: the leaf mark appears in +the icon, the favicon, the lockup and the banner. Keeping the geometry in one +place is the only way those stay identical as the mark is tuned. + +Why the wordmark is outlines and not : a README banner on GitHub or Gitea +cannot load a webfont, so would render in whatever serif the viewer +happens to have. Outlines look the same everywhere. Letterforms come from +Source Serif 4 (Adobe, SIL OFL 1.1); only the handful of glyphs actually used +are extracted, as a static drawing -- no font binary is redistributed. + +This is a design-time tool. The application never imports it, and the generated +files are committed. Re-run it only when the artwork itself changes: + + pip install fonttools + python scripts/build_artwork.py +""" + +from __future__ import annotations + +import argparse +import random +import sys +from pathlib import Path + +try: + from fontTools.pens.boundsPen import BoundsPen + from fontTools.pens.svgPathPen import SVGPathPen + from fontTools.pens.transformPen import TransformPen + from fontTools.ttLib import TTFont +except ImportError: # pragma: no cover - design-time tool + sys.exit("fontTools is required for this script: pip install fonttools") + +ROOT = Path(__file__).resolve().parent.parent +ASSETS = ROOT / "assets" + +FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf") +FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf") + +WORDMARK = "LLeMbas" +TAGLINE = "Waybread for the long road of thought" +# The capitals of LLeMbas spell LLM. Those three glyphs carry the accent colour. +ACCENT_GLYPHS = frozenset({0, 1, 3}) + +# --- Palette ----------------------------------------------------------------- +GOLD_LIGHT = "#EACB74" +GOLD = "#C9A227" +GOLD_DARK = "#916F13" +GOLD_SCORE = "#7A5C10" +GOLD_HILIGHT = "#F6E3A8" +RUNE_GOLD = "#E0B252" + +LEAF_EDGE = "#93A5B6" +LEAF_LIGHT = "#F1F6FA" +LEAF_MID = "#B8C7D5" +LEAF_VEIN = "#61758A" +LEAF_STEM = "#8A9AA8" + +NIGHT_TOP = "#080B0F" +NIGHT_MID = "#101822" +NIGHT_LOW = "#1A2530" +PARCHMENT = "#EDE6D6" +INK = "#1B1F23" +MUTED = "#9AA7B4" + +# --- The mallorn leaf -------------------------------------------------------- +# Drawn once, in a 64x64 box, and reused everywhere. Tuned so the silhouette +# still reads as a leaf at 16px, where veins and score lines disappear. +LEAF_BLADE = ( + "M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" +) +LEAF_MIDRIB = "M20.5 45.5 C28 38 36 29 45.5 18.5" +LEAF_STEM_PATH = "M21.2 44.8 L17 49.4" +LEAF_VEINS = [ + "M26.9 38.8 Q25.2 35.8 24.9 32.1", + "M32.3 33.1 Q30.9 30.3 30.4 26.9", + "M37.8 27.0 Q36.6 24.6 36.3 21.7", + "M26.9 38.8 Q30.5 40.1 33.7 40.3", + "M32.3 33.1 Q35.8 34.3 38.6 34.5", + "M37.8 27.0 Q40.8 27.9 43.2 28.1", +] + +HEADER = ' str: + """Render the glyphs, offset so the run's left edge sits at x=0. + + Class names are caller-supplied because , no page CSS reaches the document, + so the media query is the only thing keeping the wordmark legible on a dark + background. + """ + return f"""{indent}""" + + +# --- The mark ---------------------------------------------------------------- +def mark_defs(prefix: str) -> str: + return f""" + + + + + + + + + + + + + + """ + + +def mark_body(prefix: str, *, detail: bool = True) -> str: + """The wafer-and-leaf mark in a 64x64 box. + + detail=False drops the score lines, rim and veins for small-size use. + """ + parts = [f' '] + + if detail: + parts.append(f""" + + + + + + + + + + """) + + parts.append(f""" + + + """) + + if detail: + veins = "\n".join(f' ' for v in LEAF_VEINS) + parts.append(f""" +{veins} + """) + + parts.append(" ") + return "\n".join(parts) + + +# --- Asset builders ---------------------------------------------------------- +def build_logo_mark() -> str: + return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64" + role="img" aria-label="LLeMbas"> + LLeMbas + A silver mallorn leaf laid across a scored golden lembas wafer. +{mark_defs("m")} +{mark_body("m")} + +""" + + +def build_favicon() -> str: + """Small-size variant: no score lines or veins, larger blade, tighter tile.""" + return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64" + role="img" aria-label="LLeMbas"> + LLeMbas +{mark_defs("f")} + + + + + + + +""" + + +def build_wordmark() -> str: + """Standalone type. Inherits colour so it can sit on any background.""" + run = TextRun(FONT_SEMIBOLD, WORDMARK, 100) + return f"""{HEADER} viewBox="0 0 {run.width:.2f} {run.height:.2f}" + width="{run.width:.2f}" height="{run.height:.2f}" role="img" aria-label="LLeMbas"> + LLeMbas + +{type_style(" ")} + +{run.paths(ACCENT_GLYPHS, indent=" ")} + + +""" + + +def build_lockup() -> str: + """Horizontal mark + wordmark, for the application header.""" + cap = 46.0 + run = TextRun(FONT_SEMIBOLD, WORDMARK, cap) + mark_size = 64.0 + gap = 20.0 + pad = 4.0 + + height = mark_size + pad * 2 + text_x = pad + mark_size + gap + # Optically centre on the cap height rather than the full glyph bounds, so + # the ascender of "b" and the overshoot of "e" do not shift the baseline. + baseline_y = height / 2 + cap / 2 + width = text_x + run.width + pad + + return f"""{HEADER} viewBox="0 0 {width:.2f} {height:.2f}" + width="{width:.2f}" height="{height:.2f}" role="img" aria-label="LLeMbas"> + LLeMbas +{mark_defs("l")} +{type_style(" ")} + +{mark_body("l")} + + +{run.paths(ACCENT_GLYPHS, indent=" ")} + + +""" + + +def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str: + """One jagged ridge line spanning the full width.""" + rng = random.Random(seed) + points = [(0.0, base_y)] + x = 0.0 + while x < width: + step = rng.uniform(width * 0.045, width * 0.11) + x = min(x + step, width) + peak = base_y - rng.uniform(height * 0.35, height) + points.append((x, peak)) + # A short shoulder after each peak keeps the ridge from looking like a saw. + if x < width: + x = min(x + rng.uniform(width * 0.01, width * 0.03), width) + points.append((x, peak + rng.uniform(height * 0.08, height * 0.25))) + points.append((width, base_y)) + coords = " ".join(f"{px:.1f},{py:.1f}" for px, py in points) + return f' ' + + +def _stars(width: float, height: float, count: int, seed: int) -> str: + rng = random.Random(seed) + out = [] + for _ in range(count): + sx = rng.uniform(0, width) + sy = rng.uniform(0, height) + r = rng.uniform(0.6, 1.9) + opacity = rng.uniform(0.18, 0.85) + out.append( + f' ' + ) + return "\n".join(out) + + +def _drifting_leaves(seed: int) -> str: + """A few mallorn leaves adrift in the sky, well behind the type.""" + rng = random.Random(seed) + placements = [ + (120, 90, 0.42, -18), (250, 250, 0.30, 24), (1035, 95, 0.36, 12), + (1160, 215, 0.46, -32), (905, 300, 0.26, 40), (185, 300, 0.24, -8), + ] + out = [] + for cx, cy, scale, rot in placements: + opacity = rng.uniform(0.10, 0.19) + out.append( + f' ' + f'' + ) + return "\n".join(out) + + +def build_banner() -> str: + """README hero. + + Carries its own dark background rather than relying on the page, because a + README is rendered on a light background as often as a dark one. + """ + width, height = 1280.0, 420.0 + cap = 92.0 + run = TextRun(FONT_SEMIBOLD, WORDMARK, cap) + tag = TextRun(FONT_ITALIC, TAGLINE, 26.0) + + mark_size = 136.0 + gap = 34.0 + lockup_w = mark_size + gap + run.width + lockup_x = (width - lockup_w) / 2 + baseline_y = 232.0 + mark_y = baseline_y - cap / 2 - mark_size / 2 + + tag_x = (width - tag.width) / 2 - tag.x0 + tag_y = baseline_y + 68.0 + + mark_scale = mark_size / 64.0 + + return f"""{HEADER} viewBox="0 0 {width:.0f} {height:.0f}" + width="{width:.0f}" height="{height:.0f}" role="img" + aria-label="LLeMbas - {TAGLINE}"> + LLeMbas + {TAGLINE}. A mallorn leaf and wafer above the mountains at night. + + + + + + + + + + + + + + + +{mark_defs("b").removeprefix(" ").removesuffix(" ").rstrip()} + + + + +{_stars(width, 300, 130, 11)} + + + +{_drifting_leaves(5)} + + +{_mountains(width, 366, 3, 150, "#1C2836")} +{_mountains(width, 392, 8, 112, "#111A25")} +{_mountains(width, 416, 21, 74, "#080D13")} + + + + +{mark_body("b")} + + + +{run.paths(ACCENT_GLYPHS, indent=" ")} + + + +{tag.paths(indent=" ", base_class="tag")} + + +""" + + +# --- Entry point ------------------------------------------------------------- +BUILDERS = { + "logo-mark.svg": build_logo_mark, + "favicon.svg": build_favicon, + "wordmark.svg": build_wordmark, + "logo-lockup.svg": build_lockup, + "banner.svg": build_banner, +} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=ASSETS) + parser.add_argument("--only", nargs="*", choices=sorted(BUILDERS), default=None) + args = parser.parse_args() + + args.out.mkdir(parents=True, exist_ok=True) + for filename in args.only or BUILDERS: + path = args.out / filename + path.write_text(BUILDERS[filename](), encoding="utf-8") + print(f"wrote {path.relative_to(ROOT)} ({path.stat().st_size:,} bytes)") + + +if __name__ == "__main__": + main() diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py new file mode 100644 index 0000000..fe2b959 --- /dev/null +++ b/src/lembas/__init__.py @@ -0,0 +1,3 @@ +"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" + +__version__ = "0.1.0" diff --git a/src/lembas/api/__init__.py b/src/lembas/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lembas/api/deps.py b/src/lembas/api/deps.py new file mode 100644 index 0000000..711aeff --- /dev/null +++ b/src/lembas/api/deps.py @@ -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) diff --git a/src/lembas/config.py b/src/lembas/config.py new file mode 100644 index 0000000..5517cc4 --- /dev/null +++ b/src/lembas/config.py @@ -0,0 +1,64 @@ +"""Application configuration, loaded from the environment and/or a .env file.""" + +from __future__ import annotations + +import secrets +from functools import lru_cache +from pathlib import Path +from typing import Literal + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Runtime configuration. Every variable is prefixed ``LEMBAS_``.""" + + model_config = SettingsConfigDict( + env_prefix="LEMBAS_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + secret_key: str = Field(default="") + data_dir: Path = Path("./data") + + host: str = "127.0.0.1" + port: int = 8080 + reload: bool = False + log_level: Literal["debug", "info", "warning", "error"] = "info" + + allow_signup: bool = True + default_theme: Literal["moria", "shire"] = "moria" + session_ttl: int = 60 * 60 * 24 * 30 + request_timeout: float = 300.0 + + @field_validator("secret_key") + @classmethod + def _generate_secret_if_absent(cls, v: str) -> str: + # A generated key lets `lembas serve` work out of the box, but it changes + # on every restart: sessions drop and stored API keys become unreadable. + # main.py warns loudly about this. Never rely on it in production. + return v or secrets.token_urlsafe(48) + + @property + def db_path(self) -> Path: + return self.data_dir / "lembas.db" + + @property + def uploads_dir(self) -> Path: + return self.data_dir / "uploads" + + def ensure_dirs(self) -> None: + self.data_dir.mkdir(parents=True, exist_ok=True) + self.uploads_dir.mkdir(parents=True, exist_ok=True) + + +@lru_cache +def get_settings() -> Settings: + """Cached singleton so config is parsed once per process.""" + return Settings() + + +settings = get_settings() diff --git a/src/lembas/db/__init__.py b/src/lembas/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lembas/db/base.py b/src/lembas/db/base.py new file mode 100644 index 0000000..cc89a46 --- /dev/null +++ b/src/lembas/db/base.py @@ -0,0 +1,54 @@ +"""Declarative base and column conventions shared by every model. + +There is no Alembic in this project (SQLite only, schema created at startup). +That makes adding a column to an existing deployment a manual chore, so models +carry a few forward-looking columns that are not read yet -- see the notes on +``Message.parent_id`` and ``Message.content_parts_json``. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +from sqlalchemy import DateTime, MetaData, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +# Explicit naming convention so constraints have stable, predictable names. +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +def new_id() -> str: + """Primary keys are UUID4 hex strings: URL-safe and non-enumerable.""" + return uuid.uuid4().hex + + +def utcnow() -> datetime: + return datetime.now(UTC) + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) + + +class UUIDPrimaryKey: + """Mixin: opaque string primary key generated in Python.""" + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) + + +class Timestamps: + """Mixin: creation and modification times, both timezone-aware UTC.""" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False + ) diff --git a/src/lembas/db/models/__init__.py b/src/lembas/db/models/__init__.py new file mode 100644 index 0000000..def1d8a --- /dev/null +++ b/src/lembas/db/models/__init__.py @@ -0,0 +1,45 @@ +"""All ORM models. + +Importing this package registers every table on ``Base.metadata``, which is +what ``init_db()`` relies on to create the schema at startup. Any new model +module must be imported here or its table will silently never be created. +""" + +from lembas.db.models.chat import ( + ROLE_ASSISTANT, + ROLE_SYSTEM, + ROLE_TOOL, + ROLE_USER, + Chat, + Folder, + Message, +) +from lembas.db.models.connection import Connection, Model +from lembas.db.models.setting import Setting +from lembas.db.models.user import ( + ROLE_ADMIN, + ROLE_PENDING, + Group, + Session, + User, + user_groups, +) + +__all__ = [ + "ROLE_ADMIN", + "ROLE_ASSISTANT", + "ROLE_PENDING", + "ROLE_SYSTEM", + "ROLE_TOOL", + "ROLE_USER", + "Chat", + "Connection", + "Folder", + "Group", + "Message", + "Model", + "Session", + "Setting", + "User", + "user_groups", +] diff --git a/src/lembas/db/models/chat.py b/src/lembas/db/models/chat.py new file mode 100644 index 0000000..6df9625 --- /dev/null +++ b/src/lembas/db/models/chat.py @@ -0,0 +1,117 @@ +"""Folders, chats and messages.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import Boolean, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from lembas.db.base import Base, Timestamps, UUIDPrimaryKey +from lembas.db.types import JSONDict, JSONList + +ROLE_SYSTEM = "system" +ROLE_USER = "user" +ROLE_ASSISTANT = "assistant" +ROLE_TOOL = "tool" + + +class Folder(UUIDPrimaryKey, Timestamps, Base): + """A user-owned, arbitrarily nested container for chats.""" + + __tablename__ = "folders" + + user_id: Mapped[str] = mapped_column( + String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + parent_id: Mapped[str | None] = mapped_column( + String(32), ForeignKey("folders.id", ondelete="CASCADE") + ) + name: Mapped[str] = mapped_column(String(200), nullable=False) + position: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + collapsed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + children: Mapped[list[Folder]] = relationship( + back_populates="parent", + cascade="all, delete-orphan", + order_by="Folder.position, Folder.name", + ) + parent: Mapped[Folder | None] = relationship(back_populates="children", remote_side="Folder.id") + chats: Mapped[list[Chat]] = relationship(back_populates="folder") + + def __repr__(self) -> str: + return f"" + + +class Chat(UUIDPrimaryKey, Timestamps, Base): + __tablename__ = "chats" + + user_id: Mapped[str] = mapped_column( + String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + # Deleting a folder keeps its chats; they fall back to the unfiled list. + folder_id: Mapped[str | None] = mapped_column( + String(32), ForeignKey("folders.id", ondelete="SET NULL"), index=True + ) + + title: Mapped[str] = mapped_column(String(300), default="New chat") + # Set once the model writes the first reply, so auto-titling only runs once. + title_generated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # Denormalised rather than a foreign key: chat history must survive an admin + # deleting a connection or a model disappearing upstream. + model_id: Mapped[str] = mapped_column(String(300), default="") + connection_id: Mapped[str | None] = mapped_column( + String(32), ForeignKey("connections.id", ondelete="SET NULL") + ) + + system_prompt: Mapped[str] = mapped_column(Text, default="") + params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + + pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + folder: Mapped[Folder | None] = relationship(back_populates="chats") + messages: Mapped[list[Message]] = relationship( + back_populates="chat", + cascade="all, delete-orphan", + order_by="Message.created_at", + ) + + def __repr__(self) -> str: + return f"" + + +class Message(UUIDPrimaryKey, Timestamps, Base): + __tablename__ = "messages" + + chat_id: Mapped[str] = mapped_column( + String(32), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True + ) + + # Reserved for conversation branching (edit a message, regenerate a reply + # and keep both). Nothing reads it yet; it exists now because retrofitting a + # column onto a live SQLite database without migrations is painful. + parent_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("messages.id")) + + role: Mapped[str] = mapped_column(String(16), nullable=False) + content: Mapped[str] = mapped_column(Text, default="") + + # Reserved for multimodal turns: [{"type": "image_url", ...}, ...]. + # Plain-text messages leave this empty and use `content`. + content_parts_json: Mapped[list[Any]] = mapped_column(JSONList, default=list) + + model_id: Mapped[str] = mapped_column(String(300), default="") + tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list) + usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + + # Non-empty when generation failed. Rendered as a styled error in the + # thread so a failed turn is never an unexplained blank bubble. + error: Mapped[str] = mapped_column(Text, default="") + # False while a reply is still streaming; flipped when the stream ends. + complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + chat: Mapped[Chat] = relationship(back_populates="messages") + + def __repr__(self) -> str: + return f"" diff --git a/src/lembas/db/models/connection.py b/src/lembas/db/models/connection.py new file mode 100644 index 0000000..f7cd000 --- /dev/null +++ b/src/lembas/db/models/connection.py @@ -0,0 +1,82 @@ +"""OpenAI-compatible endpoint connections and their discovered models.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from lembas.db.base import Base, Timestamps, UUIDPrimaryKey +from lembas.db.types import JSONDict + + +class Connection(UUIDPrimaryKey, Timestamps, Base): + """A configured upstream endpoint speaking the OpenAI HTTP API. + + Works for api.openai.com as well as LM Studio, vLLM, llama.cpp, Ollama's + compatibility layer, OpenRouter, and anything else exposing /v1. + """ + + __tablename__ = "connections" + + name: Mapped[str] = mapped_column(String(120), nullable=False) + base_url: Mapped[str] = mapped_column(String(500), nullable=False) + + # Fernet ciphertext, never the raw key. See lembas.services.crypto. + # Empty string is legitimate: local endpoints often need no auth at all. + api_key_encrypted: Mapped[str] = mapped_column(Text, default="") + + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + position: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + # Extra headers merged into every request (e.g. OpenRouter's HTTP-Referer). + extra_headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + + # Result of the most recent "Test & refresh", surfaced in the admin list. + last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_error: Mapped[str] = mapped_column(Text, default="") + + models: Mapped[list[Model]] = relationship( + back_populates="connection", + cascade="all, delete-orphan", + order_by="Model.model_id", + ) + + def __repr__(self) -> str: + return f"" + + +class Model(UUIDPrimaryKey, Timestamps, Base): + """A model advertised by a connection, cached locally. + + Cached rather than fetched live so the chat UI stays responsive and keeps + working when an endpoint is briefly unreachable. Refreshed on demand from + the admin screen. + """ + + __tablename__ = "models" + __table_args__ = (UniqueConstraint("connection_id", "model_id"),) + + connection_id: Mapped[str] = mapped_column( + String(32), ForeignKey("connections.id", ondelete="CASCADE"), nullable=False, index=True + ) + model_id: Mapped[str] = mapped_column(String(300), nullable=False) + display_name: Mapped[str] = mapped_column(String(300), default="") + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + # Endpoints do not reliably advertise capabilities, so these are admin + # overrides consumed by later passes (vision uploads, tool calling). + capabilities_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + # Default sampling params applied to new chats using this model. + params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + + connection: Mapped[Connection] = relationship(back_populates="models") + + @property + def label(self) -> str: + return self.display_name or self.model_id + + def __repr__(self) -> str: + return f"" diff --git a/src/lembas/db/models/setting.py b/src/lembas/db/models/setting.py new file mode 100644 index 0000000..44b3c6b --- /dev/null +++ b/src/lembas/db/models/setting.py @@ -0,0 +1,28 @@ +"""Instance-wide settings, stored as a key/value table.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from lembas.db.base import Base, Timestamps +from lembas.db.types import JSONDict + + +class Setting(Timestamps, Base): + """One row per settings group, value is an arbitrary JSON object. + + A key/value table rather than a wide typed table: admin settings grow with + every feature (tools, agents, image generation) and adding a column to a + live SQLite database without migrations is exactly what this avoids. + """ + + __tablename__ = "settings" + + key: Mapped[str] = mapped_column(String(120), primary_key=True) + value: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + + def __repr__(self) -> str: + return f"" diff --git a/src/lembas/db/models/user.py b/src/lembas/db/models/user.py new file mode 100644 index 0000000..a63c3f8 --- /dev/null +++ b/src/lembas/db/models/user.py @@ -0,0 +1,89 @@ +"""Users, groups and login sessions.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from lembas.db.base import Base, Timestamps, UUIDPrimaryKey +from lembas.db.types import JSONDict + +# Roles are a simple ordered ladder rather than a permission matrix. Groups +# (below) carry finer-grained permissions once the users/groups UI lands. +ROLE_ADMIN = "admin" +ROLE_USER = "user" +ROLE_PENDING = "pending" # registered but awaiting admin approval + +user_groups = Table( + "user_groups", + Base.metadata, + Column("user_id", String(32), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True), + Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True), +) + + +class User(UUIDPrimaryKey, Timestamps, Base): + __tablename__ = "users" + + email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + role: Mapped[str] = mapped_column(String(16), default=ROLE_USER, nullable=False) + active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + # Per-user preferences: theme, default model, composer behaviour, etc. + settings_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + + groups: Mapped[list[Group]] = relationship(secondary=user_groups, back_populates="users") + sessions: Mapped[list[Session]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + + @property + def is_admin(self) -> bool: + return self.role == ROLE_ADMIN + + def __repr__(self) -> str: + return f"" + + +class Group(UUIDPrimaryKey, Timestamps, Base): + """A named set of users. Permissions are enforced once the RBAC pass lands.""" + + __tablename__ = "groups" + + name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) + description: Mapped[str] = mapped_column(Text, default="") + permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + + users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups") + + +class Session(UUIDPrimaryKey, Timestamps, Base): + """Server-side login session. + + Sessions live in the database rather than in a signed JWT so that logging + out, banning a user, or rotating a device actually revokes access + immediately instead of waiting for a token to expire. + """ + + __tablename__ = "sessions" + + user_id: Mapped[str] = mapped_column( + String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + # SHA-256 of the cookie value. The raw token is shown to the browser once + # and never stored, so a database leak does not hand over live sessions. + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + user_agent: Mapped[str] = mapped_column(Text, default="") + ip_address: Mapped[str] = mapped_column(String(45), default="") + + user: Mapped[User] = relationship(back_populates="sessions") + + +Index("ix_sessions_user_id", Session.user_id) diff --git a/src/lembas/db/session.py b/src/lembas/db/session.py new file mode 100644 index 0000000..cdc1790 --- /dev/null +++ b/src/lembas/db/session.py @@ -0,0 +1,102 @@ +"""Engine, session factory and startup schema creation.""" + +from __future__ import annotations + +import logging +from collections.abc import Iterator +from contextlib import contextmanager + +from sqlalchemy import Engine, create_engine, event +from sqlalchemy.orm import Session, sessionmaker + +from lembas.config import settings +from lembas.db.base import Base + +log = logging.getLogger(__name__) + +_engine: Engine | None = None +_SessionFactory: sessionmaker[Session] | None = None + + +@event.listens_for(Engine, "connect") +def _configure_sqlite(dbapi_connection, connection_record) -> None: # noqa: ANN001 + """Apply the pragmas SQLite needs to behave under a concurrent web server. + + - WAL lets readers proceed while a write is in flight, which matters because + a streaming reply holds a write open for the length of the generation. + - foreign_keys is OFF by default in SQLite, so every ondelete= in the models + would be decoration without this. + - busy_timeout makes concurrent writers wait rather than fail instantly. + """ + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.close() + + +def get_engine() -> Engine: + global _engine + if _engine is None: + settings.ensure_dirs() + _engine = create_engine( + f"sqlite:///{settings.db_path}", + # FastAPI runs sync endpoints in a threadpool, so a connection can + # legitimately be used from a thread other than the one that made it. + connect_args={"check_same_thread": False}, + echo=False, + future=True, + ) + return _engine + + +def get_session_factory() -> sessionmaker[Session]: + global _SessionFactory + if _SessionFactory is None: + _SessionFactory = sessionmaker( + bind=get_engine(), + autoflush=False, + expire_on_commit=False, + ) + return _SessionFactory + + +def init_db() -> None: + """Create any missing tables. + + This is ``CREATE TABLE IF NOT EXISTS`` only -- it never alters an existing + table. There is no migration tool in this project by design, so changing a + column on a model requires migrating the database by hand. + """ + import lembas.db.models # noqa: F401 (registers tables on the metadata) + + Base.metadata.create_all(bind=get_engine()) + log.debug("schema ensured at %s", settings.db_path) + + +@contextmanager +def session_scope() -> Iterator[Session]: + """Transactional scope for background work and CLI commands. + + Request handlers should use the `db` dependency in lembas.api.deps instead. + """ + factory = get_session_factory() + session = factory() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +def reset_engine() -> None: + """Drop cached engine/factory. Used by tests to rebind to a temp database.""" + global _engine, _SessionFactory + if _engine is not None: + _engine.dispose() + _engine = None + _SessionFactory = None diff --git a/src/lembas/db/types.py b/src/lembas/db/types.py new file mode 100644 index 0000000..07d21d4 --- /dev/null +++ b/src/lembas/db/types.py @@ -0,0 +1,14 @@ +"""Reusable column types. + +SQLite stores JSON as text. Wrapping the JSON type in SQLAlchemy's mutation +tracking means ``obj.settings_json["theme"] = "shire"`` marks the row dirty -- +without it, in-place edits of a dict column are silently dropped on flush. +""" + +from __future__ import annotations + +from sqlalchemy import JSON +from sqlalchemy.ext.mutable import MutableDict, MutableList + +JSONDict = MutableDict.as_mutable(JSON) +JSONList = MutableList.as_mutable(JSON) diff --git a/src/lembas/schemas/__init__.py b/src/lembas/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lembas/security/__init__.py b/src/lembas/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lembas/security/passwords.py b/src/lembas/security/passwords.py new file mode 100644 index 0000000..6e8b651 --- /dev/null +++ b/src/lembas/security/passwords.py @@ -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 diff --git a/src/lembas/security/sessions.py b/src/lembas/security/sessions.py new file mode 100644 index 0000000..4467337 --- /dev/null +++ b/src/lembas/security/sessions.py @@ -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() diff --git a/src/lembas/services/__init__.py b/src/lembas/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lembas/services/crypto.py b/src/lembas/services/crypto.py new file mode 100644 index 0000000..1aa95e6 --- /dev/null +++ b/src/lembas/services/crypto.py @@ -0,0 +1,58 @@ +"""Symmetric encryption for secrets stored in the database. + +Only upstream API keys use this today. The key is derived from +``LEMBAS_SECRET_KEY`` rather than stored separately, which means rotating that +variable makes every stored API key unreadable -- decrypt() returns "" rather +than raising, so the app degrades to "re-enter your keys" instead of crashing. +""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +from functools import lru_cache + +from cryptography.fernet import Fernet, InvalidToken + +from lembas.config import settings + +log = logging.getLogger(__name__) + + +@lru_cache +def _fernet() -> Fernet: + # Fernet requires a 32-byte urlsafe-base64 key; SECRET_KEY is free-form text. + digest = hashlib.sha256(settings.secret_key.encode("utf-8")).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + +def encrypt(plaintext: str) -> str: + """Encrypt a secret. Empty input stays empty -- keyless endpoints are valid.""" + if not plaintext: + return "" + return _fernet().encrypt(plaintext.encode("utf-8")).decode("ascii") + + +def decrypt(ciphertext: str) -> str: + """Decrypt a secret, returning "" if it cannot be read. + + An unreadable value almost always means LEMBAS_SECRET_KEY changed. Failing + soft keeps the admin UI usable so the key can simply be re-entered. + """ + if not ciphertext: + return "" + try: + return _fernet().decrypt(ciphertext.encode("ascii")).decode("utf-8") + except (InvalidToken, ValueError): + log.warning("could not decrypt a stored secret; has LEMBAS_SECRET_KEY changed?") + return "" + + +def mask(secret: str) -> str: + """Render a secret for display: never the whole thing, just enough to identify it.""" + if not secret: + return "" + if len(secret) <= 8: + return "*" * len(secret) + return f"{secret[:3]}{'*' * 8}{secret[-4:]}" diff --git a/src/lembas/services/llm/__init__.py b/src/lembas/services/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lembas/web/__init__.py b/src/lembas/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lembas/web/templates/partials/icons.html b/src/lembas/web/templates/partials/icons.html new file mode 100644 index 0000000..846d25f --- /dev/null +++ b/src/lembas/web/templates/partials/icons.html @@ -0,0 +1,125 @@ +{# + Icon sprite, inlined once at the top of . + + It lives here rather than in assets/ and is deliberately NOT loaded as an + external file: cross-document has patchy browser + support, while same-document is universal. Inlining also + costs zero extra requests. + + Icons are 24x24, stroked (never filled), and inherit currentColor, so they + take the surrounding text colour in every theme automatically. + + Use via the macro in _macros.html: {{ icon("send") }} +#} + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29