Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions backend/alembic/versions/1a2b3c4d5e6f_add_embed_token_table.py
Original file line number Diff line number Diff line change
@@ -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")
25 changes: 25 additions & 0 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
1 change: 1 addition & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 12 additions & 0 deletions backend/app/i18n/de.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
12 changes: 12 additions & 0 deletions backend/app/i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"embed": {
"stats": {
"books": "Books",
"reading": "Reading",
"read": "Read",
"to_read": "To Read",
"pages": "Pages",
"avg_pages": "Avg/Book"
}
}
}
12 changes: 12 additions & 0 deletions backend/app/i18n/es.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
12 changes: 12 additions & 0 deletions backend/app/i18n/fr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"embed": {
"stats": {
"books": "Livres",
"reading": "En cours",
"read": "Lu",
"to_read": "À lire",
"pages": "Pages",
"avg_pages": "Pages/Livre"
}
}
}
12 changes: 12 additions & 0 deletions backend/app/i18n/zh.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"embed": {
"stats": {
"books": "图书",
"reading": "正在阅读",
"read": "已读",
"to_read": "待读",
"pages": "页数",
"avg_pages": "每本页数"
}
}
}
5 changes: 4 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
30 changes: 30 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
18 changes: 18 additions & 0 deletions backend/app/routers/config.py
Original file line number Diff line number Diff line change
@@ -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,
)
30 changes: 22 additions & 8 deletions backend/app/routers/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,35 +80,49 @@ def _wrap_docs_html(html: str) -> HTMLResponse:
return HTMLResponse(html.replace("</head>", f"{custom_css}</head>"))


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)
Loading