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')} + +