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,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"<Folder {self.name}>"
|
||||
|
||||
|
||||
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"<Chat {self.title!r}>"
|
||||
|
||||
|
||||
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"<Message {self.role} {self.content[:40]!r}>"
|
||||
@@ -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"<Connection {self.name} {self.base_url}>"
|
||||
|
||||
|
||||
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"<Model {self.model_id}>"
|
||||
@@ -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"<Setting {self.key}>"
|
||||
@@ -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"<User {self.email} role={self.role}>"
|
||||
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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:]}"
|
||||
@@ -0,0 +1,125 @@
|
||||
{#
|
||||
Icon sprite, inlined once at the top of <body>.
|
||||
|
||||
It lives here rather than in assets/ and is deliberately NOT loaded as an
|
||||
external file: cross-document <use href="sprite.svg#id"> has patchy browser
|
||||
support, while same-document <use href="#id"> 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") }}
|
||||
#}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
<defs>
|
||||
<g id="icon-defaults"></g>
|
||||
</defs>
|
||||
|
||||
<symbol id="i-send" viewBox="0 0 24 24"><path d="M12 20V5M5 12l7-7 7 7"/></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
|
||||
<symbol id="i-minus" viewBox="0 0 24 24"><path d="M5 12h14"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="M4.5 12.5 9 17 19.5 6.5"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
|
||||
<symbol id="i-dots" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="5" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="12" cy="19" r="1.4"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-chevron-right" viewBox="0 0 24 24"><path d="m9.5 5.5 7 6.5-7 6.5"/></symbol>
|
||||
<symbol id="i-chevron-down" viewBox="0 0 24 24"><path d="m5.5 9.5 6.5 7 6.5-7"/></symbol>
|
||||
|
||||
<symbol id="i-chat" viewBox="0 0 24 24">
|
||||
<path d="M20.5 12.2a7.7 7.7 0 0 1-8.3 7.7l-5 2.4.9-3.7a7.7 7.7 0 1 1 12.4-6.4Z"/>
|
||||
</symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24">
|
||||
<path d="M3 7.5A2 2 0 0 1 5 5.5h3.6a1 1 0 0 1 .8.4l1.2 1.6H19a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>
|
||||
</symbol>
|
||||
<symbol id="i-folder-open" viewBox="0 0 24 24">
|
||||
<path d="M3 18.5V7.5a2 2 0 0 1 2-2h3.6a1 1 0 0 1 .8.4l1.2 1.6H18a2 2 0 0 1 2 2v1"/>
|
||||
<path d="M3 18.5 5.6 11a1 1 0 0 1 .95-.7H21a1 1 0 0 1 .95 1.3l-2.1 6.2a2 2 0 0 1-1.9 1.4H5a2 2 0 0 1-2-2Z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-trash" viewBox="0 0 24 24">
|
||||
<path d="M4 7h16M9.5 7V5.4A1.4 1.4 0 0 1 10.9 4h2.2a1.4 1.4 0 0 1 1.4 1.4V7"/>
|
||||
<path d="m6.5 7 .9 11.6A1.5 1.5 0 0 0 8.9 20h6.2a1.5 1.5 0 0 0 1.5-1.4L17.5 7"/>
|
||||
</symbol>
|
||||
<symbol id="i-pencil" viewBox="0 0 24 24">
|
||||
<path d="M4 20h4L19.3 8.7a2.4 2.4 0 0 0-3.4-3.4L4.6 16.6 4 20Z"/>
|
||||
<path d="m14.8 6.4 2.8 2.8"/>
|
||||
</symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24">
|
||||
<rect x="9" y="9" width="11" height="11" rx="2"/>
|
||||
<path d="M15 6.5V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24">
|
||||
<path d="M20 12a8 8 0 1 1-2.6-5.9"/>
|
||||
<path d="M20 4v4.5h-4.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-pin" viewBox="0 0 24 24">
|
||||
<path d="M9 3.5h6l-.8 5.2 3 3.1-.7 2.2H7.5l-.7-2.2 3-3.1Z"/>
|
||||
<path d="M12 14v6.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>
|
||||
</symbol>
|
||||
<symbol id="i-attach" viewBox="0 0 24 24">
|
||||
<path d="M17.5 8.5v7.8a5 5 0 0 1-10 0V7a3.2 3.2 0 0 1 6.4 0v9.1a1.5 1.5 0 0 1-3 0V8.5"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-user" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="8.3" r="3.8"/>
|
||||
<path d="M4.5 20a7.5 7.5 0 0 1 15 0"/>
|
||||
</symbol>
|
||||
<symbol id="i-users" viewBox="0 0 24 24">
|
||||
<circle cx="9.5" cy="8.5" r="3.4"/>
|
||||
<path d="M3 19.5a6.5 6.5 0 0 1 13 0"/>
|
||||
<path d="M16 5.6a3.4 3.4 0 0 1 0 5.9M17.5 14.4a5.6 5.6 0 0 1 3.5 5.1"/>
|
||||
</symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24">
|
||||
<path d="M12 3.2 19.5 6v5.6c0 4.6-3.1 7.6-7.5 9.2-4.4-1.6-7.5-4.6-7.5-9.2V6Z"/>
|
||||
</symbol>
|
||||
<symbol id="i-logout" viewBox="0 0 24 24">
|
||||
<path d="M14.5 4.5H18a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-3.5"/>
|
||||
<path d="M9.5 8 5.5 12l4 4M5.5 12H15"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-server" viewBox="0 0 24 24">
|
||||
<rect x="3.5" y="4" width="17" height="6" rx="1.8"/>
|
||||
<rect x="3.5" y="14" width="17" height="6" rx="1.8"/>
|
||||
<path d="M7 7h.01M7 17h.01"/>
|
||||
</symbol>
|
||||
<symbol id="i-sliders" viewBox="0 0 24 24">
|
||||
<path d="M4 8h10M18 8h2M4 16h4M12 16h8"/>
|
||||
<circle cx="16" cy="8" r="2"/><circle cx="10" cy="16" r="2"/>
|
||||
</symbol>
|
||||
<symbol id="i-gear" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="3.2"/>
|
||||
<path d="M12 3v2.2M12 18.8V21M3 12h2.2M18.8 12H21M5.6 5.6l1.6 1.6M16.8 16.8l1.6 1.6M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-sun" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
<path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4"/>
|
||||
</symbol>
|
||||
<symbol id="i-moon" viewBox="0 0 24 24">
|
||||
<path d="M20.5 13.8A8.5 8.5 0 0 1 10.2 3.5a8.5 8.5 0 1 0 10.3 10.3Z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-sidebar" viewBox="0 0 24 24">
|
||||
<rect x="3.5" y="4.5" width="17" height="15" rx="2"/>
|
||||
<path d="M10 4.5v15"/>
|
||||
</symbol>
|
||||
<symbol id="i-archive" viewBox="0 0 24 24">
|
||||
<rect x="3.5" y="4.5" width="17" height="4" rx="1.2"/>
|
||||
<path d="M5 8.5v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-9M10 12.5h4"/>
|
||||
</symbol>
|
||||
<symbol id="i-warning" viewBox="0 0 24 24">
|
||||
<path d="M12 4.2 21 19.5H3Z"/>
|
||||
<path d="M12 10v4M12 16.8h.01"/>
|
||||
</symbol>
|
||||
<symbol id="i-leaf" viewBox="0 0 64 64">
|
||||
<path d="M20.5 45.5C13.8 31.7 23.8 20.9 45.5 18.5 49.8 35 39.8 45.8 20.5 45.5Z"/>
|
||||
<path d="M20.5 45.5C28 38 36 29 45.5 18.5"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
Reference in New Issue
Block a user