diff --git a/backend/alembic/versions/1a2b3c4d5e6f_add_embed_token_table.py b/backend/alembic/versions/1a2b3c4d5e6f_add_embed_token_table.py
new file mode 100644
index 00000000..32ac7fed
--- /dev/null
+++ b/backend/alembic/versions/1a2b3c4d5e6f_add_embed_token_table.py
@@ -0,0 +1,52 @@
+"""add embed_token table for scoped embed tokens
+
+Revision ID: 1a2b3c4d5e6f
+Revises: f7e9d1c3b5a2
+Create Date: 2026-06-13 23:25:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.engine.reflection import Inspector
+
+
+revision: str = "1a2b3c4d5e6f"
+down_revision: Union[str, Sequence[str], None] = "f7e9d1c3b5a2"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ tables = inspector.get_table_names()
+ if "embed_token" not in tables:
+ op.create_table(
+ "embed_token",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("user_id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(length=255), nullable=False),
+ sa.Column("token_prefix", sa.String(), nullable=False),
+ sa.Column("token_hash", sa.String(), nullable=False),
+ sa.Column("scopes", sa.String(), nullable=False, server_default="embed:stats:read"),
+ sa.Column("allowed_origins", sa.String(), nullable=True),
+ sa.Column("expires_at", sa.DateTime(), nullable=True),
+ sa.Column("last_used_at", sa.DateTime(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=False),
+ sa.Column("revoked_at", sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(["user_id"], ["user.id"], name="fk_embed_token_user_id"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("token_hash", name="uq_embed_token_token_hash"),
+ )
+ op.create_index(op.f("ix_embed_token_token_prefix"), "embed_token", ["token_prefix"])
+ op.create_index(op.f("ix_embed_token_user_id"), "embed_token", ["user_id"])
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ tables = inspector.get_table_names()
+ if "embed_token" in tables:
+ op.drop_table("embed_token")
diff --git a/backend/app/auth.py b/backend/app/auth.py
index 45905055..2a5a39a3 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -115,6 +115,31 @@ def get_api_key_prefix(api_key: str) -> str:
return api_key[:12]
+# --- Embed token utilities ---
+
+EMBED_TOKEN_PREFIX = "le_"
+EMBED_TOKEN_SCOPE_STATS_READ = "embed:stats:read"
+
+
+def generate_embed_token() -> str:
+ """Generate a new random embed token prefixed with 'le_'."""
+ return f"{EMBED_TOKEN_PREFIX}{secrets.token_urlsafe(32)}"
+
+
+def hash_embed_token(value: str) -> str:
+ """Return a HMAC-SHA256 hex digest of the embed token."""
+ return hmac.new(
+ settings.api_key_encryption_key.encode("utf-8"),
+ value.encode("utf-8"),
+ hashlib.sha256,
+ ).hexdigest()
+
+
+def get_embed_token_prefix(token: str) -> str:
+ """Return the first 12 characters of the embed token (visible prefix)."""
+ return token[:12]
+
+
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
diff --git a/backend/app/config.py b/backend/app/config.py
index ce51d515..708d4900 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -41,6 +41,7 @@ class Settings(BaseSettings):
cover_import_timeout_seconds: int = 15
hardcover_app_api_token: str = ""
thalia_cover_search_enabled: bool = False
+ embed_enabled: bool = True
forwarded_allow_ips: str = "*"
@field_validator("api_key_encryption_key")
diff --git a/backend/app/i18n/de.json b/backend/app/i18n/de.json
new file mode 100644
index 00000000..8bf72d56
--- /dev/null
+++ b/backend/app/i18n/de.json
@@ -0,0 +1,12 @@
+{
+ "embed": {
+ "stats": {
+ "books": "Bücher",
+ "reading": "Lese ich gerade",
+ "read": "Gelesen",
+ "to_read": "Möchte ich lesen",
+ "pages": "Seiten",
+ "avg_pages": "Seiten/Buch"
+ }
+ }
+}
diff --git a/backend/app/i18n/en.json b/backend/app/i18n/en.json
new file mode 100644
index 00000000..12651e10
--- /dev/null
+++ b/backend/app/i18n/en.json
@@ -0,0 +1,12 @@
+{
+ "embed": {
+ "stats": {
+ "books": "Books",
+ "reading": "Reading",
+ "read": "Read",
+ "to_read": "To Read",
+ "pages": "Pages",
+ "avg_pages": "Avg/Book"
+ }
+ }
+}
diff --git a/backend/app/i18n/es.json b/backend/app/i18n/es.json
new file mode 100644
index 00000000..87048454
--- /dev/null
+++ b/backend/app/i18n/es.json
@@ -0,0 +1,12 @@
+{
+ "embed": {
+ "stats": {
+ "books": "Libros",
+ "reading": "Leyendo",
+ "read": "Leído",
+ "to_read": "Quiero leer",
+ "pages": "Páginas",
+ "avg_pages": "Páginas/Libro"
+ }
+ }
+}
diff --git a/backend/app/i18n/fr.json b/backend/app/i18n/fr.json
new file mode 100644
index 00000000..21bb30b2
--- /dev/null
+++ b/backend/app/i18n/fr.json
@@ -0,0 +1,12 @@
+{
+ "embed": {
+ "stats": {
+ "books": "Livres",
+ "reading": "En cours",
+ "read": "Lu",
+ "to_read": "À lire",
+ "pages": "Pages",
+ "avg_pages": "Pages/Livre"
+ }
+ }
+}
diff --git a/backend/app/i18n/zh.json b/backend/app/i18n/zh.json
new file mode 100644
index 00000000..c757466a
--- /dev/null
+++ b/backend/app/i18n/zh.json
@@ -0,0 +1,12 @@
+{
+ "embed": {
+ "stats": {
+ "books": "图书",
+ "reading": "正在阅读",
+ "read": "已读",
+ "to_read": "待读",
+ "pages": "页数",
+ "avg_pages": "每本页数"
+ }
+ }
+}
diff --git a/backend/app/main.py b/backend/app/main.py
index 01c2fb1e..5674e95a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -13,7 +13,7 @@
from app._build_info import __git_sha__, __version__
from app.config import settings
from app.logging_config import configure_logging
-from app.routers import admin, auth, books, cover_candidates, covers, data, docs, health, hygiene, import_, oidc, profile, progress, statistics, users
+from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, statistics, users
from app.services.cover_storage import cleanup_orphan_covers
from app.services.data_import import cleanup_temp_files
@@ -164,4 +164,7 @@ async def proxy_headers_middleware(request: Request, call_next) -> Response:
app.include_router(hygiene.router)
app.include_router(statistics.router)
app.include_router(data.router)
+app.include_router(config.router)
+if settings.embed_enabled:
+ app.include_router(embed.router)
app.include_router(admin.router)
diff --git a/backend/app/models.py b/backend/app/models.py
index 3ea21987..6668665f 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -205,6 +205,36 @@ class OidcLink(SQLModel, table=True):
)
+class EmbedToken(SQLModel, table=True):
+ """A scoped embed token for iframe/dashboard integrations."""
+
+ __tablename__ = "embed_token"
+
+ id: Optional[int] = Field(default=None, primary_key=True)
+ user_id: int = Field(foreign_key="user.id", index=True)
+ name: str = Field(max_length=255)
+ token_prefix: str = Field(index=True)
+ token_hash: str = Field(index=True, unique=True)
+ scopes: str = Field(default="embed:stats:read") # comma-separated list
+ allowed_origins: Optional[str] = None # comma-separated; null = wildcard
+ expires_at: Optional[datetime] = Field(
+ default=None,
+ sa_column=Column(UtcDateTime, default=None)
+ )
+ last_used_at: Optional[datetime] = Field(
+ default=None,
+ sa_column=Column(UtcDateTime, default=None)
+ )
+ created_at: datetime = Field(
+ default_factory=utcnow,
+ sa_column=Column(UtcDateTime, default=utcnow)
+ )
+ revoked_at: Optional[datetime] = Field(
+ default=None,
+ sa_column=Column(UtcDateTime, default=None)
+ )
+
+
class ImportMapping(SQLModel, table=True):
"""A saved column-mapping configuration for data import."""
diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py
new file mode 100644
index 00000000..ca6aae93
--- /dev/null
+++ b/backend/app/routers/config.py
@@ -0,0 +1,18 @@
+"""Application-level config endpoint for frontend feature flags."""
+
+from fastapi import APIRouter
+
+from app.config import settings
+from app.schemas import AppConfigRead
+
+router = APIRouter(prefix="/api", tags=["config"])
+
+
+@router.get("/config", response_model=AppConfigRead)
+def app_config() -> AppConfigRead:
+ """Return application-level feature flags."""
+ return AppConfigRead(
+ embed_enabled=settings.embed_enabled,
+ dashboard_quote_enabled=settings.dashboard_quote_enabled,
+ thalia_cover_search_enabled=settings.thalia_cover_search_enabled,
+ )
diff --git a/backend/app/routers/docs.py b/backend/app/routers/docs.py
index 5c505c48..86bb2b85 100644
--- a/backend/app/routers/docs.py
+++ b/backend/app/routers/docs.py
@@ -80,35 +80,49 @@ def _wrap_docs_html(html: str) -> HTMLResponse:
return HTMLResponse(html.replace("", f"{custom_css}"))
+def _get_openapi_url(request: Request) -> str:
+ """Return the OpenAPI URL relative to the current request, respecting root_path."""
+ root_path = request.scope.get("root_path", "")
+ return f"{root_path}/api/openapi.json"
+
+
@router.get("/docs", include_in_schema=False)
def redirect_to_custom_swagger_docs(request: Request) -> RedirectResponse:
"""Redirect to the custom-themed Swagger UI page."""
- return RedirectResponse(url="/api/docs")
+ root_path = request.scope.get("root_path", "")
+ return RedirectResponse(url=f"{root_path}/api/docs")
@router.get("/api/docs", include_in_schema=False)
def custom_swagger_docs(request: Request) -> HTMLResponse:
"""Serve a custom-themed Swagger UI page."""
- html = get_swagger_ui_html(
- openapi_url=request.app.openapi_url,
+ openapi_url = _get_openapi_url(request)
+ response = get_swagger_ui_html(
+ openapi_url=openapi_url,
title=f"{request.app.title} - Swagger UI",
swagger_ui_parameters={
"defaultModelsExpandDepth": -1,
"displayRequestDuration": True,
"docExpansion": "list",
},
- ).body.decode("utf-8")
+ )
+ body = response.body.tobytes() if isinstance(response.body, memoryview) else response.body
+ html = body.decode("utf-8")
return _wrap_docs_html(html)
@router.get("/redoc", include_in_schema=False)
def redirect_to_custom_redoc_docs(request: Request) -> RedirectResponse:
"""Redirect to the custom-themed ReDoc page."""
- return RedirectResponse(url="/api/redoc")
+ root_path = request.scope.get("root_path", "")
+ return RedirectResponse(url=f"{root_path}/api/redoc")
@router.get("/api/redoc", include_in_schema=False)
def custom_redoc_docs(request: Request) -> HTMLResponse:
"""Serve a custom-themed ReDoc page."""
- html = get_redoc_html(
- openapi_url=request.app.openapi_url,
+ openapi_url = _get_openapi_url(request)
+ response = get_redoc_html(
+ openapi_url=openapi_url,
title=f"{request.app.title} - ReDoc",
- ).body.decode("utf-8")
+ )
+ body = response.body.tobytes() if isinstance(response.body, memoryview) else response.body
+ html = body.decode("utf-8")
return _wrap_docs_html(html)
diff --git a/backend/app/routers/embed.py b/backend/app/routers/embed.py
new file mode 100644
index 00000000..5bc33af9
--- /dev/null
+++ b/backend/app/routers/embed.py
@@ -0,0 +1,297 @@
+"""Embed HTML widget endpoints for iframe dashboard integrations."""
+
+import html
+import logging
+from pathlib import Path
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
+from fastapi.responses import HTMLResponse, Response
+from sqlmodel import Session, select
+
+from app.auth import EMBED_TOKEN_SCOPE_STATS_READ, hash_embed_token
+from app.database import get_session
+from app.models import Book, EmbedToken, ReadingStatus, User
+from app.time_utils import utcnow
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/embed/v1", tags=["embed"])
+
+VALID_STAT_KEYS = {"books", "reading", "read", "to_read", "pages", "avg_pages"}
+LAYOUT_MODES = {"grid", "list"}
+
+
+def _load_stat_labels() -> dict[str, dict[str, str]]:
+ import json
+
+ i18n_dir = Path(__file__).resolve().parent.parent / "i18n"
+ required = {"books", "reading", "read", "to_read", "pages", "avg_pages"}
+ loaded: dict[str, dict[str, str]] = {}
+
+ for file_path in i18n_dir.glob("*.json"):
+ locale = file_path.stem.lower()
+ with file_path.open("r", encoding="utf-8") as f:
+ data = json.load(f)
+
+ stats_labels = data.get("embed", {}).get("stats", {})
+ if not isinstance(stats_labels, dict):
+ continue
+
+ missing = required - set(stats_labels.keys())
+ if missing:
+ raise RuntimeError(
+ f"Invalid i18n file {file_path.name}: missing embed.stats keys {', '.join(sorted(missing))}"
+ )
+ loaded[locale] = {k: str(v) for k, v in stats_labels.items() if k in required}
+
+ if "en" not in loaded:
+ raise RuntimeError("Invalid i18n files: expected at least en.json with embed.stats labels")
+
+ return loaded
+
+
+STAT_LABELS = _load_stat_labels()
+
+
+def _normalize_lang(lang: str) -> str:
+ base = (lang or "en").strip().lower().replace("_", "-").split("-", 1)[0]
+ return base if base in STAT_LABELS else "en"
+
+
+def _verify_embed_token(
+ token: str,
+ session: Session,
+ request: Request,
+) -> User:
+ token_hash_val = hash_embed_token(token)
+ db_token = session.exec(
+ select(EmbedToken).where(
+ EmbedToken.token_hash == token_hash_val,
+ EmbedToken.revoked_at.is_(None),
+ )
+ ).first()
+
+ if not db_token:
+ logger.warning("Embed auth: invalid token prefix=%s", token[:12])
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or revoked token",
+ )
+
+ if db_token.expires_at and db_token.expires_at < utcnow():
+ logger.warning(
+ "Embed auth: expired token id=%s user=%s",
+ db_token.id, db_token.user_id,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Token expired",
+ )
+
+ scopes = [s.strip() for s in db_token.scopes.split(",")]
+ if EMBED_TOKEN_SCOPE_STATS_READ not in scopes:
+ logger.warning(
+ "Embed auth: missing scope token id=%s scopes=%s",
+ db_token.id, db_token.scopes,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Token lacks required scope",
+ )
+
+ if db_token.allowed_origins:
+ origin = request.headers.get("origin") or request.headers.get("referer") or ""
+ if origin:
+ from urllib.parse import urlparse
+ parsed = urlparse(origin)
+ request_origin = f"{parsed.scheme}://{parsed.netloc}".lower()
+ else:
+ request_origin = ""
+
+ allowed = [
+ o.strip().lower()
+ for o in db_token.allowed_origins.split(",")
+ if o.strip()
+ ]
+ if request_origin and request_origin not in allowed:
+ logger.warning(
+ "Embed auth: origin denied token id=%s user=%s origin=%s allowed=%s",
+ db_token.id, db_token.user_id, request_origin, allowed,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Origin not allowed",
+ )
+
+ user = session.get(User, db_token.user_id)
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Token user not found",
+ )
+
+ db_token.last_used_at = utcnow()
+ session.add(db_token)
+ session.commit()
+
+ return user
+
+
+def _compute_stats(books: list[Book]) -> dict[str, int]:
+ total = len(books)
+ read = sum(1 for b in books if b.reading_status == ReadingStatus.read)
+ reading = sum(1 for b in books if b.reading_status == ReadingStatus.currently_reading)
+ want_to_read = sum(1 for b in books if b.reading_status == ReadingStatus.want_to_read)
+ pages = sum(b.page_count or 0 for b in books if b.page_count)
+ avg_pages = round(pages / total, 0) if total > 0 else 0
+ return {
+ "books": total,
+ "reading": reading,
+ "read": read,
+ "to_read": want_to_read,
+ "pages": pages,
+ "avg_pages": int(avg_pages),
+ }
+
+
+def _render_stats_html(
+ stats: dict[str, int],
+ theme: str,
+ accent: str,
+ radius: str,
+ density: str,
+ hide_labels: bool,
+ lang: str,
+ font_scale: float,
+ layout: str,
+ show: Optional[set[str]] = None,
+) -> str:
+ density_gap = {"compact": "0.25rem", "normal": "0.5rem", "comfortable": "0.75rem"}.get(density, "0.5rem")
+ radius_map = {"none": "0", "sm": "0.375rem", "md": "0.5rem", "lg": "0.75rem", "xl": "1rem"}.get(radius, "0.5rem")
+
+ bg = "#ffffff" if theme == "light" else "#1d232a"
+ fg = "#1f2937" if theme == "light" else "#e5e7eb"
+ muted = "#6b7280" if theme == "light" else "#9ca3af"
+ card_bg = "#f3f4f6" if theme == "light" else "#2a323d"
+ card_border = "#e5e7eb" if theme == "light" else "#3d4452"
+
+ base_font_size = f"{round(14 * font_scale, 1)}px"
+
+ card_style = (
+ f"background:{card_bg};border:1px solid {card_border};"
+ f"border-radius:{radius_map};padding:{density_gap};"
+ f"text-align:center;min-width:0"
+ )
+
+ parts: list[str] = []
+
+ labels = STAT_LABELS[_normalize_lang(lang)]
+
+ if show:
+ keys = [k for k in VALID_STAT_KEYS if k in show]
+ else:
+ keys = ["books", "reading", "read", "to_read", "pages", "avg_pages"]
+
+ items = [(labels[k], stats.get(k, 0)) for k in keys]
+
+ if layout == "list":
+ parts.append(f"
")
+ cols_per_row = 1
+ else:
+ cols_per_row = min(len(items), 3)
+ if cols_per_row == 0:
+ cols_per_row = 1
+ parts.append(f"
")
+
+ for label, value in items:
+ parts.append(f"
")
+ parts.append(
+ f"
{html.escape(str(value))}
"
+ )
+ if not hide_labels:
+ parts.append(
+ f"
"
+ f"{html.escape(label)}
"
+ )
+ parts.append("
")
+ parts.append("
")
+
+ body = "".join(parts)
+
+ return f"""
+
+
+
+
+
+
+
+{body}
+
+"""
+
+
+@router.get("/stats", response_class=HTMLResponse)
+def get_embed_stats(
+ request: Request,
+ token: str = Query(..., description="Embed token"),
+ theme: str = Query("light", description="Theme: light|dark"),
+ accent: str = Query("#3b82f6", description="Accent hex color"),
+ radius: str = Query("md", description="Border radius: none|sm|md|lg|xl"),
+ density: str = Query("normal", description="Density: compact|normal|comfortable"),
+ hide_labels: bool = Query(False, description="Hide text labels"),
+ show: Optional[str] = Query(None, description="Comma-separated stat keys to display"),
+ lang: str = Query("en", description="HTML lang attribute"),
+ font_scale: float = Query(1.0, ge=0.5, le=3.0, description="Font size multiplier"),
+ layout: str = Query("grid", description="Layout: grid|list"),
+ session: Session = Depends(get_session),
+):
+ user = _verify_embed_token(token, session, request)
+
+ books = list(
+ session.exec(
+ select(Book).where(Book.user_id == user.id)
+ ).all()
+ )
+
+ stats = _compute_stats(books)
+
+ show_set: Optional[set[str]] = None
+ if show:
+ keys = {k.strip() for k in show.split(",") if k.strip()}
+ invalid = keys - VALID_STAT_KEYS
+ if invalid:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"Invalid stat keys: {', '.join(sorted(invalid))}. Valid: {', '.join(sorted(VALID_STAT_KEYS))}",
+ )
+ show_set = keys if keys else None
+
+ if layout not in LAYOUT_MODES:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"Invalid layout '{layout}'. Valid: {', '.join(sorted(LAYOUT_MODES))}",
+ )
+
+ html_content = _render_stats_html(
+ stats, theme, accent, radius, density, hide_labels, lang, font_scale, layout, show_set,
+ )
+
+ return Response(
+ content=html_content,
+ media_type="text/html; charset=utf-8",
+ headers={
+ "Cache-Control": "private, max-age=60",
+ "X-Content-Type-Options": "nosniff",
+ "Referrer-Policy": "no-referrer",
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors *",
+ },
+ )
diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py
index 256a66aa..fa6c169b 100644
--- a/backend/app/routers/profile.py
+++ b/backend/app/routers/profile.py
@@ -9,14 +9,17 @@
clear_browser_session,
ensure_password_complexity,
generate_api_key,
+ generate_embed_token,
get_api_key_prefix,
+ get_embed_token_prefix,
get_password_hash,
hash_api_key,
+ hash_embed_token,
require_user,
)
from app.config import settings as app_settings
from app.database import get_session
-from app.models import ApiKey, User, UserSettings
+from app.models import ApiKey, EmbedToken, User, UserSettings
from app.schemas import (
ApiKeyCreate,
ApiKeyCreateResponse,
@@ -24,6 +27,10 @@
ConfirmationPhrase,
DataResetDeleted,
DataResetResponse,
+ EmbedTokenCreate,
+ EmbedTokenCreateResponse,
+ EmbedTokenRead,
+ EmbedTokenUpdate,
ProfileUpdate,
UserRead,
UserSettingsRead,
@@ -94,7 +101,6 @@ def get_settings(
user_id=settings.user_id,
language=settings.language,
timezone=settings.timezone,
- quote_service_enabled=app_settings.dashboard_quote_enabled,
theme=settings.theme,
custom_theme=settings.custom_theme,
)
@@ -123,7 +129,6 @@ def update_settings(
user_id=settings.user_id,
language=settings.language,
timezone=settings.timezone,
- quote_service_enabled=app_settings.dashboard_quote_enabled,
theme=settings.theme,
custom_theme=settings.custom_theme,
)
@@ -200,7 +205,7 @@ def list_api_keys(
select(ApiKey).where(
ApiKey.user_id == current_user.id,
ApiKey.revoked_at.is_(None),
- )
+ ).order_by(ApiKey.created_at.desc())
).all()
return [ApiKeyRead.model_validate(k) for k in keys]
@@ -239,3 +244,119 @@ def delete_api_key(
key.revoked_at = utcnow()
session.add(key)
session.commit()
+
+
+@router.get("/embed-tokens", response_model=list[EmbedTokenRead])
+def list_embed_tokens(
+ current_user: User = Depends(require_user),
+ session: Session = Depends(get_session),
+) -> list[EmbedTokenRead]:
+ """List non-revoked embed tokens for the current user."""
+ tokens = session.exec(
+ select(EmbedToken).where(
+ EmbedToken.user_id == current_user.id,
+ EmbedToken.revoked_at.is_(None),
+ ).order_by(EmbedToken.created_at.desc())
+ ).all()
+ return [EmbedTokenRead.model_validate(t) for t in tokens]
+
+
+@router.post("/embed-tokens", response_model=EmbedTokenCreateResponse, status_code=201)
+def create_embed_token(
+ body: EmbedTokenCreate,
+ current_user: User = Depends(require_user),
+ session: Session = Depends(get_session),
+) -> EmbedTokenCreateResponse:
+ """Create a new embed token for the current user."""
+ plain_token = generate_embed_token()
+ token = EmbedToken(
+ user_id=current_user.id,
+ name=body.name,
+ token_prefix=get_embed_token_prefix(plain_token),
+ token_hash=hash_embed_token(plain_token),
+ allowed_origins=body.allowed_origins or None,
+ expires_at=body.expires_at,
+ )
+ session.add(token)
+ session.commit()
+ session.refresh(token)
+ return EmbedTokenCreateResponse(
+ token=plain_token,
+ embed_token=EmbedTokenRead.model_validate(token),
+ )
+
+
+@router.patch("/embed-tokens/{token_id}", response_model=EmbedTokenRead)
+def update_embed_token(
+ token_id: int,
+ body: EmbedTokenUpdate,
+ current_user: User = Depends(require_user),
+ session: Session = Depends(get_session),
+) -> EmbedTokenRead:
+ """Update name, allowed_origins, or expires_at for an embed token."""
+ token = session.get(EmbedToken, token_id)
+ if not token or token.user_id != current_user.id or token.revoked_at is not None:
+ raise HTTPException(status_code=404, detail="Embed token not found")
+
+ update_data = body.model_dump(exclude_unset=True)
+ token.sqlmodel_update(update_data)
+ session.add(token)
+ session.commit()
+ session.refresh(token)
+ return EmbedTokenRead.model_validate(token)
+
+
+@router.post("/embed-tokens/{token_id}/rotate", response_model=EmbedTokenCreateResponse)
+def rotate_embed_token(
+ token_id: int,
+ current_user: User = Depends(require_user),
+ session: Session = Depends(get_session),
+) -> EmbedTokenCreateResponse:
+ """Revoke the current embed token and create a new one with the same settings."""
+ token = session.get(EmbedToken, token_id)
+ if not token or token.user_id != current_user.id or token.revoked_at is not None:
+ raise HTTPException(status_code=404, detail="Embed token not found")
+
+ now = utcnow()
+ if token.expires_at is not None and token.expires_at < now:
+ raise HTTPException(
+ status_code=409,
+ detail="Expired embed tokens cannot be rotated",
+ )
+
+ token.revoked_at = now
+ session.add(token)
+
+ plain_token = generate_embed_token()
+ new_token = EmbedToken(
+ user_id=current_user.id,
+ name=token.name,
+ token_prefix=get_embed_token_prefix(plain_token),
+ token_hash=hash_embed_token(plain_token),
+ scopes=token.scopes,
+ allowed_origins=token.allowed_origins,
+ expires_at=token.expires_at,
+ )
+ session.add(new_token)
+ session.commit()
+ session.refresh(new_token)
+ return EmbedTokenCreateResponse(
+ token=plain_token,
+ embed_token=EmbedTokenRead.model_validate(new_token),
+ )
+
+
+@router.delete("/embed-tokens/{token_id}", status_code=204)
+def delete_embed_token(
+ token_id: int,
+ current_user: User = Depends(require_user),
+ session: Session = Depends(get_session),
+) -> None:
+ """Revoke an embed token by ID."""
+ token = session.get(EmbedToken, token_id)
+ if not token or token.user_id != current_user.id:
+ raise HTTPException(status_code=404, detail="Embed token not found")
+
+ token.revoked_at = utcnow()
+ session.add(token)
+ session.commit()
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 80810a62..96c3b018 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -324,7 +324,6 @@ class UserSettingsRead(SQLModel):
user_id: int
language: str
timezone: str
- quote_service_enabled: bool
theme: str
custom_theme: Optional[str] = None
@@ -419,6 +418,13 @@ class OidcConfigRead(SQLModel):
provider_name: Optional[str] = None
+class AppConfigRead(SQLModel):
+ """Application-level feature flags."""
+ embed_enabled: bool
+ dashboard_quote_enabled: bool
+ thalia_cover_search_enabled: bool
+
+
class OidcLinkRead(SQLModel):
"""OIDC link status."""
linked: bool
@@ -599,6 +605,38 @@ class DataImportPreviewResponse(SQLModel):
errors: list[str] = []
+class EmbedTokenCreate(SQLModel):
+ """Embed token creation request."""
+ name: str = Field(max_length=255)
+ allowed_origins: Optional[str] = None
+ expires_at: Optional[datetime] = None
+
+
+class EmbedTokenUpdate(SQLModel):
+ """Embed token update request."""
+ name: Optional[str] = None
+ allowed_origins: Optional[str] = None
+ expires_at: Optional[datetime] = None
+
+
+class EmbedTokenRead(SQLModel):
+ """Embed token read response (without the raw token value)."""
+ id: int
+ name: str
+ token_prefix: str
+ scopes: str
+ allowed_origins: Optional[str]
+ expires_at: Optional[datetime]
+ last_used_at: Optional[datetime]
+ created_at: datetime
+
+
+class EmbedTokenCreateResponse(SQLModel):
+ """Embed token creation response containing the raw token (shown once)."""
+ token: str
+ embed_token: EmbedTokenRead
+
+
class DataImportExecuteResult(SQLModel):
"""Import execution result summary."""
imported: int
diff --git a/backend/tests/test_embed.py b/backend/tests/test_embed.py
new file mode 100644
index 00000000..2b0e45b5
--- /dev/null
+++ b/backend/tests/test_embed.py
@@ -0,0 +1,359 @@
+"""Tests for embed token lifecycle and the embed HTML widget endpoint."""
+
+import re
+from collections.abc import Callable
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlmodel import Session, select
+
+from app.auth import generate_embed_token, hash_embed_token
+from app.models import Book, EmbedToken, UserRole
+from app.time_utils import utcnow
+
+# ── Helpers ──────────────────────────────────────────────────────────────
+
+
+def _create_token(
+ session: Session,
+ user_id: int,
+ *,
+ name: str = "Test Token",
+ allowed_origins: str | None = None,
+ expires_at: datetime | None = None,
+) -> str:
+ plain = generate_embed_token()
+ token = EmbedToken(
+ user_id=user_id,
+ name=name,
+ token_prefix=plain[:12],
+ token_hash=hash_embed_token(plain),
+ allowed_origins=allowed_origins,
+ expires_at=expires_at,
+ )
+ session.add(token)
+ session.commit()
+ return plain
+
+
+# ── Profile CRUD Tests ───────────────────────────────────────────────────
+
+
+class TestListEmbedTokens:
+ def test_lists_own_tokens(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ resp = client.get("/api/profile/embed-tokens")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data) >= 1
+ assert data[0]["token_prefix"] == plain[:12]
+
+ def test_does_not_list_revoked_tokens(
+ self, client: TestClient, session: Session, create_user_with_key: Callable[..., Any]
+ ) -> None:
+ user, key = create_user_with_key(email="embed_list@example.com")
+ client.headers["X-API-Key"] = key
+ plain = _create_token(session, user.id)
+ # Revoke it
+ token = session.exec(
+ select(EmbedToken).where(EmbedToken.token_hash == hash_embed_token(plain))
+ ).first()
+ assert token is not None
+ token.revoked_at = utcnow()
+ session.add(token)
+ session.commit()
+ resp = client.get("/api/profile/embed-tokens")
+ assert resp.status_code == 200
+ assert all(t["token_prefix"] != plain[:12] for t in resp.json())
+
+ def test_other_user_cannot_see_tokens(
+ self, client: TestClient, session: Session, create_user_with_key: Callable[..., Any]
+ ) -> None:
+ user, key = create_user_with_key(email="other_embed@example.com")
+ client.headers["X-API-Key"] = key
+ _create_token(session, user_id=user.id, name="Secret")
+ resp = client.get("/api/profile/embed-tokens")
+ assert resp.status_code == 200
+ assert len(resp.json()) == 1 # only own tokens
+
+
+class TestCreateEmbedToken:
+ def test_creates_token(self, client: TestClient) -> None:
+ resp = client.post(
+ "/api/profile/embed-tokens",
+ json={"name": "Homarr Widget"},
+ )
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["token"].startswith("le_")
+ assert data["embed_token"]["name"] == "Homarr Widget"
+ assert data["embed_token"]["token_prefix"] == data["token"][:12]
+
+ def test_with_allowed_origins(self, client: TestClient) -> None:
+ resp = client.post(
+ "/api/profile/embed-tokens",
+ json={
+ "name": "With Origins",
+ "allowed_origins": "https://homarr.local,https://dashy.local",
+ },
+ )
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["embed_token"]["allowed_origins"] == "https://homarr.local,https://dashy.local"
+
+ def test_with_expiry(self, client: TestClient) -> None:
+ expires = (datetime.now(timezone.utc) + timedelta(days=90)).isoformat()
+ resp = client.post(
+ "/api/profile/embed-tokens",
+ json={"name": "Expiring", "expires_at": expires},
+ )
+ assert resp.status_code == 201
+
+
+class TestUpdateEmbedToken:
+ def test_updates_name_and_origins(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ token = session.exec(
+ select(EmbedToken).where(EmbedToken.token_hash == hash_embed_token(plain))
+ ).first()
+ assert token is not None
+ resp = client.patch(
+ f"/api/profile/embed-tokens/{token.id}",
+ json={"name": "Renamed", "allowed_origins": "https://example.com"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["name"] == "Renamed"
+ assert resp.json()["allowed_origins"] == "https://example.com"
+
+ def test_not_found_for_other_user(
+ self, client: TestClient, session: Session, create_user_with_key: Callable[..., Any]
+ ) -> None:
+ user, key = create_user_with_key(email="other_update@example.com")
+ client.headers["X-API-Key"] = key
+ resp = client.patch("/api/profile/embed-tokens/99999", json={"name": "Nope"})
+ assert resp.status_code == 404
+
+
+class TestRotateEmbedToken:
+ def test_rotate_returns_new_token(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1, name="Rotate Me")
+ token = session.exec(
+ select(EmbedToken).where(EmbedToken.token_hash == hash_embed_token(plain))
+ ).first()
+ assert token is not None
+ old_id = token.id
+ resp = client.post(f"/api/profile/embed-tokens/{old_id}/rotate")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["token"].startswith("le_")
+ assert data["token"] != plain
+ assert data["embed_token"]["name"] == "Rotate Me"
+ assert data["embed_token"]["id"] != old_id
+ # Old token should be revoked
+ session.expire_all()
+ old = session.get(EmbedToken, old_id)
+ assert old is not None
+ assert old.revoked_at is not None
+
+ def test_cannot_rotate_expired_token(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(
+ session,
+ user_id=1,
+ name="Expired",
+ expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
+ )
+ token = session.exec(
+ select(EmbedToken).where(EmbedToken.token_hash == hash_embed_token(plain))
+ ).first()
+ assert token is not None
+
+ resp = client.post(f"/api/profile/embed-tokens/{token.id}/rotate")
+ assert resp.status_code == 409
+ assert resp.json()["detail"] == "Expired embed tokens cannot be rotated"
+
+
+class TestDeleteEmbedToken:
+ def test_revokes_token(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ token = session.exec(
+ select(EmbedToken).where(EmbedToken.token_hash == hash_embed_token(plain))
+ ).first()
+ assert token is not None
+ resp = client.delete(f"/api/profile/embed-tokens/{token.id}")
+ assert resp.status_code == 204
+ session.expire_all()
+ deleted = session.get(EmbedToken, token.id)
+ assert deleted is not None
+ assert deleted.revoked_at is not None
+
+ def test_not_found(self, client: TestClient) -> None:
+ resp = client.delete("/api/profile/embed-tokens/99999")
+ assert resp.status_code == 404
+
+
+# ── Embed Widget Endpoint Tests ──────────────────────────────────────────
+
+
+class TestEmbedStatsEndpoint:
+ def test_returns_html_with_valid_token(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ resp = client.get(f"/embed/v1/stats?token={plain}")
+ assert resp.status_code == 200
+ assert resp.headers["content-type"].startswith("text/html")
+ assert "" in resp.text
+ assert "Books" in resp.text
+
+ def test_401_for_invalid_token(self, client: TestClient) -> None:
+ resp = client.get("/embed/v1/stats?token=le_invalid")
+ assert resp.status_code == 401
+
+ def test_401_for_revoked_token(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ token = session.exec(
+ select(EmbedToken).where(EmbedToken.token_hash == hash_embed_token(plain))
+ ).first()
+ assert token is not None
+ token.revoked_at = utcnow()
+ session.add(token)
+ session.commit()
+ resp = client.get(f"/embed/v1/stats?token={plain}")
+ assert resp.status_code == 401
+
+ def test_401_for_expired_token(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(
+ session, user_id=1,
+ expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
+ )
+ resp = client.get(f"/embed/v1/stats?token={plain}")
+ assert resp.status_code == 401
+
+ def test_shows_stats_for_user(self, client: TestClient, session: Session) -> None:
+ for i in range(3):
+ session.add(Book(title=f"Book {i}", author="Author", page_count=200, user_id=1, reading_status="read"))
+ session.commit()
+
+ plain = _create_token(session, user_id=1)
+ resp = client.get(f"/embed/v1/stats?token={plain}")
+ assert resp.status_code == 200
+ assert "3" in resp.text
+
+ def test_user_isolation(self, client: TestClient, session: Session) -> None:
+ for i in range(5):
+ session.add(Book(title=f"U1 Book {i}", author="Author", page_count=100, user_id=1, reading_status="want_to_read"))
+ session.commit()
+
+ from app.auth import generate_api_key, get_api_key_prefix, get_password_hash, hash_api_key, encrypt_api_key
+ from app.models import ApiKey, User, UserSettings
+
+ user2 = User(firstname="User", lastname="Two", email="u2@example.com",
+ role=UserRole.user, hashed_password=get_password_hash("secret"))
+ session.add(user2)
+ session.commit()
+ session.refresh(user2)
+ session.add(UserSettings(user_id=user2.id, language="en"))
+ key2 = generate_api_key()
+ session.add(ApiKey(user_id=user2.id, key_prefix=get_api_key_prefix(key2),
+ key_hash=hash_api_key(key2), key_encrypted=encrypt_api_key(key2),
+ description="Key"))
+ session.commit()
+ plain = _create_token(session, user2.id)
+ resp = client.get(f"/embed/v1/stats?token={plain}")
+ assert resp.status_code == 200
+ text = resp.text
+ books_match = re.search(r'
]*>\s*0\s*
', text)
+ assert books_match, f"Expected '0' somewhere in stats, got: {text[:500]}"
+
+ def test_403_for_disallowed_origin(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(
+ session, user_id=1,
+ allowed_origins="https://allowed.example.com",
+ )
+ resp = client.get(
+ f"/embed/v1/stats?token={plain}",
+ headers={"Origin": "https://evil.example.com"},
+ )
+ assert resp.status_code == 403
+
+ def test_wildcard_origin_allows_any(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1) # allowed_origins=None = wildcard
+ resp = client.get(
+ f"/embed/v1/stats?token={plain}",
+ headers={"Origin": "https://any-dashboard.local"},
+ )
+ assert resp.status_code == 200
+
+ def test_style_params_are_applied(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ resp = client.get(
+ f"/embed/v1/stats?token={plain}&theme=dark&accent=%23ff0000&radius=lg&density=compact"
+ )
+ assert resp.status_code == 200
+ assert "#ff0000" in resp.text
+
+ def test_missing_token_returns_422(self, client: TestClient) -> None:
+ resp = client.get("/embed/v1/stats")
+ assert resp.status_code == 422
+
+ def test_show_param_filters_stats(self, client: TestClient, session: Session) -> None:
+ for i in range(3):
+ session.add(Book(title=f"Book {i}", author="Author", page_count=200, user_id=1, reading_status="read"))
+ session.commit()
+ plain = _create_token(session, user_id=1)
+ resp = client.get(f"/embed/v1/stats?token={plain}&show=books,pages")
+ assert resp.status_code == 200
+ assert "Books" in resp.text
+ assert "Pages" in resp.text
+ assert "Reading" not in resp.text
+
+ def test_show_param_invalid_key_returns_422(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ resp = client.get(f"/embed/v1/stats?token={plain}&show=invalid_key")
+ assert resp.status_code == 422
+
+ def test_lang_param_sets_html_lang_and_translates_labels(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ resp = client.get(f"/embed/v1/stats?token={plain}&lang=de")
+ assert resp.status_code == 200
+ assert 'lang="de"' in resp.text
+ assert "Gelesen" in resp.text
+ assert "Books" not in resp.text
+
+ def test_lang_param_with_region_falls_back_to_base_language(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ resp = client.get(f"/embed/v1/stats?token={plain}&lang=fr-CA")
+ assert resp.status_code == 200
+ assert 'lang="fr-CA"' in resp.text
+ assert "Livres" in resp.text
+
+ def test_font_scale_param(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ resp = client.get(f"/embed/v1/stats?token={plain}&font_scale=1.5")
+ assert resp.status_code == 200
+ assert "21.0px" in resp.text
+
+ def test_font_scale_out_of_range_returns_422(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ resp = client.get(f"/embed/v1/stats?token={plain}&font_scale=5.0")
+ assert resp.status_code == 422
+
+ def test_layout_list(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ resp = client.get(f"/embed/v1/stats?token={plain}&layout=list")
+ assert resp.status_code == 200
+ assert "flex-direction:column" in resp.text
+
+ def test_layout_invalid_returns_422(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ resp = client.get(f"/embed/v1/stats?token={plain}&layout=bad")
+ assert resp.status_code == 422
+
+ def test_security_headers(self, client: TestClient, session: Session) -> None:
+ plain = _create_token(session, user_id=1)
+ resp = client.get(f"/embed/v1/stats?token={plain}")
+ assert resp.status_code == 200
+ assert resp.headers.get("cache-control") == "private, max-age=60"
+ assert resp.headers.get("x-content-type-options") == "nosniff"
+ assert resp.headers.get("referrer-policy") == "no-referrer"
+ assert resp.headers.get("content-security-policy") == "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors *"
diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts
index 157d274d..1f10d179 100644
--- a/docs/.vitepress/config.base.ts
+++ b/docs/.vitepress/config.base.ts
@@ -81,9 +81,11 @@ export default defineConfig({
link: '/api/integrations/',
collapsed: true,
items: [
+ { text: 'Embed API', link: '/api/integrations/embed-api' },
{ text: 'Dashy', link: '/api/integrations/dashy' },
{ text: 'Glance', link: '/api/integrations/glance' },
{ text: 'Home Assistant', link: '/api/integrations/homeassistant' },
+ { text: 'Homarr', link: '/api/integrations/homarr' },
{ text: 'Homepage', link: '/api/integrations/homepage' },
],
},
diff --git a/docs/api/integrations/embed-api.md b/docs/api/integrations/embed-api.md
new file mode 100644
index 00000000..c25042ed
--- /dev/null
+++ b/docs/api/integrations/embed-api.md
@@ -0,0 +1,111 @@
+# Embed API
+
+LibrisLog provides an **embed API** that returns a self-contained HTML page
+with your reading statistics. It is designed for iframe widgets in dashboards
+that cannot set custom HTTP headers (Homarr, Homepage, etc.).
+
+The embed endpoint (`/embed/v1/stats`) renders your stats as styled stat cards
+with inline CSS, no JavaScript, and no external dependencies.
+
+## Prerequisites
+
+- A running LibrisLog instance reachable from your dashboard server
+- An **embed token** with access to the embed widget endpoint
+- Embed endpoints must be enabled (default: enabled). To disable, set
+ `EMBED_ENABLED=false` in your environment.
+
+## Creating an Embed Token
+
+1. Go to your LibrisLog [Profile](/guide/using-librislog/profile) page.
+2. Scroll to the **Embed Tokens** section.
+3. Enter a name for your widget (e.g. "My Dashboard").
+4. Optionally limit allowed origins for security. Leave empty (wildcard)
+ or set to your dashboard's URL.
+5. Optionally set an expiry date for the token.
+6. Click **Add token**.
+7. **Copy the displayed token immediately** — it is shown only once.
+
+## Endpoint
+
+```
+GET /embed/v1/stats?token=
+```
+
+The token is passed as a query parameter. No `X-API-Key` header or browser
+session is required, making it ideal for iframes.
+
+### Customizing the Look
+
+Optional query parameters to style the widget:
+
+| Parameter | Default | Values | Description |
+|-----------|---------|--------|-------------|
+| `theme` | `light` | `light`, `dark` | Color theme |
+| `accent` | `#3b82f6` | hex color | Accent color for stat numbers |
+| `radius` | `md` | `none`, `sm`, `md`, `lg`, `xl` | Card border radius |
+| `density` | `normal` | `compact`, `normal`, `comfortable` | Spacing between stat cards |
+| `hide_labels` | `false` | `true`, `false` | Hide text labels |
+| `show` | all stats | `books`, `reading`, `read`, `to_read`, `pages`, `avg_pages` | Comma-separated list of stat keys to display |
+| `lang` | `en` | any language code | HTML `lang` attribute; translates stat labels when a matching locale is available |
+| `font_scale` | `1.0` | `0.5`–`3.0` | Font size multiplier |
+| `layout` | `grid` | `grid`, `list` | Layout mode |
+
+Example with custom styling:
+
+```
+/embed/v1/stats?token=&theme=dark&accent=%23f59e0b&radius=sm&density=compact&hide_labels=true
+```
+
+> **Note:** The accent color must be URL-encoded (e.g. `%23` for `#`).
+
+## How It Works
+
+The embed endpoint returns a minimal, self-contained HTML page with your
+reading statistics. It includes:
+
+- Inline CSS (no external dependencies)
+- No-referrer policy and security headers
+- No JavaScript, no app shell, no auth redirects
+
+Authentication is handled via the embed token in the query string.
+
+## Security
+
+- Embed tokens are scoped to `embed:stats:read` only — they cannot access
+ any other API endpoint.
+- Tokens can be revoked or rotated individually from your Profile page.
+- If configured, origin restrictions prevent token reuse on unauthorized
+ dashboards.
+- The embed response does not include the token in the rendered HTML body.
+- Response headers include:
+ - `Cache-Control: private, max-age=60`
+ - `X-Content-Type-Options: nosniff`
+ - `Referrer-Policy: no-referrer`
+ - `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-ancestors *`
+- The embed endpoint can be fully disabled by setting `EMBED_ENABLED=false`.
+
+## Troubleshooting
+
+**Widget shows "Invalid or revoked token"**
+
+- Verify the token was not revoked or rotated.
+- Check that the token has not expired.
+- Ensure `EMBED_ENABLED` is not set to `false` in your environment.
+
+**Widget returns 403 Forbidden**
+
+- If you configured allowed origins on your token, ensure the `Origin`
+ header sent by your dashboard matches one of the listed origins.
+- Try removing the allowed origins restriction (set to empty/wildcard)
+ for testing.
+
+**Stats are empty (all zeros)**
+
+- The widget displays statistics for the token owner's account. Ensure you
+ have added books to your LibrisLog library.
+
+**CSP errors in browser console**
+
+- The embed endpoint sets a `Content-Security-Policy` header optimized
+ for its minimal HTML. If your dashboard adds a CSP of its own, you may
+ need to adjust it to allow inline styles from the iframe.
diff --git a/docs/api/integrations/homarr.md b/docs/api/integrations/homarr.md
new file mode 100644
index 00000000..ac468749
--- /dev/null
+++ b/docs/api/integrations/homarr.md
@@ -0,0 +1,58 @@
+# Homarr
+
+LibrisLog can be integrated into [Homarr](https://homarr.dev/), a modern
+dashboard for your self-hosted services, using its
+[iframe widget](https://homarr.dev/docs/widgets/iframe/).
+
+This displays your reading statistics as a self-contained stats card on your
+Homarr dashboard.
+
+## Prerequisites
+
+- A running LibrisLog instance reachable from your Homarr server
+- An [embed token](/api/integrations/embed-api#creating-an-embed-token) with
+ access to the embed widget endpoint
+- Embed endpoints must be enabled (default: enabled). See the
+ [Embed API](/api/integrations/embed-api) page for details.
+
+## Configuration
+
+Add an **iframe widget** to your Homarr dashboard:
+
+1. In Homarr, navigate to your dashboard, click **Edit** → **Add a tile**.
+2. Select the **Iframe** widget type.
+3. Configure the widget with the following URL:
+
+```
+/embed/v1/stats?token=
+```
+
+Replace `` with your LibrisLog instance URL (e.g.
+`https://librislog.example.com`) and `` with the token you
+created.
+
+### Customizing the Look
+
+See the [Embed API style parameter reference](/api/integrations/embed-api#customizing-the-look)
+for all available options (`theme`, `accent`, `radius`, `density`,
+`hide_labels`, `show`, `lang`, `font_scale`, `layout`).
+
+Example:
+
+```
+/embed/v1/stats?token=&theme=dark&accent=%23f59e0b&density=compact&hide_labels=true
+```
+
+## Troubleshooting
+
+**Widget does not appear or is cut off**
+
+- Adjust the iframe widget size in Homarr. The widget is responsive and
+ will fit the available space.
+- Try `density=compact` to reduce spacing.
+- Try `hide_labels=true` for a more compact display.
+- Use `show` to display only the stat keys you need.
+
+For other issues (invalid token, 403 Forbidden, empty stats, CSP errors),
+see the [Embed API troubleshooting](/api/integrations/embed-api#troubleshooting)
+guide.
diff --git a/docs/api/integrations/index.md b/docs/api/integrations/index.md
index 38d592a6..9635fbf4 100644
--- a/docs/api/integrations/index.md
+++ b/docs/api/integrations/index.md
@@ -14,8 +14,25 @@ API key to use them. You can create one either:
[Headless Setup](/api/setup#3-create-an-api-key) guide for a
CLI-based workflow.
+## Embed Widgets (No Header Auth)
+
+Some dashboards only support iframe widgets and cannot set custom HTTP
+headers. For these integrations you need an **embed token**, used with the
+[Embed API](/api/integrations/embed-api):
+
+
+
+- **Create one** from your [Profile page](/guide/using-librislog/profile) under
+ "Embed Tokens".
+- Embed tokens are **scoped to embed endpoints only** and can be revoked
+ or rotated independently of API keys.
+- They are passed as query parameter (`?token=...`) in the widget URL.
+
## Available Integrations
+- [Embed API](/api/integrations/embed-api) — Generic embed widget API for
+ iframe-based dashboards. Uses scoped embed tokens and supports custom
+ styling.
- [Dashy](/api/integrations/dashy) — Display your LibrisLog statistics as
styled stat cards on a [Dashy](https://dashy.to/) dashboard using the HTML
embedded widget.
@@ -26,6 +43,8 @@ API key to use them. You can create one either:
reading statistics as sensors in
[Home Assistant](https://www.home-assistant.io/) using the RESTful
integration.
+- [Homarr](/api/integrations/homarr) — Display your LibrisLog statistics on a
+ [Homarr](https://homarr.dev/) dashboard using the iframe widget.
- [Homepage](/api/integrations/homepage) — Display your LibrisLog statistics
on a [Homepage](https://gethomepage.dev/) dashboard using the custom API
widget.
diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md
index dde0f2fe..02fa02b5 100644
--- a/docs/guide/using-librislog/profile.md
+++ b/docs/guide/using-librislog/profile.md
@@ -26,6 +26,22 @@ Create and manage API keys for headless access to the REST API. Each key can hav
See the [API Keys guide](/guide/api-keys) for detailed setup instructions.
+## Embed Tokens
+
+Create scoped embed tokens for iframe dashboard integrations (e.g. Homarr). Each token can have an optional name and a comma-separated list of allowed origins. You can also configure an expiry date.
+
+To create a token:
+
+1. Enter a name for your token.
+2. Optionally restrict allowed origins (comma-separated URLs). Leave empty for wildcard access.
+3. Click **Add token**.
+4. **Copy the displayed token immediately** — it is shown only once.
+
+Existing tokens can be **rotated** (revokes the old token and creates a new one with the same settings) or **deleted** from the list.
+
+See the [Embed API](/api/integrations/embed-api) integration guide for usage
+details and a list of supported dashboard integrations.
+
## Data Management
Two data management tools are available:
diff --git a/docs/public/screenshots/profile-thumb.png b/docs/public/screenshots/profile-thumb.png
index 2623f87c..5cf41245 100644
Binary files a/docs/public/screenshots/profile-thumb.png and b/docs/public/screenshots/profile-thumb.png differ
diff --git a/docs/public/screenshots/profile.png b/docs/public/screenshots/profile.png
index 24325392..560380e5 100644
Binary files a/docs/public/screenshots/profile.png and b/docs/public/screenshots/profile.png differ
diff --git a/frontend/e2e/specs/11-profile.spec.ts b/frontend/e2e/specs/11-profile.spec.ts
index bbbcbae1..8815933d 100644
--- a/frontend/e2e/specs/11-profile.spec.ts
+++ b/frontend/e2e/specs/11-profile.spec.ts
@@ -54,4 +54,63 @@ test.describe('Profile', () => {
await expect(page.locator('body')).toContainText(/key|api/i);
}
});
+
+ test('11.5 create embed token', async ({ page }) => {
+ await page.goto('/profile');
+ await page.waitForTimeout(1000);
+
+ await page.locator('#section-embed-tokens').scrollIntoViewIfNeeded();
+ await page.waitForTimeout(500);
+
+ const input = page.locator('input[name="embed-token-name"]');
+ await input.fill('E2E Test Widget');
+ await page.locator('#section-embed-tokens button.btn-primary').first().click();
+ await page.waitForTimeout(1000);
+
+ await expect(page.locator('#section-embed-tokens')).toContainText(/le_/);
+
+ const tokenText = await page.locator('#section-embed-tokens div.font-mono.break-all').first().textContent();
+ expect(tokenText).toBeTruthy();
+
+ if (tokenText) {
+ const backendHealthUrl = process.env.E2E_BACKEND_URL || 'http://backend:8000/api/health';
+ const backendOrigin = new URL(backendHealthUrl).origin;
+ const iframeUrl = `${backendOrigin}/embed/v1/stats?token=${tokenText.trim()}`;
+ const resp = await page.request.get(iframeUrl);
+ expect(resp.status()).toBe(200);
+ const html = await resp.text();
+ expect(html).toContain('Books');
+ expect(html).toMatch(/^/i);
+ }
+ });
+
+ test('11.6 embed tokens section respects embed_enabled flag', async ({ page }) => {
+ // Mock the config endpoint to simulate embed being disabled
+ await page.route('**/api/config', async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ embed_enabled: false,
+ dashboard_quote_enabled: true,
+ thalia_cover_search_enabled: false
+ })
+ });
+ });
+
+ await page.goto('/profile');
+ await page.waitForTimeout(1000);
+
+ // The embed tokens section should not exist in the DOM
+ await expect(page.locator('#section-embed-tokens')).toHaveCount(0);
+
+ // Remove the route override so next navigation uses real config
+ await page.unroute('**/api/config');
+
+ await page.goto('/profile');
+ await page.waitForTimeout(1000);
+
+ // Now the embed tokens section should be visible
+ await expect(page.locator('#section-embed-tokens')).toBeVisible();
+ });
});
diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts
index 4d811e94..f6491ae4 100644
--- a/frontend/src/lib/api.test.ts
+++ b/frontend/src/lib/api.test.ts
@@ -113,3 +113,109 @@ describe('api.books.list', () => {
expect(url).not.toContain('q=');
});
});
+
+describe('api.profile.embedTokens', () => {
+ afterEach(() => {
+ apiKey.set(null);
+ csrfToken.set(null);
+ vi.restoreAllMocks();
+ });
+
+ const mockHeaders = { get: () => 'application/json' };
+
+ it('listEmbedTokens calls GET /profile/embed-tokens', async () => {
+ apiKey.set('test-key');
+ csrfToken.set('test-csrf');
+
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ headers: mockHeaders,
+ json: async () => [{ id: 1, name: 'Test', token_prefix: 'le_abc', scopes: 'embed:stats:read', allowed_origins: null, expires_at: null, last_used_at: null, created_at: '2024-01-01T00:00:00Z' }]
+ } as unknown as Response);
+
+ const result = await api.profile.listEmbedTokens();
+ expect(result).toHaveLength(1);
+ const [url] = fetchMock.mock.calls[0] as [string];
+ expect(url).toContain('/profile/embed-tokens');
+ });
+
+ it('createEmbedToken sends POST with name', async () => {
+ apiKey.set('test-key');
+ csrfToken.set('test-csrf');
+
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ headers: mockHeaders,
+ json: async () => ({ token: 'le_newtoken123', embed_token: { id: 2, name: 'My Widget', token_prefix: 'le_newtoken1', scopes: 'embed:stats:read', allowed_origins: null, expires_at: null, last_used_at: null, created_at: '2024-01-01T00:00:00Z' } })
+ } as unknown as Response);
+
+ const result = await api.profile.createEmbedToken({ name: 'My Widget' });
+ expect(result.token).toBe('le_newtoken123');
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(init.method).toBe('POST');
+ expect(init.body).toContain('My Widget');
+ });
+
+ it('createEmbedToken includes allowed_origins and expires_at when provided', async () => {
+ apiKey.set('test-key');
+ csrfToken.set('test-csrf');
+
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ headers: mockHeaders,
+ json: async () => ({ token: 'le_newtoken456', embed_token: { id: 3, name: 'With Origins', token_prefix: 'le_newtoken4', scopes: 'embed:stats:read', allowed_origins: 'https://homarr.local', expires_at: null, last_used_at: null, created_at: '2024-01-01T00:00:00Z' } })
+ } as unknown as Response);
+
+ await api.profile.createEmbedToken({ name: 'With Origins', allowed_origins: 'https://homarr.local' });
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(init.body).toContain('https://homarr.local');
+ });
+
+ it('updateEmbedToken sends PATCH with fields', async () => {
+ apiKey.set('test-key');
+ csrfToken.set('test-csrf');
+
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ headers: mockHeaders,
+ json: async () => ({ id: 1, name: 'Renamed', token_prefix: 'le_abc', scopes: 'embed:stats:read', allowed_origins: null, expires_at: null, last_used_at: null, created_at: '2024-01-01T00:00:00Z' })
+ } as unknown as Response);
+
+ await api.profile.updateEmbedToken(1, { name: 'Renamed' });
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(init.method).toBe('PATCH');
+ expect(fetchMock.mock.calls[0][0]).toContain('/profile/embed-tokens/1');
+ });
+
+ it('rotateEmbedToken sends POST .../rotate', async () => {
+ apiKey.set('test-key');
+ csrfToken.set('test-csrf');
+
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ headers: mockHeaders,
+ json: async () => ({ token: 'le_rotated', embed_token: { id: 4, name: 'Rotated', token_prefix: 'le_rotated', scopes: 'embed:stats:read', allowed_origins: null, expires_at: null, last_used_at: null, created_at: '2024-01-01T00:00:00Z' } })
+ } as unknown as Response);
+
+ await api.profile.rotateEmbedToken(1);
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(init.method).toBe('POST');
+ expect(fetchMock.mock.calls[0][0]).toContain('/profile/embed-tokens/1/rotate');
+ });
+
+ it('deleteEmbedToken sends DELETE', async () => {
+ apiKey.set('test-key');
+ csrfToken.set('test-csrf');
+
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ headers: mockHeaders,
+ json: async () => ({})
+ } as unknown as Response);
+
+ await api.profile.deleteEmbedToken(1);
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(init.method).toBe('DELETE');
+ expect(fetchMock.mock.calls[0][0]).toContain('/profile/embed-tokens/1');
+ });
+});
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index be93ad17..9378fd44 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,5 +1,6 @@
import type {
ApiKeyMeta,
+ AppConfig,
BookListResponse,
DataExportDataset,
DataExportFormat,
@@ -10,6 +11,8 @@ import type {
DataImportPreviewResponse,
DataImportValidateResponse,
DataResetResponse,
+ EmbedTokenCreateResponse,
+ EmbedTokenMeta,
HygieneAttribute,
HygieneBatchUpdateRequest,
HygieneBatchUpdateResponse,
@@ -165,6 +168,34 @@ export const api = {
return request(`/profile/api-keys/${id}`, { method: 'DELETE' });
},
+ listEmbedTokens(): Promise {
+ return request('/profile/embed-tokens');
+ },
+
+ createEmbedToken(data: { name: string; allowed_origins?: string | null; expires_at?: string | null }): Promise {
+ return request('/profile/embed-tokens', {
+ method: 'POST',
+ body: JSON.stringify(data)
+ });
+ },
+
+ updateEmbedToken(id: number, data: { name?: string; allowed_origins?: string | null; expires_at?: string | null }): Promise {
+ return request(`/profile/embed-tokens/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(data)
+ });
+ },
+
+ rotateEmbedToken(id: number): Promise {
+ return request(`/profile/embed-tokens/${id}/rotate`, {
+ method: 'POST'
+ });
+ },
+
+ deleteEmbedToken(id: number): Promise {
+ return request(`/profile/embed-tokens/${id}`, { method: 'DELETE' });
+ },
+
resetData(confirmation: string): Promise {
return request('/profile/reset-data', {
method: 'POST',
@@ -221,6 +252,12 @@ export const api = {
}
},
+ app: {
+ config(): Promise {
+ return request('/config');
+ }
+ },
+
oidc: {
config(): Promise {
return request('/oidc/config');
diff --git a/frontend/src/lib/components/UserMenu.svelte b/frontend/src/lib/components/UserMenu.svelte
index 25e6af82..86fa5959 100644
--- a/frontend/src/lib/components/UserMenu.svelte
+++ b/frontend/src/lib/components/UserMenu.svelte
@@ -6,7 +6,7 @@
import { _ } from '$lib/i18n';
import { cycleTheme, applyThemeToDocument, saveThemeToStorage, getThemeMode, getThemeIcon, getCustomTheme, getThemeVersion } from '$lib/stores/theme';
import AnimalAvatar from '$lib/components/AnimalAvatar.svelte';
- import { Sun, Moon, Palette, CloudDownload } from '@lucide/svelte';
+ import { Sun, Moon, Palette, CloudDownload, ExternalLink } from '@lucide/svelte';
let { floating = true, updateInfo = null }: { floating?: boolean; updateInfo?: UpdateInfo | null } = $props();
@@ -108,6 +108,12 @@
{/if}
(open = false)}>{$_('user.profile')}
(open = false)}>{$_('user.about')}
+
+ (open = false)}>
+
+ {$_('user.docs')}
+
+